Compare commits

..
Author SHA1 Message Date
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
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
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]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Mohd 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
dependabot[bot]Claude Sonnet 5dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Mohd KaifKaifAhmad1
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
56 changed files with 8432 additions and 1369 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -93,6 +93,7 @@ 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
+11
View File
@@ -20,6 +20,17 @@ 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.
#
# 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.13-slim@sha256:7ce4b6dfe35e55397b7cda544f8a13f191b7ae28dc5aad71fe664dbc9bc2623f AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
+3
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
+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
+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/deterministicExplorerRendering.test.ts tests/explorerCapabilities.test.tsx tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts tests/ontologyEditorModel.test.ts tests/graphColorLegend.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
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;
@@ -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"
@@ -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 ? (
@@ -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,
+112
View File
@@ -0,0 +1,112 @@
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);
await page.getByRole("button", { name: "Focused", exact: true }).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`,
);
}
});
+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
+1 -1
View File
@@ -190,7 +190,7 @@ graph-all = [
tripletstore-oxigraph = ["pyoxigraph>=0.5.0"]
# ---- Vector Store Backends ----
vectorstore-qdrant = ["qdrant-client>=1.0.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"]
+9 -9
View File
@@ -4409,15 +4409,15 @@ pillow==12.3.0 \
# 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 \
+112 -13
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:
@@ -917,11 +936,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 +1174,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 +2282,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 +3809,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):
+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",
+102 -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:",
@@ -1821,6 +1839,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 +1904,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: (
+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.
+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.
+35 -10
View File
@@ -2316,7 +2316,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 +2348,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 +2531,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."""
+207 -9
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,
)
@@ -262,6 +360,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 +383,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 +437,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)
@@ -422,6 +544,12 @@ 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:
@@ -498,13 +626,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..."
@@ -625,6 +765,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 +881,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:
+32 -7
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,
}
@@ -695,9 +698,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",
+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": {}}
+105
View File
@@ -14,7 +14,15 @@ from typing import Any, Optional
log = logging.getLogger("semantica.mcp.session")
# Backends the retrieval tools can actually support end to end. faiss
# and pgvector have no metadata-scoped delete, so update_document and
# remove_document cannot work on them; selecting them fails fast here
# instead of blowing up mid-update.
SUPPORTED_VECTOR_BACKENDS = ("inmemory", "sqlite")
_graph: Optional[Any] = None
_embedder: Optional[Any] = None
_vector_store: Optional[Any] = None
# Tracks whether the last graph initialisation successfully loaded the
# configured SEMANTICA_KG_PATH file. When True (or no path was configured)
@@ -59,6 +67,103 @@ def get_graph() -> Any:
return _graph
def get_embedder() -> Any:
"""
Return the shared EmbeddingGenerator instance, creating it on first call.
Used by the semantic retrieval tools (#1235) to embed documents and
queries with one consistent model, so stored vectors and query
vectors always share the same dimensionality.
"""
global _embedder
if _embedder is None:
from semantica.embeddings import EmbeddingGenerator
_embedder = EmbeddingGenerator()
log.info(
"Embedding generator initialised (method=%s)",
_embedder.get_text_method(),
)
return _embedder
def get_vector_store() -> Any:
"""
Return the shared VectorStore instance, creating it on first call.
Backend selection:
``SEMANTICA_VECTOR_BACKEND`` ``inmemory`` (default) or ``sqlite``.
The ``sqlite`` backend additionally requires
``SEMANTICA_VECTOR_DB_PATH``. Other VectorStore backends (faiss,
pgvector) are rejected: they lack the metadata-scoped delete the
update/remove tools need.
``SEMANTICA_VECTOR_PATH`` a *directory* previously written by
``VectorStore.save()``. If it exists, the store is loaded from it
on start. Note this is a directory, unlike SEMANTICA_KG_PATH which
is a single JSON file. The persisted dimension must match the
active embedder or startup fails otherwise queries would either
error on shape mismatch or silently rank across incompatible
embedding spaces.
"""
global _vector_store
if _vector_store is None:
from semantica.vector_store import VectorStore
backend = os.environ.get("SEMANTICA_VECTOR_BACKEND", "inmemory").strip().lower()
if backend not in SUPPORTED_VECTOR_BACKENDS:
raise ValueError(
f"SEMANTICA_VECTOR_BACKEND={backend!r} is not supported by the "
"MCP retrieval tools; supported backends: "
+ ", ".join(SUPPORTED_VECTOR_BACKENDS)
)
config: dict = {}
if backend == "sqlite":
db_path = os.environ.get("SEMANTICA_VECTOR_DB_PATH", "").strip()
if not db_path:
raise ValueError(
"SEMANTICA_VECTOR_BACKEND=sqlite requires "
"SEMANTICA_VECTOR_DB_PATH to point at the database file"
)
config["db_path"] = db_path
# VectorStore defaults to dimension 768, which does not match the
# default embedding model (all-MiniLM-L6-v2 = 384, hash fallback
# = 128). Always derive it from the embedder so store and
# queries stay consistent.
embedder = get_embedder()
config["dimension"] = embedder.text_embedder.get_embedding_dimension()
store = VectorStore(backend=backend, config=config)
vector_path = os.environ.get("SEMANTICA_VECTOR_PATH", "").strip()
if vector_path and os.path.isdir(vector_path):
try:
store.load(vector_path)
log.info("Vector store loaded from %s", vector_path)
except Exception as exc:
raise ValueError(
f"Could not load vector store from {vector_path}: {exc}"
) from exc
loaded_dim = getattr(store, "dimension", None)
if loaded_dim and loaded_dim != config["dimension"]:
raise ValueError(
f"Persisted vector store at {vector_path} has dimension "
f"{loaded_dim}, but the active embedder produces "
f"{config['dimension']}. Re-embed the corpus or point "
"SEMANTICA_VECTOR_PATH at a store built with the same model."
)
_vector_store = store
log.info("Vector store initialised (backend=%s)", backend)
return _vector_store
def reset_vector_store() -> None:
"""Reset the vector store singleton (mainly useful in tests)."""
global _vector_store
_vector_store = None
def is_persistence_safe() -> bool:
"""Return True when it is safe to write mutations back to SEMANTICA_KG_PATH.
+2
View File
@@ -9,6 +9,7 @@ from .export import EXPORT_TOOLS
from .extraction import EXTRACTION_TOOLS
from .graph import GRAPH_TOOLS
from .reasoning import REASONING_TOOLS
from .retrieval import RETRIEVAL_TOOLS
# Ordered list — exposed to the MCP client via tools/list
TOOL_DEFINITIONS = (
@@ -17,6 +18,7 @@ TOOL_DEFINITIONS = (
+ GRAPH_TOOLS
+ REASONING_TOOLS
+ EXPORT_TOOLS
+ RETRIEVAL_TOOLS
)
__all__ = ["TOOL_DEFINITIONS"]
+533
View File
@@ -0,0 +1,533 @@
"""
Semantic retrieval tools store, retrieve, update and remove documents
in a vector store, combined with knowledge-graph context (#1235).
Design notes:
Documents are chunked with a fixed sliding window (default 1000 chars,
200 overlap) and every chunk carries full provenance metadata:
chunk_id, source, authority, version, project, content hash, status
and character offsets.
(source, version) is the upsert key. The content hash only decides
whether a re-store can be skipped as a no-op.
Updates and removals on the in-memory backend rebuild the store from
scratch (read everything, filter, clear, re-store) instead of calling
delete_vectors. In-memory ids are derived from ``len(self.vectors)``
and fall back after a delete, so deleting then writing can overwrite
live data (#1029). Rebuilding from an empty dict starts the counter
at zero nothing to collide with. The real fix for #1029 (ids that
never get reused) belongs in its own PR.
Retrieval results are combined with related graph nodes: for each hit
source we look up ContextGraph nodes tagged with the same
``metadata.source`` and attach their 1-hop neighbours.
"""
from __future__ import annotations
import hashlib
import logging
import os
from typing import Any, Dict, List, Tuple
import numpy as np
from ..schemas import (
REMOVE_DOCUMENT,
RETRIEVE_CONTEXT,
STORE_DOCUMENT,
UPDATE_DOCUMENT,
)
from ..session import get_embedder, get_graph, get_vector_store
log = logging.getLogger("semantica.mcp.tools.retrieval")
DEFAULT_CHUNK_SIZE = 1000
DEFAULT_CHUNK_OVERLAP = 200
MAX_TOP_K = 10
FILTER_OVERFETCH = 3
MAX_FILTER_MATCHES = 10_000
MAX_CHUNKS_PER_DOC = 10_000
# Metadata fields owned by the upsert logic. Caller-supplied metadata
# can add extra context but must not rewrite provenance: overwriting
# source/version/hash/status would break the (source, version) upsert
# key, the idempotent no-op check, and retrieval filters.
PROTECTED_META_KEYS = frozenset(
{
"chunk_id",
"text",
"source",
"authority",
"version",
"hash",
"status",
"chunk_index",
"char_start",
"char_end",
"project",
}
)
def _chunk_text(text: str, chunk_size: int, chunk_overlap: int) -> List[Tuple[int, int, str]]:
"""Split text into (char_start, char_end, chunk) windows."""
if chunk_overlap >= chunk_size:
raise ValueError("chunk_overlap must be smaller than chunk_size")
chunks: List[Tuple[int, int, str]] = []
start = 0
n = len(text)
while start < n:
end = min(start + chunk_size, n)
chunks.append((start, end, text[start:end]))
if end >= n:
break
start = end - chunk_overlap
return chunks
def _chunk_id(source: str, version: str, index: int, text: str) -> str:
"""Stable chunk id derived from the location key and chunk content."""
digest = hashlib.sha256(
f"{source}|{version}|{index}|{text}".encode("utf-8")
).hexdigest()
return f"chk_{digest[:16]}"
def _doc_hash(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _find_matching_rows(store: Any, source: str, version: str) -> List[Dict[str, Any]]:
"""
Return rows (``{id, vector, metadata}``) matching (source, version).
The persistent branch pulls whole rows (vector included) into memory;
the limit keeps the scan bounded. Documents beyond MAX_CHUNKS_PER_DOC
chunks are rejected at ingestion, so the cap cannot leave stale
chunks behind on update/remove.
"""
if getattr(store, "backend", "") == "inmemory":
rows = []
for vid, vec in getattr(store, "vectors", {}).items():
meta = getattr(store, "metadata", {}).get(vid) or {}
if meta.get("source") == source and meta.get("version") == version:
rows.append({"id": vid, "vector": vec, "metadata": meta})
return rows
backend_store = getattr(store, "_backend_store", None)
if backend_store is not None and hasattr(backend_store, "filter_by_metadata"):
return backend_store.filter_by_metadata(
{"source": source, "version": version}, limit=MAX_FILTER_MATCHES
)
raise NotImplementedError(
f"Backend {type(backend_store).__name__} does not support metadata lookup; "
"cannot locate chunks for update/remove"
)
def _find_matching_ids(store: Any, source: str, version: str) -> List[str]:
"""Return every vector id whose metadata matches (source, version)."""
return [row["id"] for row in _find_matching_rows(store, source, version)]
def _remove_ids(store: Any, remove_ids: List[str]) -> None:
"""Remove vectors by id, avoiding the #1029 in-memory id collision."""
if getattr(store, "backend", "") == "inmemory":
# Full rebuild: read all, filter in memory, clear, re-store once.
# store_vectors derives ids from len(self.vectors), and the dicts
# are empty here, so the counter restarts at zero — no reuse of
# ids that are still referenced anywhere.
remove = set(remove_ids)
vectors = getattr(store, "vectors", {})
metadata = getattr(store, "metadata", {})
saved_vectors = dict(vectors)
saved_metadata = dict(metadata)
keep_vectors = []
keep_meta = []
for vid, vec in list(vectors.items()):
if vid in remove:
continue
keep_vectors.append(vec)
keep_meta.append(metadata.get(vid, {}))
vectors.clear()
metadata.clear()
try:
if keep_vectors:
store.store_vectors(keep_vectors, keep_meta)
except Exception:
# Restore the pre-rebuild state so a failed re-store does not
# silently drop every surviving document.
vectors.update(saved_vectors)
metadata.update(saved_metadata)
store.indexer.create_index(
list(vectors.values()), list(vectors.keys())
)
raise
return
# Persistent backends do not have the len-based id collision, so a
# direct delete is safe there.
store.delete_vectors(remove_ids)
def _persist(store: Any) -> Any:
"""
Persist the store when SEMANTICA_VECTOR_PATH is configured.
Returns ``None`` when no path is configured, ``True`` on success and
``False`` when saving failed surfaced in tool results so a caller
can tell an in-memory-only write from a durable one.
"""
path = os.environ.get("SEMANTICA_VECTOR_PATH", "").strip()
if not path:
return None
try:
store.save(path)
except Exception as exc:
log.warning("Could not persist vector store to %s: %s", path, exc)
return False
return True
def _node_source(meta: Any) -> str:
"""
Extract a node's source tag from its metadata.
ContextGraph.add_node nests the caller-supplied metadata dict one
level down (``{'label': ..., 'metadata': {...}}``), while nodes added
through other paths may carry ``source`` directly. Check both.
"""
if not isinstance(meta, dict):
return ""
direct = meta.get("source")
if direct:
return str(direct)
nested = meta.get("metadata")
if isinstance(nested, dict):
return str(nested.get("source", "") or "")
return ""
def _graph_relationships(sources: List[str], max_per_source: int = 3) -> List[Dict[str, Any]]:
"""
Collect 1-hop graph neighbours for nodes tagged with the hit sources.
Node lookup matches the node's source tag against the stored document
sources. Failures degrade to an empty list graph context is a
bonus, never a hard dependency of retrieval.
"""
if not sources:
return []
try:
graph = get_graph()
nodes = list(graph.find_nodes())
except Exception as exc:
log.debug("Graph context unavailable: %s", exc)
return []
relationships: List[Dict[str, Any]] = []
seen: set = set()
for source in sources:
anchor = None
for n in nodes:
if _node_source(n.get("metadata")) == source:
anchor = n
break
if anchor is None:
continue
try:
neighbors = graph.get_neighbors(anchor["id"], hops=1)
except Exception as exc:
log.debug("get_neighbors failed for %s: %s", anchor.get("id"), exc)
continue
added = 0
for nb in neighbors:
key = (anchor.get("id"), nb.get("id"), nb.get("relationship"))
if key in seen:
continue
seen.add(key)
relationships.append(
{
"node": {
"id": anchor.get("id"),
"type": anchor.get("type"),
"content": str(anchor.get("content") or "")[:200],
"source": source,
},
"related": {
"id": nb.get("id"),
"type": nb.get("type"),
"content": str(nb.get("content") or "")[:200],
},
"relationship": nb.get("relationship"),
}
)
added += 1
if added >= max_per_source:
break
return relationships
def _upsert(args: dict, action: str) -> dict:
"""Shared implementation for store_document and update_document."""
content = args.get("content", "")
source = str(args.get("source", "")).strip()
if not content or not source:
return {"error": "content and source are required"}
authority = str(args.get("authority", "")).strip()
if action == "store" and not authority:
return {"error": "authority is required"}
version = str(args.get("version", "")).strip() or "v1"
project = str(args.get("project", "")).strip() or None
chunk_size = int(args.get("chunk_size", DEFAULT_CHUNK_SIZE))
chunk_overlap = int(args.get("chunk_overlap", DEFAULT_CHUNK_OVERLAP))
if chunk_overlap >= chunk_size:
return {"error": "chunk_overlap must be smaller than chunk_size"}
extra = args.get("metadata") or {}
if not isinstance(extra, dict):
return {"error": "metadata must be an object"}
doc_hash = _doc_hash(content)
try:
store = get_vector_store()
embedder = get_embedder()
existing_ids = _find_matching_ids(store, source, version)
if action == "update" and not existing_ids:
return {"status": "not_found", "source": source, "version": version}
existing_first: Dict[str, Any] = {}
if existing_ids:
existing_first = store.get_metadata(existing_ids[0]) or {}
if action == "store" and existing_first.get("hash") == doc_hash:
# Same content already stored under (source, version) —
# skip re-embedding entirely.
return {
"status": "unchanged",
"source": source,
"version": version,
"chunk_ids": [
(store.get_metadata(vid) or {}).get("chunk_id")
for vid in existing_ids
],
}
chunks = _chunk_text(content, chunk_size, chunk_overlap)
if len(chunks) > MAX_CHUNKS_PER_DOC:
return {
"error": (
f"document produces {len(chunks)} chunks, above the "
f"{MAX_CHUNKS_PER_DOC}-chunk limit; split it into smaller "
"documents or raise chunk_size"
)
}
vectors = np.asarray(
embedder.generate_embeddings([c_text for _, _, c_text in chunks])
)
if vectors.ndim == 1:
vectors = vectors.reshape(1, -1)
if vectors.shape[0] != len(chunks):
return {
"error": (
f"embedder returned {vectors.shape[0]} vectors "
f"for {len(chunks)} chunks"
)
}
final_authority = authority or existing_first.get("authority") or "unknown"
final_project = project or existing_first.get("project")
old_rows: List[Dict[str, Any]] = []
if existing_ids:
# Snapshot the rows being replaced so a failed write of the
# new chunks can put the old document back instead of leaving
# (source, version) silently empty.
old_rows = _find_matching_rows(store, source, version)
_remove_ids(store, existing_ids)
metas = []
chunk_ids = []
for idx, (start, end, c_text) in enumerate(chunks):
cid = _chunk_id(source, version, idx, c_text)
chunk_ids.append(cid)
meta: Dict[str, Any] = {
"chunk_id": cid,
"text": c_text,
"source": source,
"authority": final_authority,
"version": version,
"hash": doc_hash,
"status": "active",
"chunk_index": idx,
"char_start": start,
"char_end": end,
}
if final_project:
meta["project"] = final_project
for key in extra:
if key in PROTECTED_META_KEYS:
log.debug(
"Ignoring caller metadata key %r: provenance field is "
"managed by the tool",
key,
)
else:
meta[key] = extra[key]
metas.append(meta)
try:
store.store_vectors(list(vectors), metas)
except Exception:
if old_rows:
log.warning(
"Storing new chunks failed for (%s, %s); restoring the "
"previous document",
source,
version,
)
store.store_vectors(
[row["vector"] for row in old_rows],
[row["metadata"] for row in old_rows],
)
raise
persisted = _persist(store)
return {
"status": "stored" if action == "store" else "updated",
"source": source,
"version": version,
"chunk_ids": chunk_ids,
"chunk_count": len(chunk_ids),
"hash": doc_hash,
"persisted": persisted,
}
except Exception as exc:
log.exception("%s_document failed", action)
return {"error": str(exc)}
def handle_store_document(args: dict) -> dict:
"""Chunk a document, embed it, and store it for semantic retrieval."""
return _upsert(args, "store")
def handle_update_document(args: dict) -> dict:
"""Replace the stored content of a (source, version) document."""
return _upsert(args, "update")
def handle_retrieve_context(args: dict) -> dict:
"""Embed a query and return the most relevant stored chunks."""
query = str(args.get("query", "")).strip()
if not query:
return {"error": "query is required", "results": []}
try:
top_k = max(1, min(int(args.get("top_k", 5)), MAX_TOP_K))
except (TypeError, ValueError):
top_k = 5
project = str(args.get("project", "")).strip() or None
try:
store = get_vector_store()
query_vector = np.asarray(get_embedder().generate_embeddings([query]))[0]
# Over-fetch so a project filter can drop hits without starving
# the result list.
fetch_k = top_k * FILTER_OVERFETCH if project else top_k
raw = store.search_vectors(query_vector, k=fetch_k)
results = []
for hit in raw:
meta = hit.get("metadata") or {}
if project and meta.get("project") != project:
continue
results.append(
{
"chunk_id": meta.get("chunk_id", hit.get("id")),
"text": meta.get("text", ""),
"score": hit.get("score"),
"source": meta.get("source"),
"authority": meta.get("authority"),
"version": meta.get("version"),
"project": meta.get("project"),
"status": meta.get("status"),
"hash": meta.get("hash"),
}
)
if len(results) >= top_k:
break
sources = list(dict.fromkeys(r["source"] for r in results if r["source"]))
return {
"query": query,
"results": results,
"count": len(results),
"graph_context": _graph_relationships(sources),
}
except Exception as exc:
log.exception("retrieve_context failed")
return {"error": str(exc), "results": []}
def handle_remove_document(args: dict) -> dict:
"""Remove every chunk stored under (source, version)."""
source = str(args.get("source", "")).strip()
if not source:
return {"error": "source is required"}
version = str(args.get("version", "")).strip() or "v1"
try:
store = get_vector_store()
existing_ids = _find_matching_ids(store, source, version)
if not existing_ids:
return {"status": "not_found", "source": source, "version": version}
_remove_ids(store, existing_ids)
persisted = _persist(store)
return {
"status": "removed",
"source": source,
"version": version,
"removed_chunks": len(existing_ids),
"persisted": persisted,
}
except Exception as exc:
log.exception("remove_document failed")
return {"error": str(exc)}
RETRIEVAL_TOOLS = [
{
"name": "store_document",
"description": (
"Chunk a document, embed the chunks, and store them for semantic "
"retrieval. Keyed on (source, version); storing identical content "
"again is a no-op."
),
"inputSchema": STORE_DOCUMENT,
"_handler": handle_store_document,
},
{
"name": "retrieve_context",
"description": (
"Embed a natural-language query and return the most relevant "
"stored chunks with scores and provenance, combined with related "
"knowledge-graph relationships."
),
"inputSchema": RETRIEVE_CONTEXT,
"_handler": handle_retrieve_context,
},
{
"name": "update_document",
"description": (
"Replace the stored content of a document identified by "
"(source, version). Old chunks are removed and the new content "
"is re-chunked and re-embedded. Returns not_found when no "
"stored document matches (source, version)."
),
"inputSchema": UPDATE_DOCUMENT,
"_handler": handle_update_document,
},
{
"name": "remove_document",
"description": (
"Remove every chunk stored under (source, version) from the "
"vector store."
),
"inputSchema": REMOVE_DOCUMENT,
"_handler": handle_remove_document,
},
]
@@ -452,3 +452,42 @@ def test_find_precedents_sees_lowercase_precedent_edge():
precedents = graph.find_precedents(later)
assert [d.decision_id for d in precedents] == [precedent]
def test_heuristic_cause_reported_once_per_shared_entity_pair():
"""A potential cause found through the shared-entity heuristic must be
reported once, not once per shared entity.
``trace_decision_causality()`` collects ``potential_causes`` by looping
over every entity of the current decision, so a decision sharing two
entities with an earlier one (e.g. the same customer and the same
property) used to be appended twice and produced two identical
"influences" chains.
"""
graph = ContextGraph(advanced_analytics=True)
cause = graph.record_decision(
category="lending", scenario="earlier review", reasoning="r",
outcome="approved", confidence=0.9,
entities=["customer_123", "property_456"],
)
effect = graph.record_decision(
category="risk", scenario="later review", reasoning="r",
outcome="flagged", confidence=0.9,
entities=["customer_123", "property_456"],
)
# Pin timestamps so the earlier/later ordering is deterministic.
graph._decisions[cause]["timestamp"] = 100.0
graph._decisions[effect]["timestamp"] = 200.0
chains = graph.trace_decision_chain(effect)
influence_hops = [
(hop["from"], hop["to"])
for chain in chains
for hop in chain["hops"]
if hop["type"] == "influences"
]
assert influence_hops, "shared-entity heuristic must find the earlier decision"
assert influence_hops.count((cause, effect)) == 1, (
"the same (cause, effect) pair must be reported once, not once per shared entity"
)
+1 -1
View File
@@ -89,7 +89,7 @@ class _DeleteStore:
class _NoDeleteStore:
"""Backend shaped like FAISS/Milvus/Weaviate: no delete surface at all."""
"""Backend shaped like FAISS: no delete surface at all."""
backend = "faiss"
+61
View File
@@ -13,6 +13,7 @@ pytest.importorskip("fastapi")
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.routes.ontology import ( # noqa: E402
_MAX_ANALYSIS_NODES,
OntologyEntry,
_convert_ontology_to_graph,
_node_belongs_to_ontology,
@@ -310,6 +311,66 @@ def test_ontology_graph_ignores_unrelated_data_when_enforcing_size_limit(client)
}
def test_ontology_graph_rejects_oversized_core_and_stops_scanning(client, monkeypatch):
graph = client.app.state.session.graph
for index in range(5_001):
graph.add_node(
f"http://example.org/onto-a#Bulk{index:05d}",
node_type="owl:Class",
content="Bulk",
scheme_uri="http://example.org/onto-a",
)
for index in range(3_000):
graph.add_node(
f"urn:unrelated:{index}",
node_type="owl:Class",
content="Unrelated",
scheme_uri="http://example.org/onto-b",
)
streamed = 0
original_iter_nodes = GraphSession.iter_nodes
def counting_iter_nodes(self, node_type=None):
nonlocal streamed
for node in original_iter_nodes(self, node_type=node_type):
streamed += 1
yield node
monkeypatch.setattr(GraphSession, "iter_nodes", counting_iter_nodes)
response = client.get(
"/api/ontology/graph",
params={"uri": "http://example.org/onto-a"},
)
assert response.status_code == 413
assert str(_MAX_ANALYSIS_NODES) in response.json()["detail"]
# The graph holds 8,001 owl:Class nodes and onto-a's own sort first, so a
# scan that abandons at the cap sees far fewer than the whole type.
assert streamed < 6_000
def test_ontology_graph_hydrates_external_edge_targets_in_sorted_order(client):
graph = client.app.state.session.graph
external = "http://external.example/Thing"
also_external = "http://external.example/Aardvark"
graph.add_node(external, node_type="owl:Class", content="External Thing")
graph.add_node(also_external, node_type="owl:Class", content="External Aardvark")
graph.add_edge("http://example.org/onto-a#name", external, edge_type="rdfs:range")
graph.add_edge("http://example.org/onto-a#name", also_external, edge_type="rdfs:range")
response = client.get(
"/api/ontology/graph",
params={"uri": "http://example.org/onto-a"},
)
assert response.status_code == 200
node_ids = [node["id"] for node in response.json()["nodes"]]
assert {external, also_external} <= set(node_ids)
assert node_ids == sorted(node_ids)
def test_shacl_generate_and_shapes(client):
response = client.post(
"/api/ontology/shacl/generate",
@@ -0,0 +1,460 @@
"""Tests for reconciling synthetic relation endpoints into the graph.
Relation extraction synthesizes ``UNKNOWN`` entities for endpoints that are
absent from the NER entity list, but ``GraphBuilder._process_item`` kept only
the endpoint string. The resulting graph then failed ``GraphValidator`` with
``DANGLING_EDGE``. See issue #1463.
"""
import pytest
from semantica.kg import GraphBuilder, GraphValidator
from semantica.semantic_extract import Entity, Relation, Triplet
def _known(text="Known Entity", label="CONCEPT"):
return Entity(text=text, label=label, start_char=0, end_char=len(text))
def _synthetic(text):
return Entity(
text=text,
label="UNKNOWN",
start_char=0,
end_char=len(text),
confidence=0.8,
metadata={"synthetic": True},
)
def _rel(subject, predicate, object_):
return Relation(subject=subject, predicate=predicate, object=object_)
def _issues(graph):
return GraphValidator().validate(graph).to_dict()["issues"]
def test_single_missing_endpoint_promoted():
graph = GraphBuilder(resolve_conflicts=False).build(
[_known(), _rel(_known(), "related_to", _synthetic("Synthetic Entity"))],
extract=False,
)
entity_ids = {e["id"] for e in graph["entities"]}
assert "Synthetic Entity" in entity_ids
assert not [i for i in _issues(graph) if i.get("code") == "DANGLING_EDGE"]
def test_two_missing_endpoints_promoted():
graph = GraphBuilder(resolve_conflicts=False).build(
[_rel(_synthetic("Org A"), "related_to", _synthetic("Org B"))],
extract=False,
)
entity_ids = {e["id"] for e in graph["entities"]}
assert {"Org A", "Org B"} <= entity_ids
def test_promoted_default_confidence_is_0_8():
graph = GraphBuilder(resolve_conflicts=False).build(
[_rel(_known(), "related_to", _synthetic("Synthetic Entity"))],
extract=False,
)
synthetic = next(e for e in graph["entities"] if e["id"] == "Synthetic Entity")
assert synthetic["confidence"] == 0.8
assert synthetic["metadata"].get("synthetic") is True
assert synthetic["type"] == "UNKNOWN"
assert synthetic["name"] == "Synthetic Entity"
def test_no_duplicate_when_endpoint_already_present():
# A synthetic endpoint sharing an id with an existing entity must not
# create a duplicate node.
known = _known("Shared", label="CONCEPT")
graph = GraphBuilder(resolve_conflicts=False).build(
[known, _rel(known, "related_to", _synthetic("Shared"))],
extract=False,
)
ids = [e["id"] for e in graph["entities"] if e["id"] == "Shared"]
assert len(ids) == 1
def test_merge_entities_true_no_dangling_edge():
# With entity merging explicitly enabled (and conflict resolution on, the
# default), a promoted synthetic endpoint still must not leave a dangling
# edge and must not duplicate the existing entity.
graph = GraphBuilder(merge_entities=True).build(
[_known(), _rel(_known(), "related_to", _synthetic("Synthetic Entity"))],
extract=False,
)
assert not [i for i in _issues(graph) if i.get("code") == "DANGLING_EDGE"]
synthetic = [e for e in graph["entities"] if e["id"] == "Synthetic Entity"]
assert len(synthetic) == 1
def test_reject_policy_drops_dangling_relationship():
builder = GraphBuilder(
resolve_conflicts=False,
unknown_relation_endpoint="reject",
)
graph = builder.build(
[_rel(_known(), "related_to", _synthetic("Synthetic Entity"))],
extract=False,
)
assert graph["relationships"] == []
assert "Synthetic Entity" not in {e["id"] for e in graph["entities"]}
def test_reject_policy_requires_config_to_promote():
# Same input as test_reject_policy but policies must be explicit; only
# "reject" disables promotion, the default remains permissive.
default_builder = GraphBuilder(resolve_conflicts=False)
graph = default_builder.build(
[_rel(_known(), "related_to", _synthetic("Synthetic Entity"))],
extract=False,
)
assert "Synthetic Entity" in {e["id"] for e in graph["entities"]}
def test_dict_source_relationship_with_all_endpoints_present():
# Non-synthetic dict relationships with a legitimate entity set are left
# untouched; endpoints already resolve so no dangling edge appears.
graph = GraphBuilder(resolve_conflicts=False).build(
{
"entities": [{"id": "Known Entity", "name": "Known Entity"}],
"relationships": [
{
"source": "Known Entity",
"target": "Known Entity",
"type": "related_to",
}
],
},
extract=False,
)
assert "Known Entity" in {e["id"] for e in graph["entities"]}
assert not [i for i in _issues(graph) if i.get("code") == "DANGLING_EDGE"]
def test_reject_policy_via_nested_config():
# The orchestrator constructs GraphBuilder(config=self.config.get("kg", {})),
# which lands as a nested "config" keyword. The policy lookup must read
# through that nesting so the reject option works end to end.
builder = GraphBuilder(
resolve_conflicts=False,
config={"unknown_relation_endpoint": "reject"},
)
graph = builder.build(
[_rel(_known(), "related_to", _synthetic("Synthetic Entity"))],
extract=False,
)
assert graph["relationships"] == []
assert "Synthetic Entity" not in {e["id"] for e in graph["entities"]}
def test_real_entity_wins_over_prior_synthetic():
# A synthetic endpoint promoted early must yield to a real entity with the
# same id that arrives later (e.g. relation data precedes NER entity data).
graph = GraphBuilder(resolve_conflicts=False).build(
[_rel(_synthetic("Shared"), "related_to", _known("Other"))],
extract=False,
)
# Force a real "Shared" entity to coexist with the promoted synthetic one and
# confirm only the real one survives.
real_graph = GraphBuilder(resolve_conflicts=False).build(
[
_synthetic("Shared"),
_known("Shared"),
_rel(_synthetic("Shared"), "related_to", _known("Other")),
],
extract=False,
)
shared = [e for e in real_graph["entities"] if e["id"] == "Shared"]
assert len(shared) == 1
assert shared[0]["metadata"].get("synthetic") is not True
def test_triplet_with_synthetic_endpoint_promoted():
# The LLM triplet path tags endpoint texts it could not match, and the
# GraphBuilder promotes them as synthetic entities (issue #1463).
triplet = Triplet(
subject="Missing Subj",
predicate="related_to",
object="Known Target",
)
triplet.metadata = {"synthetic_endpoints": ["Missing Subj"]}
graph = GraphBuilder(resolve_conflicts=False).build(
[_known("Known Target"), triplet],
extract=False,
)
entity_ids = {e["id"] for e in graph["entities"]}
assert "Missing Subj" in entity_ids
assert not [i for i in _issues(graph) if i.get("code") == "DANGLING_EDGE"]
def test_synthetic_endpoint_deduped_against_entity_id():
# A dict entity may carry its canonical id under "entity_id". A synthetic
# endpoint promoted with the same id must not create a duplicate node.
graph = GraphBuilder(resolve_conflicts=False).build(
[
{"entity_id": "Shared", "name": "Shared"},
_rel(_synthetic("Shared"), "related_to", _known("Other")),
],
extract=False,
)
ids = [
e.get("id")
for e in graph["entities"]
if e.get("id") == "Shared" or e.get("entity_id") == "Shared"
]
assert len(ids) == 1
def test_dict_relationship_unknown_endpoint_untouched_by_design():
# Dictionary-form relationships stay out of the synthetic reconcile path by
# design; they have no synthetic marker so no entity is invented on their
# behalf.
graph = GraphBuilder(resolve_conflicts=False).build(
{
"entities": [{"id": "Known"}],
"relationships": [
{"source": "Known", "target": "Absent", "type": "related_to"}
],
},
extract=False,
)
ids = {e["id"] for e in graph["entities"]}
assert "Known" in ids
assert "Absent" not in ids
def test_entity_with_none_metadata_does_not_crash_build():
# An entity carrying an explicit metadata=None must not raise in the
# synthetic-reconcile pass; .get("metadata", {}) returns None, not {}.
src = {
"entities": [
{"id": "a", "name": "a", "metadata": None},
{"id": "b", "name": "b"},
],
"relationships": [{"source": "a", "target": "b", "type": "rel"}],
}
graph = GraphBuilder(resolve_conflicts=False).build(src, extract=False)
ids = {e["id"] for e in graph["entities"]}
assert {"a", "b"} <= ids
assert graph["relationships"][0]["type"] == "rel"
def test_reject_policy_via_orchestrator_shaped_config_fold():
# The orchestrator builds with GraphBuilder(config=self.config.get("kg", {})),
# so config= carries flat kg options. Folding that dict into the option
# mapping must make reject effective and leave the nested
# entity_resolution/conflict_detection options readable too.
builder = GraphBuilder(
config={
"unknown_relation_endpoint": "reject",
"entity_resolution": {"enabled": False},
"conflict_detection": {"enabled": False},
},
)
graph = builder.build(
[_rel(_known(), "related_to", _synthetic("Synthetic Entity"))],
extract=False,
)
assert graph["relationships"] == []
assert "Synthetic Entity" not in {e["id"] for e in graph["entities"]}
def test_real_entity_with_entity_id_only_wins_over_prior_synthetic():
# A real entity that carries its canonical id under ``entity_id`` (no ``id``
# field) must win the real-entity-wins dedup pass in build().
# The dedup collects real ids via both ``id`` and ``entity_id``, so a
# synthetic entity promoted with id='Shared' must be dropped when a real
# dict entity with entity_id='Shared' (no 'id') arrives.
graph = GraphBuilder(resolve_conflicts=False).build(
[
_rel(_synthetic("Shared"), "related_to", _known("Other")),
_known("Other"),
# real entity uses entity_id only — no 'id' key
{"entity_id": "Shared", "name": "Shared Real"},
],
extract=False,
)
shared = [
e for e in graph["entities"]
if e.get("id") == "Shared" or e.get("entity_id") == "Shared"
]
assert len(shared) == 1
# The real dict entity (entity_id-only) must survive, not the synthetic one.
assert shared[0].get("entity_id") == "Shared"
assert not (shared[0].get("metadata") or {}).get("synthetic")
def test_llm_relation_synthetic_endpoints_promoted():
# _parse_relation_result marks unmatched relation endpoints as synthetic
# Entity objects (label=UNKNOWN, metadata={'synthetic': True}).
# GraphBuilder must promote those entities and produce a clean graph.
from semantica.semantic_extract.methods import _parse_relation_result
from semantica.semantic_extract import Entity as SEEntity
known_ent = SEEntity(text="Apple", label="ORG", start_char=0, end_char=5)
parsed = {
"relations": [
{
"subject": "Apple",
"predicate": "founded_by",
"object": "Steve Jobs",
"confidence": 0.95,
}
]
}
relations = _parse_relation_result(
parsed,
[known_ent],
"Apple was founded by Steve Jobs.",
"openai",
"gpt-4",
)
assert len(relations) == 1
assert relations[0].object.metadata.get("synthetic") is True
graph = GraphBuilder(resolve_conflicts=False).build(
[known_ent, relations[0]],
extract=False,
)
entity_ids = {e["id"] for e in graph["entities"]}
assert "Steve Jobs" in entity_ids
assert not [i for i in _issues(graph) if i.get("code") == "DANGLING_EDGE"]
def test_reject_policy_emits_warning_when_all_rels_dropped(caplog):
# When reject drops every relationship from a dict-style source, the
# build() 'all relationships dropped' warning must still fire (the
# input_relationships_count accounts for the dict source's list length
# before processing, so it sees the original count > 0).
import logging
builder = GraphBuilder(
resolve_conflicts=False,
unknown_relation_endpoint="reject",
)
rel = _rel(_known(), "related_to", _synthetic("S"))
with caplog.at_level(logging.WARNING, logger="graph_builder"):
graph = builder.build(
{
"entities": [{"id": "Known Entity", "name": "Known Entity"}],
"relationships": [rel],
},
extract=False,
)
assert graph["relationships"] == []
assert any(
"All relationships were dropped" in record.message
for record in caplog.records
)
# ---------------------------------------------------------------------------
# Observability of the reject policy (Qodo finding: "reject silently drops")
# ---------------------------------------------------------------------------
def test_reject_policy_count_in_metadata():
# graph["metadata"]["rejected_relationships"] must equal the number of
# relationships dropped by the reject policy so callers can observe the
# outcome without parsing logs.
builder = GraphBuilder(
resolve_conflicts=False,
unknown_relation_endpoint="reject",
)
graph = builder.build(
[
_known("A"),
_known("B"),
_rel(_known("A"), "clean", _known("B")), # kept
_rel(_known("A"), "dirty1", _synthetic("S1")), # rejected
_rel(_synthetic("S2"), "dirty2", _known("B")), # rejected
],
extract=False,
)
assert graph["metadata"]["rejected_relationships"] == 2
assert len(graph["relationships"]) == 1
assert graph["relationships"][0]["type"] == "clean"
def test_include_policy_rejected_count_is_zero():
# With the default include policy the rejected count must be 0.
graph = GraphBuilder(resolve_conflicts=False).build(
[_known(), _rel(_known(), "r", _synthetic("S"))],
extract=False,
)
assert graph["metadata"]["rejected_relationships"] == 0
def test_reject_policy_emits_warning_log_per_dropped_edge(caplog):
# Each dropped edge must produce a WARNING-level log entry (not just INFO).
# This makes the rejection visible to callers who configure standard
# WARNING-level logging without enabling DEBUG.
import logging
builder = GraphBuilder(
resolve_conflicts=False,
unknown_relation_endpoint="reject",
)
with caplog.at_level(logging.WARNING, logger="semantica.graph_builder"):
graph = builder.build(
[
_known(),
_rel(_known(), "dirty1", _synthetic("S1")),
_rel(_known(), "dirty2", _synthetic("S2")),
],
extract=False,
)
assert graph["relationships"] == []
drop_records = [
r for r in caplog.records
if "Dropping relationship" in r.message and r.levelno == logging.WARNING
]
assert len(drop_records) == 2, (
f"Expected 2 WARNING drop records, got {len(drop_records)}: {[r.message for r in caplog.records]}"
)
def test_rejected_count_resets_between_build_calls():
# _rejected_relationships must be reset at the start of each build() call
# so successive builds on the same instance report independent counts.
builder = GraphBuilder(
resolve_conflicts=False,
unknown_relation_endpoint="reject",
)
g1 = builder.build(
[_known(), _rel(_known(), "r", _synthetic("S"))],
extract=False,
)
assert g1["metadata"]["rejected_relationships"] == 1
# Second build has no synthetic endpoints — count must be 0, not accumulated.
g2 = builder.build(
[_known("A"), _known("B"), _rel(_known("A"), "clean", _known("B"))],
extract=False,
)
assert g2["metadata"]["rejected_relationships"] == 0
@@ -0,0 +1,149 @@
"""Regression tests for the legacy-SDK model-instance cache on ``GeminiProvider``.
When the new ``google.genai`` package is unavailable, ``GeminiProvider`` falls
back to the legacy ``google.generativeai`` package, whose ``GenerativeModel``
binds its model name at construction time and whose API key lives in
module-level state (``genai.configure()``).
``GeminiProvider._legacy_client_for()`` therefore keeps a per-instance cache
keyed by model name, so a repeated per-call ``model=`` override reuses one
``GenerativeModel`` instead of rebuilding it on every request, and re-asserts
``genai.configure(api_key=...)`` with this provider's own key before each use.
PR #1488 (issue #1268) locked in *which* model a per-call override resolves to.
These tests cover what it did not: that the resolved instance is built once and
cached, and that the cache and credentials stay isolated per provider instance
(issue #1269).
"""
import sys
from unittest.mock import MagicMock, patch
import pytest
from semantica.semantic_extract.providers import GeminiProvider
CONSTRUCTION_MODEL = "gemini-pro"
OVERRIDE_MODEL = "gemini-1.5-flash"
OTHER_MODEL = "gemini-1.5-pro"
JSON_TEXT = '{"answer": 42}'
def _make_provider(api_key="fake-key", model=CONSTRUCTION_MODEL):
"""A GeminiProvider on the legacy path with the real SDK bootstrap skipped."""
with patch.object(GeminiProvider, "_init_client", return_value=None):
provider = GeminiProvider(api_key=api_key, model=model)
provider._use_new_genai = False
provider.client = MagicMock(name="construction client")
return provider
@pytest.fixture
def fake_legacy_genai(monkeypatch):
"""Install a stand-in ``google.generativeai`` module.
Unlike the fake in ``test_gemini_model_override``, ``GenerativeModel`` here
does **not** cache internally: it returns a fresh mock every call and records
every model name it was asked to build, so a test can tell whether the
provider rebuilt a model or served it from its own cache. ``configure`` is a
plain mock so credential re-assertion is observable.
"""
module = MagicMock()
module.build_calls = []
def build_model(name):
module.build_calls.append(name)
model = MagicMock(name=f"GenerativeModel({name})#{len(module.build_calls)}")
response = MagicMock()
response.text = JSON_TEXT
model.generate_content.return_value = response
return model
module.GenerativeModel.side_effect = build_model
monkeypatch.setitem(sys.modules, "google.generativeai", module)
return module
class TestLegacyModelCacheReuse:
"""``_legacy_client_for()`` builds each per-call model once, then caches it."""
def test_repeated_override_builds_one_generative_model(self, fake_legacy_genai):
provider = _make_provider()
provider.generate("hello", model=OVERRIDE_MODEL)
provider.generate("hello", model=OVERRIDE_MODEL)
provider.generate_structured("hello", model=OVERRIDE_MODEL)
assert fake_legacy_genai.build_calls == [OVERRIDE_MODEL]
assert list(provider._legacy_model_cache) == [OVERRIDE_MODEL]
def test_cache_hit_returns_the_same_instance(self, fake_legacy_genai):
provider = _make_provider()
first = provider._legacy_client_for(OVERRIDE_MODEL)
second = provider._legacy_client_for(OVERRIDE_MODEL)
assert first is second
assert first is provider._legacy_model_cache[OVERRIDE_MODEL]
assert fake_legacy_genai.build_calls == [OVERRIDE_MODEL]
def test_distinct_overrides_are_cached_separately(self, fake_legacy_genai):
provider = _make_provider()
provider.generate("hello", model=OVERRIDE_MODEL)
provider.generate("hello", model=OTHER_MODEL)
provider.generate("hello", model=OVERRIDE_MODEL)
assert fake_legacy_genai.build_calls == [OVERRIDE_MODEL, OTHER_MODEL]
assert set(provider._legacy_model_cache) == {OVERRIDE_MODEL, OTHER_MODEL}
assert (
provider._legacy_model_cache[OVERRIDE_MODEL]
is not provider._legacy_model_cache[OTHER_MODEL]
)
def test_default_model_is_not_cached_or_rebuilt(self, fake_legacy_genai):
provider = _make_provider(model=CONSTRUCTION_MODEL)
construction_client = provider.client
provider.generate("hello")
provider.generate("hello", model=CONSTRUCTION_MODEL)
assert fake_legacy_genai.build_calls == []
assert provider._legacy_model_cache == {}
assert construction_client.generate_content.call_count == 2
class TestLegacyModelCacheIsolation:
"""The cache and the legacy SDK's module-level key stay per-instance."""
def test_configure_reasserted_with_this_key_before_every_call(
self, fake_legacy_genai
):
provider = _make_provider(api_key="key-A")
provider.generate("hello", model=OVERRIDE_MODEL)
provider.generate("hello", model=OVERRIDE_MODEL) # cache hit still re-asserts
assert fake_legacy_genai.configure.call_count == 2
for call in fake_legacy_genai.configure.call_args_list:
assert call.kwargs == {"api_key": "key-A"}
def test_two_instances_keep_separate_caches_and_keys(self, fake_legacy_genai):
provider_a = _make_provider(api_key="key-A")
provider_b = _make_provider(api_key="key-B")
provider_a.generate("hello", model=OVERRIDE_MODEL)
provider_b.generate("hello", model=OVERRIDE_MODEL)
# Same model name, but each instance built and cached its own object.
assert fake_legacy_genai.build_calls == [OVERRIDE_MODEL, OVERRIDE_MODEL]
assert (
provider_a._legacy_model_cache[OVERRIDE_MODEL]
is not provider_b._legacy_model_cache[OVERRIDE_MODEL]
)
assert fake_legacy_genai.configure.call_args_list[-2].kwargs == {
"api_key": "key-A"
}
assert fake_legacy_genai.configure.call_args_list[-1].kwargs == {
"api_key": "key-B"
}
@@ -0,0 +1,187 @@
"""Regression tests for the per-call ``model=`` override on ``GeminiProvider``.
``GeminiProvider.generate(model=...)`` and ``generate_structured(model=...)`` must
select the model the same way as each other, on both supported Gemini SDK paths:
* the new ``google.genai`` client (``_use_new_genai = True``), and
* the legacy ``google.generativeai`` package (``_use_new_genai = False``), where a
per-call model has to be resolved through ``_legacy_client_for()`` because the
legacy ``GenerativeModel`` binds its model name at construction time.
"""
import sys
from unittest.mock import MagicMock, patch
import pytest
from semantica.semantic_extract.providers import GeminiProvider
CONSTRUCTION_MODEL = "gemini-pro"
OVERRIDE_MODEL = "gemini-1.5-flash"
JSON_TEXT = '{"answer": 42}'
def _make_provider():
"""A GeminiProvider with the real SDK bootstrap skipped."""
with patch.object(GeminiProvider, "_init_client", return_value=None):
return GeminiProvider(api_key="fake-key", model=CONSTRUCTION_MODEL)
class TestNewGenaiModelOverride:
"""New ``google.genai`` client path (``_use_new_genai = True``)."""
@staticmethod
def _provider():
provider = _make_provider()
provider._use_new_genai = True
client = MagicMock()
response = MagicMock()
response.text = JSON_TEXT
client.models.generate_content.return_value = response
provider.client = client
return provider, client
def test_generate_honors_per_call_model(self):
provider, client = self._provider()
provider.generate("hello", model=OVERRIDE_MODEL)
assert (
client.models.generate_content.call_args.kwargs["model"] == OVERRIDE_MODEL
)
def test_generate_structured_honors_per_call_model(self):
provider, client = self._provider()
provider.generate_structured("hello", model=OVERRIDE_MODEL)
assert (
client.models.generate_content.call_args.kwargs["model"] == OVERRIDE_MODEL
)
def test_generate_falls_back_to_construction_model(self):
provider, client = self._provider()
provider.generate("hello")
assert (
client.models.generate_content.call_args.kwargs["model"]
== CONSTRUCTION_MODEL
)
def test_generate_structured_falls_back_to_construction_model(self):
provider, client = self._provider()
provider.generate_structured("hello")
assert (
client.models.generate_content.call_args.kwargs["model"]
== CONSTRUCTION_MODEL
)
def test_both_methods_select_model_consistently(self):
provider, client = self._provider()
provider.generate("hello", model=OVERRIDE_MODEL)
generate_model = client.models.generate_content.call_args.kwargs["model"]
client.models.generate_content.reset_mock()
provider.generate_structured("hello", model=OVERRIDE_MODEL)
structured_model = client.models.generate_content.call_args.kwargs["model"]
assert generate_model == structured_model == OVERRIDE_MODEL
class TestLegacyModelOverride:
"""Legacy ``google.generativeai`` path (``_use_new_genai = False``)."""
@pytest.fixture
def fake_legacy_genai(self, monkeypatch):
"""Install a stand-in ``google.generativeai`` module.
``GenerativeModel(name)`` returns a distinct mock per model name (cached,
mirroring ``_legacy_client_for``) so tests can assert which model name the
per-call override resolved to.
"""
module = MagicMock()
module.created_models = {}
def make_model(name):
model = module.created_models.get(name)
if model is None:
model = MagicMock(name=f"GenerativeModel({name})")
response = MagicMock()
response.text = JSON_TEXT
model.generate_content.return_value = response
module.created_models[name] = model
return model
module.GenerativeModel.side_effect = make_model
monkeypatch.setitem(sys.modules, "google.generativeai", module)
return module
@staticmethod
def _provider():
provider = _make_provider()
provider._use_new_genai = False
construction_client = MagicMock(name="construction client")
response = MagicMock()
response.text = JSON_TEXT
construction_client.generate_content.return_value = response
provider.client = construction_client
return provider, construction_client
def test_generate_builds_client_for_per_call_model(self, fake_legacy_genai):
provider, construction_client = self._provider()
provider.generate("hello", model=OVERRIDE_MODEL)
fake_legacy_genai.GenerativeModel.assert_called_once_with(OVERRIDE_MODEL)
construction_client.generate_content.assert_not_called()
def test_generate_structured_builds_client_for_per_call_model(
self, fake_legacy_genai
):
provider, construction_client = self._provider()
provider.generate_structured("hello", model=OVERRIDE_MODEL)
fake_legacy_genai.GenerativeModel.assert_called_once_with(OVERRIDE_MODEL)
construction_client.generate_content.assert_not_called()
def test_without_override_uses_construction_client(self, fake_legacy_genai):
provider, construction_client = self._provider()
provider.generate("hello")
provider.generate_structured("hello")
fake_legacy_genai.GenerativeModel.assert_not_called()
assert construction_client.generate_content.call_count == 2
def test_both_methods_select_model_consistently(self, fake_legacy_genai):
provider, construction_client = self._provider()
provider.generate("hello", model=OVERRIDE_MODEL)
provider.generate_structured("hello", model=OVERRIDE_MODEL)
assert set(fake_legacy_genai.created_models) == {OVERRIDE_MODEL}
assert (
fake_legacy_genai.created_models[OVERRIDE_MODEL].generate_content.call_count
== 2
)
construction_client.generate_content.assert_not_called()
def test_generate_structured_still_forwards_generation_config(
self, fake_legacy_genai
):
"""generate_structured() must forward generation params on the legacy path,
the same as generate() does."""
provider, _ = self._provider()
provider.generate_structured("hello", model=OVERRIDE_MODEL, temperature=0.2)
override_client = fake_legacy_genai.created_models[OVERRIDE_MODEL]
gen_config = override_client.generate_content.call_args.kwargs[
"generation_config"
]
assert gen_config["temperature"] == 0.2
@@ -0,0 +1,763 @@
"""Regression coverage for NER method merge strategies."""
from unittest.mock import patch
import pytest
from semantica.semantic_extract.ner_extractor import NERExtractor
from semantica.semantic_extract.types import Entity
def entity(text, label, start, end, confidence=0.9):
"""Create a compact entity fixture with realistic offsets."""
return Entity(text, label, start, end, confidence=confidence)
def test_consensus_rejects_single_method_candidate_with_empty_peer():
"""An empty eligible method must remain in the consensus denominator."""
extractor = NERExtractor(
method=["first", "second"], merge_strategy="consensus", min_votes=2
)
result = extractor._vote_entities(
[("first", [entity("Apple", "ORG", 0, 5)]), ("second", [])],
eligible_methods=["first", "second"],
)
assert result == []
def test_consensus_aligns_compatible_labels_and_exposes_provenance():
"""Compatible labels vote together and preserve auditable method evidence."""
extractor = NERExtractor(
method=["first", "second"], merge_strategy="consensus", min_votes=2
)
result = extractor._vote_entities(
[
("first", [entity("Apple", "ORGANIZATION", 0, 5, 0.8)]),
("second", [entity("Apple", "ORG", 0, 5, 0.9)]),
],
eligible_methods=["first", "second"],
)
assert len(result) == 1
merged = result[0]
assert merged.label == "ORG"
assert merged.confidence == pytest.approx(0.85)
assert merged.metadata["supporting_methods"] == ["first", "second"]
assert merged.metadata["vote_count"] == 2
assert merged.metadata["eligible_method_count"] == 2
assert merged.metadata["agreement"] == 1.0
assert merged.metadata["method_scores"] == {"first": 0.8, "second": 0.9}
def test_consensus_keeps_repeated_mentions_at_distinct_offsets():
"""Matching text must not collapse separate document mentions."""
extractor = NERExtractor(
method=["first", "second"], merge_strategy="consensus", min_votes=2
)
first_mentions = [
entity("Apple", "ORG", 0, 5),
entity("Apple", "ORG", 10, 15),
]
second_mentions = [
entity("Apple", "ORG", 0, 5),
entity("Apple", "ORG", 10, 15),
]
result = extractor._vote_entities(
[("first", first_mentions), ("second", second_mentions)],
eligible_methods=["first", "second"],
)
assert [(item.text, item.start_char, item.end_char) for item in result] == [
("Apple", 0, 5),
("Apple", 10, 15),
]
def test_consensus_resolves_boundary_variants_deterministically():
"""Equal-confidence overlapping spans prefer the most specific boundary."""
extractor = NERExtractor(
method=["first", "second"], merge_strategy="consensus", min_votes=2
)
result = extractor._vote_entities(
[
("first", [entity("Apple", "ORG", 0, 5, 0.9)]),
("second", [entity("Apple Inc.", "ORG", 0, 10, 0.9)]),
],
eligible_methods=["first", "second"],
)
assert len(result) == 1
assert (result[0].text, result[0].start_char, result[0].end_char) == (
"Apple Inc.",
0,
10,
)
def test_consensus_resolves_conflicting_labels_deterministically():
"""With equal vote counts, the higher-confidence label wins predictably."""
extractor = NERExtractor(
method=["first", "second"], merge_strategy="consensus", min_votes=1
)
result = extractor._vote_entities(
[
("first", [entity("Apple", "ORG", 0, 5, 0.7)]),
("second", [entity("Apple", "PRODUCT", 0, 5, 0.9)]),
],
eligible_methods=["first", "second"],
)
assert len(result) == 1
assert result[0].label == "PRODUCT"
assert result[0].metadata["supporting_methods"] == ["second"]
def test_consensus_prefers_a_same_label_match_over_a_tied_conflict():
"""A conflicting duplicate from one method cannot hide true agreement."""
extractor = NERExtractor(
method=["first", "second"], merge_strategy="consensus", min_votes=2
)
result = extractor._vote_entities(
[
(
"first",
[
entity("Apple", "PRODUCT", 0, 5),
entity("Apple", "ORG", 0, 5),
],
),
("second", [entity("Apple", "ORG", 0, 5)]),
],
eligible_methods=["first", "second"],
)
assert len(result) == 1
assert result[0].label == "ORG"
assert result[0].metadata["supporting_methods"] == ["first", "second"]
def test_default_consensus_rejects_a_label_conflict_without_two_votes():
"""Different labels do not turn two methods into two votes for either label."""
extractor = NERExtractor(method=["first", "second"], merge_strategy="consensus")
result = extractor._vote_entities(
[
("first", [entity("Apple", "ORG", 0, 5)]),
("second", [entity("Apple", "PRODUCT", 0, 5)]),
],
eligible_methods=["first", "second"],
)
assert result == []
def test_consensus_keeps_a_majority_label_despite_a_high_weight_single_vote():
"""Weights break ties only after the configured vote requirements are met."""
extractor = NERExtractor(
method=["first", "second", "third"],
merge_strategy="consensus",
min_votes=2,
method_weights={"third": 100.0},
)
result = extractor._vote_entities(
[
("first", [entity("Apple", "ORG", 0, 5, 0.7)]),
("second", [entity("Apple", "ORG", 0, 5, 0.7)]),
("third", [entity("Apple", "PRODUCT", 0, 5, 0.99)]),
],
eligible_methods=["first", "second", "third"],
)
assert len(result) == 1
assert result[0].label == "ORG"
assert result[0].metadata["supporting_methods"] == ["first", "second"]
def test_method_weights_treat_ml_and_spacy_as_one_backend():
"""A spaCy alias weight must apply when the configured method is called ml."""
extractor = NERExtractor(method="pattern", merge_strategy="consensus", min_votes=1)
result = extractor._vote_entities(
[
("ml", [entity("Apple", "ORG", 0, 5, 0.7)]),
("regex", [entity("Apple", "PRODUCT", 0, 5, 0.9)]),
],
eligible_methods=["ml", "regex"],
min_votes=1,
method_weights={"spacy": 100.0, "regex": 1.0},
)
assert len(result) == 1
assert result[0].label == "ORG"
def test_method_weights_reject_conflicting_ml_and_spacy_aliases():
"""A single backend cannot receive two different alias weights."""
with pytest.raises(ValueError, match="conflicting values to aliases"):
NERExtractor(
method="pattern",
merge_strategy="consensus",
method_weights={"ml": 1.0, "spacy": 2.0},
)
def test_consensus_does_not_merge_distant_mentions_through_a_broad_span():
"""A broad span below the IoU threshold cannot fabricate cross-method support."""
extractor = NERExtractor(
method=["first", "second"], merge_strategy="consensus", min_votes=2
)
result = extractor._vote_entities(
[
(
"first",
[
entity("A B", "ORG", 0, 3),
entity("C D", "ORG", 4, 7),
],
),
("second", [entity("A B C D", "ORG", 0, 7)]),
],
eligible_methods=["first", "second"],
)
assert result == []
def test_consensus_requires_each_vote_to_overlap_every_other_vote():
"""A chain of pairwise overlaps must not fabricate three-way support."""
extractor = NERExtractor(
method=["first", "second", "third"],
merge_strategy="consensus",
min_votes=3,
)
result = extractor._vote_entities(
[
("first", [entity("ABCD", "ORG", 0, 4)]),
("second", [entity("ABCDEF", "ORG", 0, 6)]),
("third", [entity("CDEF", "ORG", 2, 6)]),
],
eligible_methods=["first", "second", "third"],
)
assert result == []
def test_consensus_prefers_an_exact_boundary_match_within_one_method_batch():
"""A weaker overlap cannot consume another method's exact boundary vote."""
extractor = NERExtractor(
method=["first", "second"], merge_strategy="consensus", min_votes=2
)
result = extractor._vote_entities(
[
(
"first",
[
entity("Apple", "ORG", 0, 5, 0.99),
entity("Apple Inc.", "ORG", 0, 10, 0.1),
],
),
("second", [entity("Apple Inc.", "ORG", 0, 10, 0.9)]),
],
eligible_methods=["first", "second"],
)
assert len(result) == 1
assert (result[0].start_char, result[0].end_char) == (0, 10)
assert result[0].metadata["supporting_methods"] == ["first", "second"]
def test_consensus_boundary_result_is_independent_of_method_result_order():
"""Method result order cannot affect the selected consensus boundary."""
extractor = NERExtractor(
method=["first", "second"], merge_strategy="consensus", min_votes=2
)
first = [
entity("Apple", "ORG", 0, 5, 0.99),
entity("Apple Inc.", "ORG", 0, 10, 0.1),
]
second = [entity("Apple Inc.", "ORG", 0, 10, 0.9)]
forward = extractor._vote_entities(
[("first", first), ("second", second)],
eligible_methods=["first", "second"],
)
reverse = extractor._vote_entities(
[("second", second), ("first", list(reversed(first)))],
eligible_methods=["first", "second"],
)
assert [
(item.label, item.start_char, item.end_char, item.metadata) for item in reverse
] == [
(item.label, item.start_char, item.end_char, item.metadata) for item in forward
]
def test_consensus_keeps_nested_entities_with_different_labels():
"""Exact-span label arbitration must not erase a nested entity."""
extractor = NERExtractor(
method=["first", "second"], merge_strategy="consensus", min_votes=2
)
mentions = [
entity("New York", "GPE", 0, 8),
entity("New York Times", "ORG", 0, 14),
]
result = extractor._vote_entities(
[("first", mentions), ("second", mentions)],
eligible_methods=["first", "second"],
)
assert [(item.label, item.start_char, item.end_char) for item in result] == [
("GPE", 0, 8),
("ORG", 0, 14),
]
def test_union_keeps_complementary_single_method_entities():
"""Explicit union preserves the legacy complementary-method behavior."""
extractor = NERExtractor(method=["first", "second"], merge_strategy="union")
result = extractor._union_entities(
[("first", [entity("SKU-12345", "PRODUCT_CODE", 10, 19)]), ("second", [])],
eligible_methods=["first", "second"],
)
assert [(item.text, item.label) for item in result] == [
("SKU-12345", "PRODUCT_CODE")
]
assert result[0].metadata["vote_count"] == 1
assert result[0].metadata["agreement"] == 0.5
def test_union_preserves_distinct_labels_and_original_label_spelling():
"""Union is complementary: it does not force label-conflict resolution."""
extractor = NERExtractor(method=["first", "second"], merge_strategy="union")
result = extractor._union_entities(
[
("first", [entity("Apple", "ORG", 0, 5)]),
("second", [entity("Apple", "PRODUCT", 0, 5)]),
],
eligible_methods=["first", "second"],
)
assert [(item.text, item.label) for item in result] == [
("Apple", "ORG"),
("Apple", "PRODUCT"),
]
def test_ml_and_spacy_aliases_do_not_duplicate_an_entity_or_a_vote():
"""Two names for spaCy represent one backend, not independent voters."""
extractor = NERExtractor(method=["ml", "spacy"], merge_strategy="union")
result = extractor._union_entities(
[
("ml", [entity("Apple", "ORG", 0, 5)]),
("spacy", [entity("Apple", "ORG", 0, 5)]),
],
eligible_methods=["ml", "spacy"],
)
assert len(result) == 1
assert result[0].metadata["supporting_methods"] == ["ml"]
assert result[0].metadata["eligible_method_count"] == 1
def test_extract_consensus_counts_a_successful_empty_method():
"""The public extraction path must retain empty method results for voting."""
extractor = NERExtractor(
method=["pattern", "regex"], merge_strategy="consensus", min_votes=2
)
responses = {"pattern": [entity("Apple", "ORG", 0, 5)], "regex": []}
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda method_name: (
lambda _text, **_options: responses[method_name]
),
):
assert extractor.extract_entities("Apple") == []
def test_extract_consensus_keeps_method_provenance_on_a_successful_vote():
"""Public extraction must retain method names for provenance."""
extractor = NERExtractor(
method=["pattern", "regex"], merge_strategy="consensus", min_votes=2
)
responses = {
"pattern": [entity("Apple", "ORG", 0, 5, 0.8)],
"regex": [entity("Apple", "ORGANIZATION", 0, 5, 0.9)],
}
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda method_name: lambda _text, **_options: responses[
method_name
],
):
result = extractor.extract_entities("Apple")
assert len(result) == 1
assert result[0].metadata["supporting_methods"] == ["pattern", "regex"]
assert result[0].metadata["method_scores"] == {"pattern": 0.8, "regex": 0.9}
def test_merge_options_are_not_forwarded_to_custom_methods():
"""Custom methods without **kwargs remain usable with the new merge API."""
extractor = NERExtractor(
method=["first", "second"], merge_strategy="consensus", min_votes=2
)
responses = {
"first": [entity("Apple", "ORG", 0, 5)],
"second": [entity("Apple", "ORG", 0, 5)],
}
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda method_name: lambda _text: responses[method_name],
):
result = extractor.extract_entities("Apple")
assert len(result) == 1
def test_legacy_options_remain_available_to_custom_methods():
"""Existing custom methods retain the legacy options they previously received."""
with pytest.warns(DeprecationWarning, match="ensemble_voting"):
extractor = NERExtractor(
method="custom", ensemble_voting=True, post_process=True
)
received = {}
def custom_method(_text, *, ensemble_voting, post_process):
received.update(
ensemble_voting=ensemble_voting,
post_process=post_process,
)
return [entity("Apple", "ORG", 0, 5)]
with patch(
"semantica.semantic_extract.methods.get_entity_method",
return_value=custom_method,
):
result = extractor.extract_entities("Apple")
assert len(result) == 1
assert received == {"ensemble_voting": True, "post_process": True}
def test_extract_consensus_recovers_missing_llm_offsets_before_voting():
"""Typed LLM results with schema-default 0:0 spans can still vote safely."""
extractor = NERExtractor(
method=["llm", "regex"], merge_strategy="consensus", min_votes=2
)
responses = {
"llm": [entity("Apple", "ORG", 0, 0, 0.9)],
"regex": [entity("Apple", "ORG", 0, 5, 0.8)],
}
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda method_name: lambda _text, **_options: responses[
method_name
],
):
result = extractor.extract_entities("Apple")
assert len(result) == 1
assert (result[0].start_char, result[0].end_char) == (0, 5)
assert result[0].metadata["supporting_methods"] == ["llm", "regex"]
@pytest.mark.parametrize("start, end", [(0, 0), (None, None)])
def test_union_discards_unresolved_invalid_offsets(start, end):
"""Unresolvable default or nullable spans cannot leak into union output."""
extractor = NERExtractor(method="llm", merge_strategy="union")
responses = {"llm": [entity("Absent", "ORG", start, end, 0.9)]}
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda method_name: lambda _text, **_options: responses[
method_name
],
):
assert extractor.extract_entities("Apple") == []
def test_union_recovers_nullable_offsets_when_text_matches():
"""Nullable custom offsets remain usable when they can be aligned safely."""
extractor = NERExtractor(method="custom", merge_strategy="union")
responses = {"custom": [entity("Apple", "ORG", None, None, 0.9)]}
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda method_name: lambda _text, **_options: responses[
method_name
],
):
result = extractor.extract_entities("Apple")
assert [(item.start_char, item.end_char) for item in result] == [(0, 5)]
def test_missing_offsets_are_assigned_to_distinct_repeated_mentions():
"""Text-only duplicate output is aligned in occurrence order before merging."""
extractor = NERExtractor(method="llm", merge_strategy="union")
aligned = extractor._align_entities_to_text(
[
entity("Apple", "ORG", 0, 0),
entity("Apple", "ORG", 0, 0),
],
"Apple and Apple",
)
assert [(item.start_char, item.end_char) for item in aligned] == [(0, 5), (10, 15)]
def test_missing_offsets_wrap_to_an_unoccupied_repeated_mention():
"""A valid later mention must not make an earlier missing one unalignable."""
extractor = NERExtractor(method="llm", merge_strategy="union")
aligned = extractor._align_entities_to_text(
[
entity("Apple", "ORG", 10, 15),
entity("Apple", "ORG", 0, 0),
],
"Apple and Apple",
)
assert [(item.start_char, item.end_char) for item in aligned] == [(10, 15), (0, 5)]
def test_missing_offsets_do_not_align_a_substring_inside_a_larger_word():
"""Offset recovery must not let Apple vote for the substring in Pineapple."""
extractor = NERExtractor(
method=["llm", "regex"], merge_strategy="consensus", min_votes=2
)
responses = {
"llm": [entity("Apple", "ORG", 0, 0, 0.9)],
"regex": [entity("Pineapple", "ORG", 0, 9, 0.8)],
}
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda method_name: lambda _text, **_options: responses[
method_name
],
):
assert extractor.extract_entities("Pineapple") == []
def test_missing_offsets_preserve_original_unicode_character_positions():
"""Case-insensitive matching preserves original Unicode offsets."""
extractor = NERExtractor(method="llm", merge_strategy="union")
aligned = extractor._align_entities_to_text(
[entity("Apple", "ORG", 0, 0)],
"İ Apple",
)
assert [(item.start_char, item.end_char) for item in aligned] == [(2, 7)]
def test_consensus_eligible_methods_can_exclude_complementary_extractors():
"""An explicit eligible subset controls the denominator used for agreement."""
extractor = NERExtractor(
method=["first", "second", "supplement"],
merge_strategy="consensus",
min_votes=2,
min_agreement=0.75,
eligible_methods=["first", "second"],
)
responses = {
"first": [entity("Apple", "ORG", 0, 5)],
"second": [entity("Apple", "ORG", 0, 5)],
"supplement": [],
}
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda method_name: lambda _text, **_options: responses[
method_name
],
):
result = extractor.extract_entities("Apple")
assert len(result) == 1
assert result[0].metadata["eligible_method_count"] == 2
assert result[0].metadata["agreement"] == 1.0
def test_extract_consensus_does_not_inject_fallback_candidates():
"""An all-empty consensus result must remain empty rather than fall back."""
extractor = NERExtractor(
method=["pattern", "regex"], merge_strategy="consensus", min_votes=2
)
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda _method_name: lambda _text, **_options: [],
):
assert extractor.extract_entities("Apple") == []
def test_consensus_counts_a_failed_eligible_method_in_agreement():
"""A failed configured method is non-supporting rather than silently removed."""
extractor = NERExtractor(
method=["pattern", "regex"],
merge_strategy="consensus",
min_votes=1,
min_agreement=0.75,
)
def method_for(method_name):
if method_name == "pattern":
return lambda _text, **_options: [entity("Apple", "ORG", 0, 5)]
return lambda _text, **_options: (_ for _ in ()).throw(RuntimeError("offline"))
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=method_for,
):
assert extractor.extract_entities("Apple") == []
def test_union_ignores_consensus_eligible_method_subset():
"""Complementary union must retain all methods even when consensus is scoped."""
extractor = NERExtractor(
method=["pattern", "regex"],
merge_strategy="union",
eligible_methods=["pattern"],
)
responses = {
"pattern": [entity("Apple", "ORG", 0, 5)],
"regex": [entity("SKU-12345", "PRODUCT_CODE", 10, 19)],
}
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda method_name: lambda _text, **_options: responses[
method_name
],
):
result = extractor.extract_entities("Apple SKU-12345")
assert [(item.text, item.label) for item in result] == [
("Apple", "ORG"),
("SKU-12345", "PRODUCT_CODE"),
]
def test_issue_1283_consensus_requires_cross_method_agreement():
"""The former ensemble union must not be mistaken for consensus."""
responses = {
"first": [entity("Apple", "ORG", 0, 5)],
"second": [entity("SKU-12345", "PRODUCT_CODE", 6, 15)],
}
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda method_name: lambda _text, **_options: responses[
method_name
],
):
with pytest.warns(DeprecationWarning, match="ensemble_voting"):
legacy = NERExtractor(method=["first", "second"], ensemble_voting=True)
consensus = NERExtractor(
method=["first", "second"],
merge_strategy="consensus",
min_votes=2,
)
legacy_entities = legacy.extract_entities("Apple SKU-12345")
consensus_entities = consensus.extract_entities("Apple SKU-12345")
assert [(item.text, item.label) for item in legacy_entities] == [
("Apple", "ORG"),
("SKU-12345", "PRODUCT_CODE"),
]
assert consensus_entities == []
def test_fallback_remains_first_nonempty_method():
"""The default strategy remains the documented ordered fallback chain."""
extractor = NERExtractor(method=["first", "second"], merge_strategy="fallback")
responses = {"first": [], "second": [entity("Apple", "ORG", 0, 5)]}
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda method_name: (
lambda _text, **_options: responses[method_name]
),
):
result = extractor.extract_entities("Apple")
assert [(item.text, item.label) for item in result] == [("Apple", "ORG")]
def test_default_strategy_short_circuits_at_the_first_nonempty_method():
"""No merge strategy keeps the public ordered fallback behavior unchanged."""
extractor = NERExtractor(method=["first", "second"])
requested_methods = []
responses = {
"first": [entity("Apple", "ORG", 0, 5)],
"second": [entity("SKU-12345", "PRODUCT_CODE", 6, 15)],
}
def method_for(method_name):
requested_methods.append(method_name)
return lambda _text, **_options: responses[method_name]
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=method_for,
):
result = extractor.extract_entities("Apple SKU-12345")
assert [(item.text, item.label) for item in result] == [("Apple", "ORG")]
assert requested_methods == ["first"]
def test_legacy_ensemble_flag_maps_to_deprecated_union_strategy():
"""Existing callers keep their union behavior while receiving migration guidance."""
with pytest.warns(DeprecationWarning, match="ensemble_voting"):
extractor = NERExtractor(method=["first", "second"], ensemble_voting=True)
assert extractor.merge_strategy == "union"
def test_legacy_ensemble_flag_still_runs_the_union_path():
"""The deprecated flag retains single-method candidates during migration."""
with pytest.warns(DeprecationWarning, match="ensemble_voting"):
extractor = NERExtractor(method=["first", "second"], ensemble_voting=True)
responses = {
"first": [entity("Apple", "ORG", 0, 5)],
"second": [entity("SKU-12345", "PRODUCT_CODE", 6, 15)],
}
with patch(
"semantica.semantic_extract.methods.get_entity_method",
side_effect=lambda method_name: lambda _text, **_options: responses[
method_name
],
):
result = extractor.extract_entities("Apple SKU-12345")
assert [(item.text, item.label) for item in result] == [
("Apple", "ORG"),
("SKU-12345", "PRODUCT_CODE"),
]
+387
View File
@@ -857,6 +857,156 @@ class TestReason:
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_run_infers_from_graph_store_facts(self, runner, monkeypatch, tmp_path):
# reason run used to call Reasoner.run(), which does not exist
# (#1354); it must feed graph store facts + --rules into
# Reasoner.infer_facts().
pytest.importorskip("numpy", reason="semantica.reasoning needs numpy")
rules_file = tmp_path / "rules.yaml"
rules_file.write_text(
'- IF Person(?x) THEN Human(?x)\n'
'- IF MANAGES(?x, ?y) THEN Manager(?x)\n'
'- IF Employee(?x) THEN Staff(?x)\n',
encoding="utf-8")
class _FakeStore:
# Same dict schema as the real backends: nodes carry
# labels/properties, relationships carry start_node_id/end_node_id.
def get_nodes(self, limit=None):
return [{"id": 1, "labels": ["Person"],
"properties": {"name": "Alice"}},
{"id": 2, "labels": ["Person", "Employee"],
"properties": {"name": "Bob"}}]
def get_relationships(self, limit=None):
return [{"id": 9, "type": "MANAGES",
"start_node_id": 1, "end_node_id": 2}]
monkeypatch.setattr(cli_module, "_get_graph_store", lambda ctx: _FakeStore())
result = runner.invoke(
cli_module.main,
["--json", "reason", "run", "--rules", str(rules_file)],
)
_ok(result)
data = json.loads(result.output.strip())
# Person(Alice), Person(Bob), Employee(Bob), MANAGES(Alice, Bob)
assert data["facts"] == 4
assert "Human(Alice)" in data["inferred_facts"]
# Relationship endpoints resolve node ids to names.
assert "Manager(Alice)" in data["inferred_facts"]
# Secondary labels also become facts.
assert "Staff(Bob)" in data["inferred_facts"]
assert data["inferred_count"] == len(data["inferred_facts"])
def test_run_rejects_unwired_engine(self, runner):
result = runner.invoke(cli_module.main,
["reason", "run", "--engine", "sparql"])
assert result.exit_code != 0
assert "not wired" in result.output
assert "reason query" in result.output
assert "Traceback" not in result.output
def test_load_rule_definitions_formats(self, tmp_path):
yaml_list = tmp_path / "list.yaml"
yaml_list.write_text('- IF A(?x) THEN B(?x)\n- IF B(?x) THEN C(?x)\n',
encoding="utf-8")
assert cli_module._load_rule_definitions(str(yaml_list)) == [
"IF A(?x) THEN B(?x)", "IF B(?x) THEN C(?x)"]
yaml_map = tmp_path / "map.yaml"
yaml_map.write_text('rules:\n - IF A(?x) THEN B(?x)\n', encoding="utf-8")
assert cli_module._load_rule_definitions(str(yaml_map)) == [
"IF A(?x) THEN B(?x)"]
plain = tmp_path / "rules.dl"
plain.write_text('# comment\nIF A(?x) THEN B(?x)\n\n', encoding="utf-8")
assert cli_module._load_rule_definitions(str(plain)) == [
"IF A(?x) THEN B(?x)"]
def test_run_empty_graph_returns_zero_facts(self, runner, monkeypatch):
"""reason run with an empty graph store should not crash and report 0 facts."""
pytest.importorskip("numpy", reason="semantica.reasoning needs numpy")
class _EmptyStore:
def get_nodes(self, limit=None): return []
def get_relationships(self, limit=None): return []
monkeypatch.setattr(cli_module, "_get_graph_store", lambda ctx: _EmptyStore())
result = runner.invoke(cli_module.main, ["--json", "reason", "run"])
_ok(result)
data = json.loads(result.output.strip())
assert data["facts"] == 0
assert data["inferred_count"] == 0
assert data["inferred_facts"] == []
def test_run_graph_store_error_surfaces_cleanly(self, runner, monkeypatch):
"""A graph-store connectivity error must surface as a clean error, not a Traceback."""
def _bad_store(ctx):
raise RuntimeError("connection refused")
monkeypatch.setattr(cli_module, "_get_graph_store", _bad_store)
result = runner.invoke(cli_module.main, ["reason", "run"])
assert result.exit_code != 0
assert "Traceback" not in result.output
assert "connection refused" in result.output
def test_run_no_rules_uses_empty_ruleset(self, runner, monkeypatch):
"""reason run without --rules should still succeed (zero rules -> zero inferences)."""
pytest.importorskip("numpy", reason="semantica.reasoning needs numpy")
class _FakeStore:
def get_nodes(self, limit=None):
return [{"id": 1, "labels": ["Person"], "properties": {"name": "Alice"}}]
def get_relationships(self, limit=None):
return []
monkeypatch.setattr(cli_module, "_get_graph_store", lambda ctx: _FakeStore())
result = runner.invoke(cli_module.main, ["--json", "reason", "run"])
_ok(result)
data = json.loads(result.output.strip())
assert data["facts"] == 1
assert data["inferred_count"] == 0
def test_load_rule_definitions_yaml_mapping_without_rules_key_raises(self, tmp_path):
"""A YAML mapping with no 'rules' key must raise ClickException, not silently
pass the raw YAML lines as rules."""
bad = tmp_path / "bad.yaml"
bad.write_text("some_key: some_value\nother_key: other_value\n", encoding="utf-8")
import click as _click
with pytest.raises(_click.ClickException, match="no 'rules' key"):
cli_module._load_rule_definitions(str(bad))
def test_load_rule_definitions_empty_file_returns_empty_list(self, tmp_path):
empty = tmp_path / "empty.yaml"
empty.write_text("", encoding="utf-8")
assert cli_module._load_rule_definitions(str(empty)) == []
def test_load_rule_definitions_yaml_rules_null_falls_to_plaintext(self, tmp_path):
"""rules: null is valid YAML with the key present; the null value is
not a list, so the function falls through to plain-text parsing and
returns the literal line (one no-op rule). This documents the edge
case rather than asserting a specific useful behaviour."""
f = tmp_path / "null_rules.yaml"
f.write_text("rules: null\n", encoding="utf-8")
result = cli_module._load_rule_definitions(str(f))
# Plain-text fallback: the non-comment, non-blank line becomes a rule.
assert result == ["rules: null"]
def test_run_rejects_deductive_engine(self, runner, monkeypatch):
"""Engines other than rete/forward-chain must be rejected with a helpful message."""
class _EmptyStore:
def get_nodes(self, limit=None): return []
def get_relationships(self, limit=None): return []
monkeypatch.setattr(cli_module, "_get_graph_store", lambda ctx: _EmptyStore())
result = runner.invoke(cli_module.main, ["reason", "run", "--engine", "deductive"])
assert result.exit_code != 0
assert "not wired" in result.output
assert "Traceback" not in result.output
def test_explain_requires_conclusion(self, runner):
result = runner.invoke(cli_module.main, ["reason", "explain"])
assert result.exit_code != 0
@@ -1436,6 +1586,65 @@ class TestStore:
result = runner.invoke(cli_module.main, ["store", "connect", "--backend", "neo4j"])
_ok(result)
def test_connect_dispatches_through_graph_store(self, runner, monkeypatch):
# store connect used to call get_graph_store_method(backend) — the
# method registry, which needs (task, method_name) — so it raised a
# TypeError before any connection attempt (#1354).
calls = {}
class _FakeGraphStore:
def __init__(self, backend=None, **cfg):
calls["backend"] = backend
calls["cfg"] = cfg
def connect(self):
calls["connected"] = True
return True
import semantica.graph_store as gs_mod
monkeypatch.setattr(gs_mod, "GraphStore", _FakeGraphStore)
result = runner.invoke(cli_module.main, [
"store", "connect", "--backend", "neo4j",
"--uri", "bolt://example:7687", "--json"])
_ok(result)
data = _json_output(result)
assert data == {"backend": "neo4j", "connected": True}
assert calls["backend"] == "neo4j"
assert calls["cfg"].get("uri") == "bolt://example:7687"
assert calls.get("connected") is True
def test_connect_invalid_backend_reports_error_not_dispatch_error(self, runner):
"""An unknown backend name must produce a meaningful backend error, not a
Python TypeError from the old get_graph_store_method() dispatch (#1354)."""
result = runner.invoke(cli_module.main,
["store", "connect", "--backend", "does-not-exist"])
# Exit 0 because store_connect always catches and reports errors gracefully.
_ok(result)
# The output must mention the backend, not a Python internal error.
assert "does-not-exist" in result.output
assert "TypeError" not in result.output
assert "Traceback" not in result.output
def test_connect_backend_error_surfaces_in_json(self, runner, monkeypatch):
"""A connect() failure must appear in JSON output as connected=False with an error field."""
class _FailingStore:
def __init__(self, backend=None, **cfg):
pass
def connect(self):
raise RuntimeError("auth failed")
import semantica.graph_store as gs_mod
monkeypatch.setattr(gs_mod, "GraphStore", _FailingStore)
result = runner.invoke(cli_module.main, [
"store", "connect", "--backend", "neo4j", "--json"])
_ok(result)
data = _json_output(result)
assert data["connected"] is False
assert "auth failed" in data.get("error", "")
assert data["backend"] == "neo4j"
def test_migrate_dry_run(self, runner):
result = runner.invoke(cli_module.main, ["store", "migrate",
"--from", "faiss", "--to", "qdrant", "--dry-run"])
@@ -1905,6 +2114,30 @@ class TestMCP:
assert "Traceback" not in result.output
assert "Invalid JSON" in result.output
def test_call_failure_global_json_mode_keeps_stdout_clean(self, runner):
"""Under global --json, stdout must stay machine-readable: failures are
emitted as structured JSON on stderr, never as a Rich panel on stdout."""
result = runner.invoke(
cli_module.main,
["--json", "mcp", "call", "some_tool", "--args", "{bad json}"],
)
assert result.exit_code != 0
assert result.stdout == ""
err = json.loads(result.stderr)
assert err["error"].startswith("Invalid JSON in --args")
assert err["type"] == "ClickException"
def test_call_failure_local_json_mode_keeps_stdout_clean(self, runner):
"""The subcommand's own --json flag promises the same stream contract."""
result = runner.invoke(
cli_module.main,
["mcp", "call", "some_tool", "--args", "{bad json}", "--json"],
)
assert result.exit_code != 0
assert result.stdout == ""
err = json.loads(result.stderr)
assert err["error"].startswith("Invalid JSON in --args")
def test_call_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
@@ -2157,6 +2390,160 @@ class TestDoctorEmbeddingHintsAndEnv:
assert "hash fallback" in st["note"], "padded/caps env value must enable deep mode"
class TestDoctorTableLayout:
"""#1428 + Qodo review: doctor table must keep Check labels and Hint text
readable at a normal 80-column terminal.
These tests render the *human-readable* (non-JSON) doctor table into a
captured 80-column Rich console so they cover the actual column-width
arithmetic, not just the JSON data.
Two regressions are protected:
A. #1428 — Hint (and Note) columns must not collapse into unreadable
single-character fragments or be silently truncated with a layout ''.
overflow="fold" on both columns ensures content wraps across lines while
remaining fully present.
B. Qodo Long Check labels such as "Embeddings (sentence-transformers)"
must not be truncated/ellipsized. Assigning ratio=1 to the Check column
(as the original PR did) caused Rich to squeeze it below its min_width
at narrow terminals, so the fix removes ratio from the fixed-size columns.
"""
def _render_doctor_at_80(self, runner, monkeypatch):
"""Return the plain-text (ANSI-stripped) doctor table rendered at 80 cols."""
import io
import re
from rich.console import Console
# Unset LLM-provider env vars so the warn rows (with hints) are always present.
for var in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GROQ_API_KEY"):
monkeypatch.delenv(var, raising=False)
buf = io.StringIO()
narrow_console = Console(
file=buf, width=80, highlight=False, force_terminal=True, no_color=True
)
monkeypatch.setattr(cli_module, "console", narrow_console)
result = runner.invoke(cli_module.main, ["doctor"])
assert result.exit_code == 0, f"doctor exited non-zero: {result.output!r}"
return re.sub(r"\x1b\[[0-9;]*m", "", buf.getvalue())
def _hint_column_parts(self, output: str) -> "list[str]":
"""Extract non-blank Hint-column segments from each rendered line.
Locates the Hint column start from the header row and slices that
suffix from every subsequent line, so the test is insensitive to the
exact widths of the other columns.
"""
lines = output.splitlines()
# Line 0 is blank (console.print() blank line before table).
hdr = next((l for l in lines if "Hint" in l and "Check" in l), None)
assert hdr is not None, "Could not find table header in doctor output"
hint_start = hdr.index("Hint")
parts = []
for line in lines:
if len(line) > hint_start:
seg = line[hint_start:].rstrip()
if seg and seg != "Hint" and not set(seg).issubset({"", " "}):
parts.append(seg)
return parts
# ── B: Qodo regression ────────────────────────────────────────────────────
def test_long_check_label_not_truncated_at_80_cols(self, runner, monkeypatch):
"""'Embeddings (sentence-transformers)' must appear verbatim at 80 cols.
Before the fix, ratio=1 on the Check column let Rich squeeze it below
its min_width, turning the label into 'Embedd…' or similar.
"""
output = self._render_doctor_at_80(runner, monkeypatch)
assert "Embeddings (sentence-transformers)" in output, (
"Check label 'Embeddings (sentence-transformers)' was truncated in "
"the 80-column doctor table — the ratio= constraint on the Check "
"column must be removed so min_width=34 is always honoured."
)
def test_all_check_labels_not_truncated_at_80_cols(self, runner, monkeypatch):
"""Every standard Check label must appear verbatim at 80 cols."""
output = self._render_doctor_at_80(runner, monkeypatch)
for label in (
"Python",
"semantica",
"rich",
"Graph store",
"Vector store",
"Embeddings (sentence-transformers)",
"Embeddings (fastembed)",
"OpenAI",
"Anthropic",
"Groq",
"Config file",
"Log directory",
):
assert label in output, (
f"Check label {label!r} was truncated or missing in the "
"80-column doctor table."
)
# ── A: #1428 regression ───────────────────────────────────────────────────
def test_hint_content_fully_present_at_80_cols(self, runner, monkeypatch):
"""The LLM-provider hints must be fully present (folded, not ellipsized).
With overflow='fold' the full hint text wraps across lines; no
characters are discarded. Joining the Hint-column segments (stripping
whitespace) must reconstruct each complete hint string.
"""
import re
output = self._render_doctor_at_80(runner, monkeypatch)
parts = self._hint_column_parts(output)
hint_joined = re.sub(r"\s+", "", "".join(parts))
# Each LLM-provider hint must be fully recoverable from the folded lines.
for expected in (
"exportOPENAI_API_KEY=\u2026", # export OPENAI_API_KEY=…
"exportANTHROPIC_API_KEY=\u2026", # export ANTHROPIC_API_KEY=…
"exportGROQ_API_KEY=\u2026", # export GROQ_API_KEY=…
):
assert expected in hint_joined, (
f"Hint content {expected!r} is missing from the 80-column "
"doctor table — overflow='fold' must be set on the Hint column "
"so no content is silently discarded."
)
def test_hint_column_has_no_single_char_fragments_at_80_cols(
self, runner, monkeypatch
):
"""No Hint-column line must be a single alphabetic character.
The original #1428 bug produced outputs like:
export
O
P
E
N
A
I
...
because Rich allocated the Hint column only 12 characters of content
width. overflow='fold' on a properly-wide column eliminates this.
"""
output = self._render_doctor_at_80(runner, monkeypatch)
parts = self._hint_column_parts(output)
single_char_alpha = [p for p in parts if len(p.strip()) == 1 and p.strip().isalpha()]
assert not single_char_alpha, (
f"Hint column contains single-character lines {single_char_alpha!r} "
"at 80 columns — the Hint column is too narrow; check min_width and "
"ratio settings."
)
class TestEmbedGenerateOutput:
"""#994: `embed generate --output` must write files `embed index` can read."""
+14
View File
@@ -51,6 +51,20 @@ def test_generate_structured_forwards_to_the_real_provider():
gemini.provider.generate_structured.assert_called_once_with("hello")
def test_per_call_model_override_forwarded_for_both_methods():
gemini = Gemini(api_key="fake-key")
gemini.provider = MagicMock()
gemini.provider.is_available.return_value = True
gemini.generate("hello", model="gemini-1.5-flash")
gemini.generate_structured("hello", model="gemini-1.5-flash")
gemini.provider.generate.assert_called_once_with("hello", model="gemini-1.5-flash")
gemini.provider.generate_structured.assert_called_once_with(
"hello", model="gemini-1.5-flash"
)
def test_generate_typed_forwards_schema_and_max_retries():
gemini = Gemini(api_key="fake-key")
gemini.provider = MagicMock()
+787
View File
@@ -0,0 +1,787 @@
"""
Tests for the MCP semantic retrieval tools (#1235).
Covers the six acceptance behaviours proposed in the issue:
1. store_document chunks content and stores it in a real supported
vector backend with provenance metadata (status / version / hash).
2. retrieve_context returns semantically relevant chunks with scores
and provenance, combined with related graph relationships.
3. update_document replaces stored content under (source, version).
4. remove_document deletes every chunk of a document.
5. Remove-then-store does not collide with surviving in-memory ids
(regression guard for the #1029 interaction).
6. The same tool set works against the sqlite backend (real persistent
store, skipped when the sqlite_vec extension is missing).
"""
import os
import sys
import tempfile
import unittest
import zlib
from unittest.mock import patch
import numpy as np
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import semantica_mcp.mcp.session as session
import semantica.embeddings as _embeddings_pkg
import semantica.vector_store.vector_store as _vs_module
from semantica_mcp.mcp.session import get_vector_store, reset_vector_store
from semantica_mcp.mcp.tools import TOOL_DEFINITIONS
from semantica_mcp.mcp.tools.retrieval import (
_chunk_id,
_chunk_text,
handle_remove_document,
handle_retrieve_context,
handle_store_document,
handle_update_document,
)
class FakeTextEmbedder:
def __init__(self, dim: int = 64):
self.dim = dim
def get_embedding_dimension(self) -> int:
return self.dim
class FakeEmbedder:
"""
Deterministic keyword-bag embedder on a fixed dimension.
Same words land on the same dimensions, so a query sharing vocabulary
with a chunk scores higher than one that does not enough signal for
ranking assertions without any model download. crc32 keeps the
word-to-dimension mapping stable across processes (unlike builtin
hash(), whose per-process salt would make collisions flaky), and 64
dims keep the test keywords collision-free.
"""
def __init__(self, dim: int = 64):
self.dim = dim
self.text_embedder = FakeTextEmbedder(dim)
def get_text_method(self) -> str:
return "fake"
def generate_embeddings(self, texts):
out = []
for t in texts:
v = np.zeros(self.dim, dtype=float)
for w in str(t).lower().split():
if w == "x":
# "x" is the filler make_doc pads with; treating it
# as a stopword keeps vectors keyword-driven instead
# of filler-dominated.
continue
v[zlib.crc32(w.encode("utf-8")) % self.dim] += 1.0
norm = np.linalg.norm(v)
if norm:
v /= norm
out.append(v)
return np.array(out)
def make_doc(*keywords) -> str:
"""
Build filler text with exactly one keyword per chunk.
With the default 1000 window / 200 overlap, chunk i covers
[800*i, 800*i+1000). Keyword i is placed at 800*i + 300, which sits
inside chunk i only clear of both neighbouring overlap zones.
Filler is spaced "x " tokens, which the fake embedder treats as a
stopword, so chunk vectors are keyword-driven.
"""
filler = "x "
parts = []
pos = 0
for i, word in enumerate(keywords):
target = 800 * i + 300
parts.append(filler * ((target - pos) // 2))
parts.append(word + " ")
pos = target + len(word) + 1
parts.append(filler * 30)
return "".join(parts)
def patch_embedding_generators():
"""
Patch every EmbeddingGenerator construction site with FakeEmbedder.
The real EmbeddingGenerator probes FastEmbed / sentence-transformers
on init; where those packages are installed but the model is not
cached, the probe blocks on a full TCP connect timeout (~30s each).
VectorStore's in-memory branch builds one internally, so tests patch
both import sites to keep the suite fast and network-free.
"""
return (
patch.object(_embeddings_pkg, "EmbeddingGenerator", FakeEmbedder),
patch.object(_vs_module, "EmbeddingGenerator", FakeEmbedder),
)
def _clear_retrieval_env():
for var in ("SEMANTICA_VECTOR_PATH", "SEMANTICA_VECTOR_BACKEND", "SEMANTICA_VECTOR_DB_PATH"):
os.environ.pop(var, None)
class InmemoryBackendTestBase(unittest.TestCase):
def setUp(self):
_clear_retrieval_env()
session._embedder = FakeEmbedder()
session._vector_store = None
session._graph = None
self._patches = patch_embedding_generators()
for p in self._patches:
p.start()
def tearDown(self):
for p in self._patches:
p.stop()
session._embedder = None
reset_vector_store()
session._graph = None
_clear_retrieval_env()
class TestChunking(InmemoryBackendTestBase):
def test_fixed_window_with_overlap(self):
text = "a" * 2600
chunks = _chunk_text(text, 1000, 200)
self.assertEqual([c[:2] for c in chunks], [(0, 1000), (800, 1800), (1600, 2600)])
self.assertTrue(all(c == text[s:e] for s, e, c in chunks))
def test_short_text_single_chunk(self):
chunks = _chunk_text("short", 1000, 200)
self.assertEqual(chunks, [(0, 5, "short")])
def test_overlap_must_be_smaller_than_window(self):
with self.assertRaises(ValueError):
_chunk_text("abc", 200, 200)
def test_chunk_id_is_stable_and_position_sensitive(self):
a = _chunk_id("src", "v1", 0, "hello")
b = _chunk_id("src", "v1", 0, "hello")
c = _chunk_id("src", "v1", 1, "hello")
self.assertEqual(a, b)
self.assertNotEqual(a, c)
class TestStoreDocument(InmemoryBackendTestBase):
def test_chunks_carry_provenance_metadata(self):
result = handle_store_document(
{
"content": make_doc("alpha", "beta"),
"source": "policy_manual#p12",
"authority": "official",
"version": "v2",
"project": "lending",
}
)
self.assertNotIn("error", result)
self.assertEqual(result["status"], "stored")
self.assertEqual(result["chunk_count"], len(result["chunk_ids"]))
store = get_vector_store()
first = next(
m
for m in store.metadata.values()
if m.get("source") == "policy_manual#p12" and m.get("chunk_index") == 0
)
self.assertEqual(first["chunk_id"], result["chunk_ids"][0])
self.assertEqual(first["authority"], "official")
self.assertEqual(first["version"], "v2")
self.assertEqual(first["project"], "lending")
self.assertEqual(first["status"], "active")
self.assertEqual(first["hash"], result["hash"])
self.assertEqual(first["char_start"], 0)
def test_identical_content_is_a_noop(self):
args = {"content": "same content", "source": "doc", "authority": "official"}
first = handle_store_document(args)
second = handle_store_document(args)
self.assertEqual(second["status"], "unchanged")
self.assertEqual(second["chunk_ids"], first["chunk_ids"])
self.assertEqual(get_vector_store().count(), first["chunk_count"])
def test_missing_authority_rejected(self):
result = handle_store_document({"content": "text", "source": "doc"})
self.assertIn("error", result)
def test_caller_metadata_cannot_override_provenance(self):
result = handle_store_document(
{
"content": make_doc("alpha"),
"source": "real_source",
"authority": "official",
"metadata": {
"source": "spoofed_source",
"authority": "backdated",
"status": "tombstone",
"hash": "deadbeef",
"version": "v99",
"project": "shadow_project",
"dept": "risk",
},
}
)
self.assertNotIn("error", result)
store = get_vector_store()
meta = next(
m
for m in store.metadata.values()
if m.get("chunk_id") == result["chunk_ids"][0]
)
self.assertEqual(meta["source"], "real_source")
self.assertEqual(meta["authority"], "official")
self.assertEqual(meta["status"], "active")
self.assertEqual(meta["hash"], result["hash"])
self.assertEqual(meta["version"], "v1")
self.assertNotIn("project", meta)
# Non-provenance keys still land.
self.assertEqual(meta["dept"], "risk")
# Provenance stays intact, so the idempotent no-op still works.
again = handle_store_document(
{
"content": make_doc("alpha"),
"source": "real_source",
"authority": "official",
}
)
self.assertEqual(again["status"], "unchanged")
def test_non_dict_metadata_rejected(self):
result = handle_store_document(
{"content": "text", "source": "doc", "authority": "official", "metadata": ["bad"]}
)
self.assertIn("error", result)
class TestRetrieveContext(InmemoryBackendTestBase):
def setUp(self):
super().setUp()
handle_store_document(
{
"content": make_doc("approval", "collateral", "interest"),
"source": "lending_policy",
"authority": "official",
"project": "lending",
}
)
handle_store_document(
{
"content": make_doc("payment", "refund"),
"source": "billing_faq",
"authority": "draft",
"project": "billing",
}
)
def test_relevant_chunks_ranked_with_provenance(self):
result = handle_retrieve_context({"query": "collateral", "top_k": 3})
self.assertNotIn("error", result)
self.assertGreater(result["count"], 0)
relevant = [r for r in result["results"] if r["score"] and r["score"] > 0]
self.assertTrue(relevant)
top = relevant[0]
self.assertIn("collateral", top["text"])
self.assertEqual(top["source"], "lending_policy")
self.assertEqual(top["authority"], "official")
self.assertEqual(top["version"], "v1")
self.assertEqual(top["status"], "active")
self.assertTrue(top["hash"])
self.assertIsInstance(top["score"], float)
def test_top_k_is_capped_at_ten(self):
# 13 chunks (one keyword per chunk) so the cap is actually hit;
# with fewer stored chunks the assertion would pass trivially.
handle_store_document(
{
"content": make_doc(*["k%02d" % i for i in range(1, 14)]),
"source": "capdoc",
"authority": "official",
}
)
result = handle_retrieve_context({"query": "k01", "top_k": 99})
self.assertEqual(result["count"], 10)
def test_project_filter_narrows_results(self):
result = handle_retrieve_context({"query": "collateral", "project": "billing"})
for r in result["results"]:
self.assertEqual(r["project"], "billing")
def test_graph_relationships_attached(self):
graph = session.get_graph()
graph.add_node(
node_id="policy_doc_lending_policy",
label="Lending policy doc",
node_type="Document",
metadata={"source": "lending_policy"},
)
graph.add_node(node_id="risk_team", label="Risk team", node_type="Team")
graph.add_edge(
source_id="policy_doc_lending_policy",
target_id="risk_team",
edge_type="OWNED_BY",
)
result = handle_retrieve_context({"query": "collateral"})
self.assertGreaterEqual(len(result["graph_context"]), 1)
rel = result["graph_context"][0]
self.assertEqual(rel["node"]["source"], "lending_policy")
self.assertEqual(rel["related"]["id"], "risk_team")
self.assertEqual(rel["relationship"], "OWNED_BY")
def test_empty_query_rejected(self):
result = handle_retrieve_context({"query": " "})
self.assertIn("error", result)
class TestUpdateDocument(InmemoryBackendTestBase):
def test_update_replaces_chunks(self):
handle_store_document(
{
"content": make_doc("oldterm", "legacy"),
"source": "handbook",
"authority": "official",
}
)
result = handle_update_document(
{
"content": make_doc("newterm"),
"source": "handbook",
"version": "v1",
}
)
self.assertEqual(result["status"], "updated")
self.assertEqual(result["chunk_count"], 1)
hits = handle_retrieve_context({"query": "newterm"})["results"]
hits = [h for h in hits if h["score"] and h["score"] > 0]
self.assertTrue(hits and "newterm" in hits[0]["text"])
stale = handle_retrieve_context({"query": "oldterm"})["results"]
stale = [h for h in stale if h["score"] and h["score"] > 0]
self.assertEqual(stale, [])
# Authority is inherited from the stored version when omitted.
self.assertEqual(hits[0]["authority"], "official")
self.assertEqual(get_vector_store().count(), 1)
def test_update_rolls_back_when_new_write_fails(self):
handle_store_document(
{
"content": make_doc("oldterm", "legacy"),
"source": "handbook",
"authority": "official",
}
)
store = get_vector_store()
real_store_vectors = store.store_vectors
def failing_write(vectors, metas):
if any("phoenix" in (m.get("text") or "") for m in metas):
raise RuntimeError("simulated write failure")
return real_store_vectors(vectors, metas)
with patch.object(store, "store_vectors", side_effect=failing_write):
result = handle_update_document(
{"content": make_doc("phoenix"), "source": "handbook"}
)
self.assertIn("error", result)
self.assertIn("simulated write failure", result["error"])
# The old document must survive the failed replacement, with no
# trace of the new content.
store = get_vector_store()
self.assertEqual(store.count(), 2)
old = [
h
for h in handle_retrieve_context({"query": "oldterm"})["results"]
if h["score"] and h["score"] > 0
]
self.assertTrue(old and "oldterm" in old[0]["text"])
self.assertEqual(old[0]["source"], "handbook")
self.assertEqual(old[0]["authority"], "official")
phoenix = [
h
for h in handle_retrieve_context({"query": "phoenix"})["results"]
if h["score"] and h["score"] > 0
]
self.assertEqual(phoenix, [])
def test_update_missing_document_reports_not_found(self):
result = handle_update_document(
{
"content": make_doc("neverseen"),
"source": "never_stored",
"version": "v1",
}
)
self.assertEqual(result["status"], "not_found")
self.assertEqual(result["source"], "never_stored")
self.assertEqual(result["version"], "v1")
self.assertEqual(get_vector_store().count(), 0)
class TestRemoveDocument(InmemoryBackendTestBase):
def test_remove_deletes_every_chunk(self):
handle_store_document(
{
"content": make_doc("alpha", "beta", "gamma"),
"source": "docA",
"authority": "official",
}
)
result = handle_remove_document({"source": "docA"})
self.assertEqual(result["status"], "removed")
self.assertEqual(result["removed_chunks"], 3)
self.assertEqual(get_vector_store().count(), 0)
again = handle_remove_document({"source": "docA"})
self.assertEqual(again["status"], "not_found")
def test_remove_missing_document_reports_not_found(self):
result = handle_remove_document({"source": "never_stored"})
self.assertEqual(result["status"], "not_found")
class TestInMemoryIdCollisionRegression(InmemoryBackendTestBase):
"""
#1029 interaction guard.
In-memory vector ids are ``vec_{len(self.vectors) + i}``. Deleting a
document that is NOT a suffix makes len() fall below surviving ids, so
the next plain write overwrites live data. Our rebuild path must
prevent that: store a 1-chunk doc, then a 3-chunk doc, remove the
1-chunk one, then store another doc. Without the rebuild the last
store lands on the surviving document's third chunk id and destroys
it.
"""
def test_remove_then_store_keeps_surviving_chunks_intact(self):
handle_store_document(
{"content": make_doc("alpha"), "source": "docA", "authority": "official"}
)
handle_store_document(
{
"content": make_doc("bravo", "charlie", "delta"),
"source": "docB",
"authority": "official",
}
)
self.assertEqual(get_vector_store().count(), 4)
removed = handle_remove_document({"source": "docA"})
self.assertEqual(removed["status"], "removed")
stored = handle_store_document(
{"content": make_doc("echo"), "source": "docC", "authority": "official"}
)
self.assertEqual(stored["status"], "stored")
store = get_vector_store()
self.assertEqual(store.count(), 4)
delta_hits = handle_retrieve_context({"query": "delta"})["results"]
delta_hits = [h for h in delta_hits if h["score"] and h["score"] > 0]
self.assertTrue(delta_hits, "docB's third chunk was destroyed by an id collision")
self.assertIn("delta", delta_hits[0]["text"])
self.assertEqual(delta_hits[0]["source"], "docB")
for keyword, expected_source in (
("bravo", "docB"),
("charlie", "docB"),
("echo", "docC"),
):
hits = [
h
for h in handle_retrieve_context({"query": keyword})["results"]
if h["score"] and h["score"] > 0
]
self.assertTrue(hits, f"expected a hit for {keyword}")
self.assertEqual(hits[0]["source"], expected_source)
class TestBackendPolicy(InmemoryBackendTestBase):
def test_unsupported_backend_fails_fast(self):
# faiss/pgvector lack a metadata-scoped delete, so update/remove
# cannot work on them; selecting them must fail at startup, not
# mid-update.
for backend in ("faiss", "pgvector"):
with self.subTest(backend=backend):
os.environ["SEMANTICA_VECTOR_BACKEND"] = backend
with self.assertRaises(ValueError) as ctx:
get_vector_store()
self.assertIn("not supported", str(ctx.exception))
def test_oversized_document_rejected_before_embedding(self):
# chunk_size=1 turns a 12k-char body into 12k chunks, crossing
# the ingestion cap without any expensive embedding work.
result = handle_store_document(
{
"content": "ab" * 6000,
"source": "bigdoc",
"authority": "official",
"chunk_size": 1,
"chunk_overlap": 0,
}
)
self.assertIn("error", result)
self.assertIn("chunks", result["error"])
self.assertEqual(get_vector_store().count(), 0)
class TestToolRegistration(unittest.TestCase):
def test_retrieval_tools_are_registered(self):
retrieval = {
t["name"]: t
for t in TOOL_DEFINITIONS
if t["name"] in ("store_document", "retrieve_context", "update_document", "remove_document")
}
self.assertEqual(len(retrieval), 4)
for name, t in retrieval.items():
self.assertTrue(callable(t["_handler"]))
self.assertIn("required", t["inputSchema"])
class TestSqliteBackend(unittest.TestCase):
def setUp(self):
try:
import sqlite_vec # noqa: F401
except ImportError:
self.skipTest("sqlite_vec extension not installed")
self.tmpdir = tempfile.mkdtemp(prefix="semantica_sqlite_test_")
self.patches = patch_embedding_generators()
for p in self.patches:
p.start()
_clear_retrieval_env()
os.environ["SEMANTICA_VECTOR_BACKEND"] = "sqlite"
os.environ["SEMANTICA_VECTOR_DB_PATH"] = os.path.join(self.tmpdir, "vectors.db")
session._embedder = FakeEmbedder()
session._vector_store = None
def tearDown(self):
for p in self.patches:
p.stop()
session._embedder = None
reset_vector_store()
_clear_retrieval_env()
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
def test_sqlite_backend_roundtrip(self):
handle_store_document(
{
"content": make_doc("alpha", "beta"),
"source": "docS",
"authority": "official",
}
)
hits = handle_retrieve_context({"query": "beta"})["results"]
self.assertTrue(hits and "beta" in hits[0]["text"])
self.assertEqual(hits[0]["source"], "docS")
updated = handle_update_document(
{"content": make_doc("gamma"), "source": "docS"}
)
self.assertEqual(updated["status"], "updated")
# NB: score scales differ across backends (sqlite maps distance
# through 1/(1+d), so an orthogonal chunk still scores 0.5).
# Assert on text, the only backend-independent signal.
stale = [
h
for h in handle_retrieve_context({"query": "beta"})["results"]
if "beta" in (h.get("text") or "")
]
self.assertEqual(stale, [])
self.assertTrue(handle_retrieve_context({"query": "gamma"})["results"])
removed = handle_remove_document({"source": "docS"})
self.assertEqual(removed["status"], "removed")
self.assertEqual(get_vector_store().count(), 0)
def test_sqlite_multi_document_isolation(self):
handle_store_document(
{
"content": make_doc("harbor", "vessel"),
"source": "nav_docs",
"authority": "official",
}
)
handle_store_document(
{
"content": make_doc("ledger", "invoice"),
"source": "fin_docs",
"authority": "draft",
"version": "v2",
}
)
nav = [
h
for h in handle_retrieve_context({"query": "vessel"})["results"]
if "vessel" in (h.get("text") or "")
]
self.assertTrue(nav)
self.assertEqual(nav[0]["source"], "nav_docs")
self.assertEqual(nav[0]["authority"], "official")
self.assertEqual(nav[0]["status"], "active")
self.assertTrue(nav[0]["hash"])
# Updating one document must leave the other untouched.
updated = handle_update_document(
{"content": make_doc("anchor"), "source": "nav_docs"}
)
self.assertEqual(updated["status"], "updated")
fin = [
h
for h in handle_retrieve_context({"query": "invoice"})["results"]
if "invoice" in (h.get("text") or "")
]
self.assertTrue(fin)
self.assertEqual(fin[0]["source"], "fin_docs")
self.assertEqual(fin[0]["authority"], "draft")
vessel_stale = [
h
for h in handle_retrieve_context({"query": "vessel"})["results"]
if "vessel" in (h.get("text") or "")
]
self.assertEqual(vessel_stale, [])
# Removing the other document must leave the first intact.
removed = handle_remove_document({"source": "fin_docs", "version": "v2"})
self.assertEqual(removed["status"], "removed")
anchor = [
h
for h in handle_retrieve_context({"query": "anchor"})["results"]
if "anchor" in (h.get("text") or "")
]
self.assertTrue(anchor and anchor[0]["source"] == "nav_docs")
ledger_stale = [
h
for h in handle_retrieve_context({"query": "ledger"})["results"]
if "ledger" in (h.get("text") or "")
]
self.assertEqual(ledger_stale, [])
def test_sqlite_update_rolls_back_on_write_failure(self):
# Persistent path: removal is a direct delete_vectors, so the
# rollback has to re-store the snapshotted rows (plain lists,
# not arrays) when the new write fails.
handle_store_document(
{
"content": make_doc("oldterm", "legacy"),
"source": "handbook",
"authority": "official",
}
)
store = get_vector_store()
real_store_vectors = store.store_vectors
def failing_write(vectors, metas):
if any("phoenix" in (m.get("text") or "") for m in metas):
raise RuntimeError("simulated write failure")
return real_store_vectors(vectors, metas)
with patch.object(store, "store_vectors", side_effect=failing_write):
result = handle_update_document(
{"content": make_doc("phoenix"), "source": "handbook"}
)
self.assertIn("error", result)
self.assertEqual(get_vector_store().count(), 2)
old = [
h
for h in handle_retrieve_context({"query": "oldterm"})["results"]
if "oldterm" in (h.get("text") or "")
]
self.assertTrue(old and old[0]["source"] == "handbook")
def test_sqlite_without_db_path_raises(self):
os.environ.pop("SEMANTICA_VECTOR_DB_PATH", None)
with self.assertRaises(ValueError):
get_vector_store()
class TestPersistence(InmemoryBackendTestBase):
def test_store_persists_and_reloads(self):
tmpdir = tempfile.mkdtemp(prefix="semantica_vec_test_")
try:
os.environ["SEMANTICA_VECTOR_PATH"] = tmpdir
result = handle_store_document(
{"content": make_doc("persist"), "source": "docP", "authority": "official"}
)
self.assertTrue(result["persisted"])
self.assertTrue(os.path.isfile(os.path.join(tmpdir, "store_data.json")))
# Fresh session state: the store must reload from disk.
reset_vector_store()
hits = handle_retrieve_context({"query": "persist"})["results"]
self.assertTrue(hits and "persist" in hits[0]["text"])
self.assertEqual(hits[0]["source"], "docP")
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
def test_persist_failure_is_reported_not_silent(self):
tmpdir = tempfile.mkdtemp(prefix="semantica_vec_test_")
try:
os.environ["SEMANTICA_VECTOR_PATH"] = tmpdir
store = get_vector_store()
with patch.object(store, "save", side_effect=RuntimeError("disk full")):
result = handle_store_document(
{"content": make_doc("volatile"), "source": "docV", "authority": "official"}
)
# The write itself succeeded; only the durable copy failed.
self.assertEqual(result["status"], "stored")
self.assertFalse(result["persisted"])
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
def test_reload_dimension_mismatch_rejected(self):
tmpdir = tempfile.mkdtemp(prefix="semantica_vec_test_")
try:
os.environ["SEMANTICA_VECTOR_PATH"] = tmpdir
handle_store_document(
{"content": make_doc("persist"), "source": "docP", "authority": "official"}
)
# A different embedder dimension must not silently rank
# vectors from an incompatible embedding space.
session._embedder = FakeEmbedder(32)
reset_vector_store()
with self.assertRaises(ValueError) as ctx:
get_vector_store()
self.assertIn("dimension", str(ctx.exception))
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
def test_corrupt_store_fails_on_startup(self):
"""A broken persisted store must raise immediately, not silently
fall back to an empty store that would overwrite the user's data
on the first persist."""
tmpdir = tempfile.mkdtemp(prefix="semantica_vec_test_")
try:
# Drop a file that looks like a store directory but won't load.
with open(os.path.join(tmpdir, "store_data.json"), "w") as f:
f.write("{not valid json")
os.environ["SEMANTICA_VECTOR_PATH"] = tmpdir
reset_vector_store()
with self.assertRaises(ValueError) as ctx:
get_vector_store()
self.assertIn("Could not load", str(ctx.exception))
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -236,11 +236,11 @@ class TestNERConfigurations(unittest.TestCase):
mock_spacy.load.return_value = mock_nlp
with patch('semantica.semantic_extract.methods.SPACY_AVAILABLE', True):
# Init extractor with list of methods
extractor = NERExtractor(method=["llm", "ml"], ensemble_voting=True)
# Explicit union retains complementary single-method entities.
extractor = NERExtractor(method=["llm", "ml"], merge_strategy="union")
entities = extractor.extract_entities(self.text)
# Since ensemble_voting=True (implied merge), we expect unique entities
# Union keeps unique entities from every successful method.
# Apple Inc (from both) + Steve Jobs (from ML)
texts = [e.text for e in entities]
@@ -0,0 +1,662 @@
"""Tests for FAISSIndex.delete_vectors and FAISSStore.delete_vectors (#1374)."""
import numpy as np
import pytest
from semantica.context.erasure import (
STATUS_ERASED,
STATUS_UNSUPPORTED,
ErasureCoordinator,
)
from semantica.utils.exceptions import ProcessingError
from semantica.vector_store import VectorStore
from semantica.vector_store.faiss_store import FAISSIndex, FAISSStore
# ---------------------------------------------------------------------------
# Fixtures and helpers
# ---------------------------------------------------------------------------
def _flat_index(dim: int = 3) -> "faiss.IndexFlatL2": # noqa: F821
faiss = pytest.importorskip("faiss")
return faiss.IndexFlatL2(dim)
def _populated_store(
dim: int = 3,
ids=("a", "b", "c", "d", "e"),
meta=None,
):
"""Return an FAISSStore with *ids* already inserted (random unit vectors)."""
faiss = pytest.importorskip("faiss")
store = FAISSStore(dimension=dim)
n = len(ids)
rng = np.random.default_rng(seed=42)
vectors = rng.random((n, dim)).astype(np.float32)
metadata = meta or [{} for _ in ids]
store.add_vectors(vectors, ids=list(ids), metadata=metadata)
return store
def _populated_index(dim: int = 3, ids=("a", "b", "c", "d", "e")):
"""Return a bare FAISSIndex with *ids* inserted (random unit vectors)."""
faiss = pytest.importorskip("faiss")
idx = FAISSIndex(faiss.IndexFlatL2(dim), dimension=dim)
n = len(ids)
rng = np.random.default_rng(seed=42)
vectors = rng.random((n, dim)).astype(np.float32)
idx.add_vectors(vectors, ids=list(ids))
return idx
# ---------------------------------------------------------------------------
# FAISSIndex-level unit tests
# ---------------------------------------------------------------------------
class TestFAISSIndexDeleteVectors:
def test_delete_single_existing_id(self):
idx = _populated_index()
result = idx.delete_vectors(["b"])
assert result == {"delete_count": 1}
assert "b" not in idx.vector_ids
assert idx.index.ntotal == len(idx.vector_ids) == 4
def test_delete_multiple_existing_ids(self):
idx = _populated_index()
result = idx.delete_vectors(["b", "d"])
assert result == {"delete_count": 2}
assert "b" not in idx.vector_ids
assert "d" not in idx.vector_ids
assert sorted(idx.vector_ids) == ["a", "c", "e"]
assert idx.index.ntotal == 3
def test_delete_nonexistent_id_is_noop(self):
idx = _populated_index()
result = idx.delete_vectors(["z"])
assert result == {"delete_count": 0}
assert len(idx.vector_ids) == 5
assert idx.index.ntotal == 5
def test_delete_empty_list_is_noop(self):
idx = _populated_index()
result = idx.delete_vectors([])
assert result == {"delete_count": 0}
assert len(idx.vector_ids) == 5
def test_delete_duplicate_ids_in_request_only_removes_once(self):
idx = _populated_index()
result = idx.delete_vectors(["b", "b", "b"])
assert result == {"delete_count": 1}
assert "b" not in idx.vector_ids
assert len(idx.vector_ids) == 4
def test_delete_count_reflects_actual_removal(self):
idx = _populated_index()
# "z" doesn't exist; only "a" and "c" do
result = idx.delete_vectors(["a", "c", "z"])
assert result == {"delete_count": 2}
def test_metadata_removed_for_deleted_id(self):
faiss = pytest.importorskip("faiss")
idx = FAISSIndex(faiss.IndexFlatL2(3), dimension=3)
vectors = np.eye(3, dtype=np.float32)[:2]
idx.add_vectors(vectors, ids=["x", "y"])
idx.metadata = {"x": {"val": 1}, "y": {"val": 2}}
idx.delete_vectors(["x"])
assert "x" not in idx.metadata
assert "y" in idx.metadata
def test_vector_ids_list_stays_parallel_to_faiss_ntotal(self):
idx = _populated_index(ids=["a", "b", "c"])
idx.delete_vectors(["b"])
assert len(idx.vector_ids) == idx.index.ntotal == 2
def test_search_does_not_return_deleted_id(self):
"""After deletion, similarity search must not return the deleted ID."""
faiss = pytest.importorskip("faiss")
idx = FAISSIndex(faiss.IndexFlatL2(3), dimension=3)
vectors = np.array(
[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32
)
idx.add_vectors(vectors, ids=["a", "b", "c"])
idx.delete_vectors(["b"])
query = np.array([[0.0, 1.0, 0.0]], dtype=np.float32)
distances, indices = idx.search(query, k=3)
# Filter both negative sentinels (-1) and out-of-range indices.
returned_ids = [
idx.vector_ids[i]
for i in indices[0]
if 0 <= i < len(idx.vector_ids)
]
assert "b" not in returned_ids
def test_get_vector_returns_none_after_deletion(self):
faiss = pytest.importorskip("faiss")
idx = FAISSIndex(faiss.IndexFlatL2(3), dimension=3)
vectors = np.eye(3, dtype=np.float32)
idx.add_vectors(vectors, ids=["a", "b", "c"])
idx.delete_vectors(["b"])
assert idx.get_vector("b") is None
def test_get_metadata_returns_none_after_deletion(self):
faiss = pytest.importorskip("faiss")
idx = FAISSIndex(faiss.IndexFlatL2(3), dimension=3)
idx.add_vectors(np.eye(3, dtype=np.float32)[:2], ids=["a", "b"])
idx.metadata = {"a": {"k": 1}, "b": {"k": 2}}
idx.delete_vectors(["b"])
assert idx.get_metadata("b") is None
def test_add_vectors_after_deletion_works(self):
"""Inserting new vectors after deletion maintains correct position mapping."""
idx = _populated_index(ids=["a", "b", "c"])
idx.delete_vectors(["b"])
new_vecs = np.array([[0.5, 0.5, 0.0]], dtype=np.float32)
idx.add_vectors(new_vecs, ids=["new"])
assert "new" in idx.vector_ids
assert len(idx.vector_ids) == idx.index.ntotal == 3
def test_save_load_after_deletion_preserves_state(self, tmp_path):
"""Deletion persists correctly through save/load round-trip."""
_ = pytest.importorskip("faiss")
idx = _populated_index(ids=["a", "b", "c"])
idx.delete_vectors(["b"])
path = tmp_path / "idx.faiss"
idx.save(path)
loaded = FAISSIndex.load(path, dimension=3)
assert "b" not in loaded.vector_ids
assert sorted(loaded.vector_ids) == ["a", "c"]
assert loaded.index.ntotal == 2
def test_hnsw_delete_raises_not_implemented(self):
"""HNSW does not support remove_ids; must raise NotImplementedError."""
faiss = pytest.importorskip("faiss")
hnsw = FAISSIndex(faiss.IndexHNSWFlat(4, 16), dimension=4)
vecs = np.random.rand(5, 4).astype(np.float32)
hnsw.add_vectors(vecs, ids=["a", "b", "c", "d", "e"])
with pytest.raises(NotImplementedError):
hnsw.delete_vectors(["a"])
# Python-side state must be untouched
assert len(hnsw.vector_ids) == 5
def test_ivf_delete_raises_not_implemented(self):
"""IVF does not compact labels after remove_ids; raise NotImplementedError.
IVF surviving labels stay sparse (0,2,4 not 0,1,2), so the list-compact
approach used by Flat would desynchronize search labels from vector_ids.
"""
faiss = pytest.importorskip("faiss")
dim = 4
train = np.random.rand(80, dim).astype(np.float32)
q = faiss.IndexFlatL2(dim)
ivf = faiss.IndexIVFFlat(q, dim, 2)
ivf.train(train)
idx = FAISSIndex(ivf, dimension=dim)
vecs = np.random.rand(5, dim).astype(np.float32)
idx.add_vectors(vecs, ids=["a", "b", "c", "d", "e"])
with pytest.raises(NotImplementedError):
idx.delete_vectors(["b"])
# Python-side state must be completely untouched
assert idx.vector_ids == ["a", "b", "c", "d", "e"]
# ---------------------------------------------------------------------------
# FAISSStore-level unit tests
# ---------------------------------------------------------------------------
class TestFAISSStoreDeleteVectors:
def test_delete_uninitialized_index_raises_processing_error(self):
store = FAISSStore(dimension=3)
with pytest.raises(ProcessingError, match="Index not initialized"):
store.delete_vectors(["a"])
def test_delete_existing_id_returns_dict(self):
_ = pytest.importorskip("faiss")
store = _populated_store(ids=["a", "b", "c"])
result = store.delete_vectors(["b"])
assert result == {"delete_count": 1}
def test_delete_reduces_count(self):
_ = pytest.importorskip("faiss")
store = _populated_store(ids=["a", "b", "c"])
assert store.count() == 3
store.delete_vectors(["b"])
assert store.count() == 2
def test_delete_multiple_ids(self):
_ = pytest.importorskip("faiss")
store = _populated_store(ids=["a", "b", "c", "d"])
result = store.delete_vectors(["a", "c"])
assert result == {"delete_count": 2}
assert store.count() == 2
def test_delete_empty_input_is_noop(self):
_ = pytest.importorskip("faiss")
store = _populated_store(ids=["a", "b"])
result = store.delete_vectors([])
assert result == {"delete_count": 0}
assert store.count() == 2
def test_delete_nonexistent_id_is_zero(self):
_ = pytest.importorskip("faiss")
store = _populated_store(ids=["a", "b"])
result = store.delete_vectors(["z"])
assert result == {"delete_count": 0}
assert store.count() == 2
def test_duplicate_ids_in_request(self):
_ = pytest.importorskip("faiss")
store = _populated_store(ids=["a", "b"])
result = store.delete_vectors(["a", "a"])
assert result == {"delete_count": 1}
assert store.count() == 1
def test_metadata_cleaned_up(self):
_ = pytest.importorskip("faiss")
store = _populated_store(
ids=["a", "b"],
meta=[{"owner": "alice"}, {"owner": "bob"}],
)
store.delete_vectors(["a"])
assert store.get_metadata("a") is None
assert store.get_metadata("b") == {"owner": "bob"}
def test_get_vector_returns_none_after_deletion(self):
_ = pytest.importorskip("faiss")
store = _populated_store(ids=["a", "b"])
store.delete_vectors(["a"])
assert store.get_vector("a") is None
def test_search_excludes_deleted_vector(self):
"""search_similar must not return a deleted vector's ID."""
faiss = pytest.importorskip("faiss")
store = FAISSStore(dimension=3)
vectors = np.array(
[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32
)
store.add_vectors(vectors, ids=["a", "b", "c"])
store.delete_vectors(["b"])
query = np.array([0.0, 1.0, 0.0], dtype=np.float32)
results = store.search_similar(query, k=3)
returned_ids = [r["id"] for r in results]
assert "b" not in returned_ids
def test_add_vectors_after_deletion(self):
_ = pytest.importorskip("faiss")
store = _populated_store(ids=["a", "b", "c"])
store.delete_vectors(["b"])
vecs = np.array([[0.5, 0.5, 0.0]], dtype=np.float32)
store.add_vectors(vecs, ids=["new"])
assert store.count() == 3
assert store.get_vector("new") is not None
def test_save_load_after_deletion(self, tmp_path):
"""Deleted vectors do not reappear after save/load."""
_ = pytest.importorskip("faiss")
store = _populated_store(ids=["a", "b", "c"])
store.delete_vectors(["b"])
path = tmp_path / "store.faiss"
store.save_index(path)
fresh = FAISSStore(dimension=3)
fresh.load_index(path)
assert fresh.count() == 2
assert "b" not in fresh.index.vector_ids
assert fresh.get_vector("b") is None
def test_options_kwarg_is_accepted_and_ignored(self):
"""delete_vectors(**options) must not crash even with extra kwargs."""
_ = pytest.importorskip("faiss")
store = _populated_store(ids=["a"])
result = store.delete_vectors(["a"], unused_option=True)
assert result["delete_count"] == 1
def test_hnsw_raises_not_implemented(self):
"""FAISSStore.delete_vectors on HNSW must propagate NotImplementedError."""
faiss = pytest.importorskip("faiss")
store = FAISSStore(dimension=4)
store.create_index(index_type="hnsw", metric="L2")
vecs = np.random.rand(5, 4).astype(np.float32)
store.add_vectors(vecs, ids=["a", "b", "c", "d", "e"])
with pytest.raises(NotImplementedError):
store.delete_vectors(["a"])
# Count must be unchanged
assert store.count() == 5
def test_ivf_raises_not_implemented(self):
"""FAISSStore.delete_vectors on IVF must raise NotImplementedError.
IVF remove_ids preserves original labels rather than compacting them,
which would desynchronize search labels from vector_ids.
"""
faiss = pytest.importorskip("faiss")
store = FAISSStore(dimension=4)
# nlist=2 so we only need >= 2*39 = 78 training points
store.create_index(index_type="ivf", metric="L2", nlist=2)
train = np.random.rand(80, 4).astype(np.float32)
store.index.index.train(train)
store.add_vectors(train[:5], ids=["a", "b", "c", "d", "e"])
with pytest.raises(NotImplementedError):
store.delete_vectors(["a"])
# State must be completely unchanged
assert store.count() == 5
def test_delete_with_loaded_index_auto_saves(self, tmp_path):
"""Deletion on a store loaded from disk auto-saves without explicit save_index."""
_ = pytest.importorskip("faiss")
# Create, populate, save
store = _populated_store(ids=["a", "b", "c"])
path = tmp_path / "store.faiss"
store.save_index(path)
# Load into a fresh store and delete
loaded = FAISSStore(dimension=3)
loaded.load_index(path)
loaded.delete_vectors(["b"])
# Reload without any additional save call — deletion must have persisted
reloaded = FAISSStore(dimension=3)
reloaded.load_index(path)
assert reloaded.count() == 2
assert "b" not in reloaded.index.vector_ids
def test_default_id_no_collision_after_deletion(self):
"""Default vec_N IDs must not reuse a surviving ID after deletion."""
_ = pytest.importorskip("faiss")
store = _populated_store(ids=["vec_0", "vec_1", "vec_2"])
# Delete the middle one; len(vector_ids) drops to 2
store.delete_vectors(["vec_1"])
assert store.count() == 2
# Add a new vector — without the monotonic counter, the default ID
# would be vec_2 which already exists and would be silently skipped.
new_vecs = np.random.rand(1, 3).astype(np.float32)
returned_ids = store.add_vectors(new_vecs)
# The returned ID must not be an existing one
assert returned_ids[0] not in {"vec_0", "vec_2"}, (
f"Default ID {returned_ids[0]} collides with a surviving ID"
)
# And the vector must actually have been inserted
assert store.count() == 3
def test_default_id_skip_past_explicit_id(self):
"""Blocker: default IDs must skip over explicit IDs already in the store.
If a user inserts an explicit ``"vec_N"`` and then adds two vectors
without IDs, the generator must skip ``"vec_N"`` rather than
producing it and losing the second vector silently.
"""
_ = pytest.importorskip("faiss")
store = FAISSStore(dimension=3)
# Explicit vec_1 first
store.add_vectors(np.ones((1, 3), dtype=np.float32), ids=["vec_1"])
# 2 default vectors — one would collide with vec_1 if not skipped
store.add_vectors(np.ones((2, 3), dtype=np.float32))
# 1 more default vector — must get a fresh ID, not re-generate a used one
original_meta = {vid: {"original": vid} for vid in store.index.vector_ids}
for vid, m in original_meta.items():
store.index.metadata[vid] = m
count_before = store.count()
ret = store.add_vectors(
np.ones((1, 3), dtype=np.float32), metadata=[{"new": True}]
)
new_id = ret[0]
assert store.count() == count_before + 1, (
f"Vector was silently skipped; count stayed {store.count()}"
)
assert new_id not in original_meta, (
f"Generated ID {new_id!r} collides with an already-existing ID"
)
# Surviving IDs' metadata must not be overwritten
for vid, m in original_meta.items():
assert store.index.metadata.get(vid) == m, (
f"Metadata for surviving {vid!r} was overwritten"
)
def test_stale_persisted_next_id_is_clamped_to_inferred_minimum(self, tmp_path):
"""Regression: a stale ``next_id`` in the sidecar must be clamped to
at least ``max(vec_N)+1`` so that auto-save after deletion cannot
propagate the stale value and cause future ID collisions.
"""
import json as _json
_ = pytest.importorskip("faiss")
rng = np.random.default_rng(seed=3)
store = FAISSStore(dimension=3)
store.add_vectors(rng.random((5, 3)).astype(np.float32))
# IDs are vec_0..vec_4, next_id=5
path = tmp_path / "s.faiss"
store.save_index(path)
# Corrupt the sidecar: set next_id to a stale low value
meta = _json.loads((tmp_path / "s.faiss.meta.json").read_text())
meta["next_id"] = 2 # stale — vec_2, vec_3, vec_4 still exist
(tmp_path / "s.faiss.meta.json").write_text(_json.dumps(meta))
# Load and immediately delete one vector (auto-save fires)
s2 = FAISSStore(dimension=3)
s2.load_index(path)
assert s2._next_id == 5, f"Stale next_id should be clamped to 5, got {s2._next_id}"
s2.delete_vectors(["vec_3"]) # triggers auto-save
# The sidecar must not carry the stale value forward
persisted = _json.loads((tmp_path / "s.faiss.meta.json").read_text())
assert persisted["next_id"] >= 5, (
f"Auto-save propagated stale next_id={persisted['next_id']} (expected >= 5)"
)
def test_search_does_not_return_phantom_id_when_k_exceeds_ntotal(self):
"""Regression: when k > ntotal, FAISS returns -1 sentinel values.
``-1 < len(vector_ids)`` is always True in Python, so without an
explicit non-negative guard ``-1`` maps to ``vector_ids[-1]``,
making the last vector appear as a spurious extra result.
"""
faiss = pytest.importorskip("faiss")
store = FAISSStore(dimension=3)
vecs = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], dtype=np.float32)
store.add_vectors(vecs, ids=["only_a", "only_b"])
# Ask for 10 neighbors but only 2 exist
results = store.search_similar(
np.array([0.0, 1.0, 0.0], dtype=np.float32), k=10
)
returned_ids = [r["id"] for r in results]
assert len(results) == 2, (
f"Expected exactly 2 results, got {len(results)}: {returned_ids}"
)
assert returned_ids.count("only_b") == 1, (
f"only_b appears {returned_ids.count('only_b')} time(s) — "
"sentinel -1 is mapping to vector_ids[-1]"
)
def test_next_id_persisted_across_delete_save_reload(self, tmp_path):
"""Regression test for critical bug: delete → auto-save → reload → add.
Without persisting ``next_id`` in the sidecar, ``load_index`` would
set ``_next_id = ntotal`` (4 after one deletion from 5 vectors), which
would generate ``"vec_4"`` as the next default ID. That ID is still
present in the surviving vector list, so the insertion would be
silently skipped, the count would not increase, and the old vector's
metadata would be overwritten by the new metadata.
This test pins the full lifecycle so any regression is caught
immediately.
"""
_ = pytest.importorskip("faiss")
rng = np.random.default_rng(seed=7)
dim = 4
# Step 1: create vec_0 .. vec_4, record their embeddings
store1 = FAISSStore(dimension=dim)
vecs = rng.random((5, dim)).astype(np.float32)
store1.add_vectors(vecs)
for vid in store1.index.vector_ids:
store1.index.metadata[vid] = {"original": vid}
path = tmp_path / "idx.faiss"
store1.save_index(path)
# Step 2: reload → delete vec_2 (auto-saves) → reload again
store2 = FAISSStore(dimension=dim)
store2.load_index(path)
store2.delete_vectors(["vec_2"]) # ntotal drops to 4; auto-save triggered
store3 = FAISSStore(dimension=dim)
store3.load_index(path)
# Step 3: add a new vector without an explicit ID
new_vec = rng.random((1, dim)).astype(np.float32)
count_before = store3.count()
returned_ids = store3.add_vectors(new_vec, metadata=[{"new": True}])
# The generated ID must not collide with any surviving ID
surviving = set(store3.index.vector_ids[:count_before])
new_id = returned_ids[0]
assert new_id not in surviving, (
f"Generated ID {new_id!r} collides with surviving ID "
f"(surviving={sorted(surviving)})"
)
# The new vector must actually have been inserted
assert store3.count() == count_before + 1, (
f"Count did not increase: was {count_before}, still {store3.count()}"
)
# The new vector must be retrievable
assert store3.get_vector(new_id) is not None, (
f"New vector with ID {new_id!r} is not retrievable"
)
# The surviving vec_4's embedding must be unchanged
original_vec4 = vecs[4]
loaded_vec4 = store3.get_vector("vec_4")
assert loaded_vec4 is not None
np.testing.assert_allclose(loaded_vec4, original_vec4, atol=1e-5,
err_msg="vec_4 embedding was corrupted by the new add")
# The surviving vec_4's metadata must be unchanged
assert store3.index.metadata.get("vec_4") == {"original": "vec_4"}, (
f"vec_4 metadata was overwritten: {store3.index.metadata.get('vec_4')}"
)
# The new vector's metadata must be the new value
assert store3.index.metadata.get(new_id) == {"new": True}
def test_no_op_delete_does_not_rewrite_disk(self, tmp_path):
"""A deletion of only nonexistent IDs must not call FAISSIndex.save().
Uses a spy on ``FAISSIndex.save`` rather than filesystem mtime so the
assertion is deterministic regardless of filesystem timestamp resolution.
"""
from unittest.mock import patch
_ = pytest.importorskip("faiss")
store = _populated_store(ids=["a", "b", "c"])
path = tmp_path / "idx.faiss"
store.save_index(path)
loaded = FAISSStore(dimension=3)
loaded.load_index(path)
with patch.object(loaded.index, "save", wraps=loaded.index.save) as mock_save:
loaded.delete_vectors(["z"]) # nonexistent → delete_count 0
loaded.delete_vectors([]) # empty list → delete_count 0
assert mock_save.call_count == 0, (
f"save() called {mock_save.call_count} time(s) for a no-op deletion"
)
# A real deletion must still trigger save()
loaded.delete_vectors(["b"])
assert mock_save.call_count == 1, (
f"save() was not called after a real deletion (calls={mock_save.call_count})"
)
# ---------------------------------------------------------------------------
# Facade delegation test
# ---------------------------------------------------------------------------
class TestFAISSFacadeDelegation:
def test_vector_store_facade_delegates_to_faiss_store(self):
"""VectorStore(backend='faiss').delete_vectors() must call FAISSStore."""
_ = pytest.importorskip("faiss")
vs = VectorStore(backend="faiss", config={"dimension": 3})
vecs = np.eye(3, dtype=np.float32)
vs.store_vectors(list(vecs), metadata=[{}, {}, {}])
# Count before
assert vs._backend_store.count() == 3
result = vs.delete_vectors(["vec_0"])
assert result == {"delete_count": 1}
assert vs._backend_store.count() == 2
# ---------------------------------------------------------------------------
# ErasureCoordinator integration tests
# ---------------------------------------------------------------------------
class TestFAISSErasureCoordinator:
def _faiss_vector_store(self, dim: int = 3) -> VectorStore:
_ = pytest.importorskip("faiss")
vs = VectorStore(backend="faiss", config={"dimension": dim})
vecs = np.eye(dim, dtype=np.float32)
vs.store_vectors(list(vecs), metadata=[{}, {}, {}])
return vs
def test_erasure_reports_status_erased(self):
vs = self._faiss_vector_store()
# store_vectors assigns ids "vec_0", "vec_1", "vec_2"
vector_ids = vs._backend_store.index.vector_ids
coord = ErasureCoordinator(vector_store=vs)
receipt = coord.erase_entity(vector_ids[0], vector_ids=[vector_ids[0]])
assert receipt.stores["vectors"]["status"] == STATUS_ERASED
def test_erasure_backend_name_is_faiss(self):
vs = self._faiss_vector_store()
coord = ErasureCoordinator(vector_store=vs)
receipt = coord.erase_entity("vec_0", vector_ids=["vec_0"])
assert receipt.stores["vectors"]["backend"] == "faiss"
def test_erasure_receipt_is_complete_after_deletion(self):
vs = self._faiss_vector_store()
coord = ErasureCoordinator(vector_store=vs)
receipt = coord.erase_entity("vec_0", vector_ids=["vec_0"])
assert receipt.complete
def test_erasure_hnsw_reports_unsupported(self):
"""HNSW deletion raises NotImplementedError; coordinator must report unsupported."""
faiss = pytest.importorskip("faiss")
vs = VectorStore(backend="faiss", config={"dimension": 4})
vs._backend_store.create_index(index_type="hnsw", metric="L2")
vecs = np.random.rand(5, 4).astype(np.float32)
vs._backend_store.add_vectors(vecs, ids=["a", "b", "c", "d", "e"])
coord = ErasureCoordinator(vector_store=vs)
receipt = coord.erase_entity("a", vector_ids=["a"])
assert receipt.stores["vectors"]["status"] == STATUS_UNSUPPORTED
assert not receipt.complete
def test_erasure_ivf_reports_unsupported(self):
"""IVF deletion raises NotImplementedError; coordinator must report unsupported."""
faiss = pytest.importorskip("faiss")
dim = 4
vs = VectorStore(backend="faiss", config={"dimension": dim})
vs._backend_store.create_index(index_type="ivf", metric="L2", nlist=2)
train = np.random.rand(80, dim).astype(np.float32)
vs._backend_store.index.index.train(train)
vs._backend_store.add_vectors(train[:5], ids=["a", "b", "c", "d", "e"])
coord = ErasureCoordinator(vector_store=vs)
receipt = coord.erase_entity("a", vector_ids=["a"])
assert receipt.stores["vectors"]["status"] == STATUS_UNSUPPORTED
assert not receipt.complete
@@ -0,0 +1,123 @@
"""Tests for MilvusStore.delete_vectors (#1374)."""
from unittest import TestCase
from unittest.mock import MagicMock, patch
from semantica.context.erasure import STATUS_ERASED, ErasureCoordinator
from semantica.utils.exceptions import ProcessingError
from semantica.vector_store import VectorStore
from semantica.vector_store.milvus_store import MilvusStore
class MilvusStoreDeleteVectorsTest(TestCase):
def setUp(self):
self.patches = [
patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
]
for p in self.patches:
p.start()
def tearDown(self):
for p in reversed(self.patches):
p.stop()
def _store(self, result=None, error=None):
"""Return (store, exprs) where delete() records exprs, returns result."""
exprs = []
coll = MagicMock()
def _delete(expr, **kwargs):
exprs.append(expr)
if error is not None:
raise error
if result is None:
return MagicMock(delete_count=0)
return result
coll.collection.delete.side_effect = _delete
store = MilvusStore()
store.collection = coll
return store, exprs
def test_delete_single_id_uses_equality_expr(self):
store, exprs = self._store(result=MagicMock(delete_count=1))
ret = store.delete_vectors(["abc"])
self.assertEqual(exprs, ['id == "abc"'])
self.assertEqual(ret, {"delete_count": 1})
def test_delete_many_ids_uses_in_expr(self):
store, exprs = self._store(result=MagicMock(delete_count=2))
ret = store.delete_vectors(["a", "b"])
self.assertEqual(exprs, ['id in ["a", "b"]'])
self.assertEqual(ret, {"delete_count": 2})
def test_delete_escapes_quote_and_backslash_in_id(self):
store, exprs = self._store()
store.delete_vectors(['he said "hi"', "a\\b"])
self.assertEqual(exprs, ['id in ["he said \\"hi\\"", "a\\\\b"]'])
def test_delete_empty_ids_is_noop(self):
store, _ = self._store()
ret = store.delete_vectors([])
self.assertEqual(ret, {"delete_count": 0})
store.collection.collection.delete.assert_not_called()
def test_delete_without_collection_raises(self):
store = MilvusStore()
with self.assertRaises(ProcessingError):
store.delete_vectors(["a"])
def test_delete_backend_error_raises_processing_error(self):
store, _ = self._store(error=RuntimeError("connection reset"))
with self.assertRaises(ProcessingError):
store.delete_vectors(["a"])
def test_delete_string_delete_count_is_parsed(self):
store, _ = self._store(result=MagicMock(delete_count="3"))
ret = store.delete_vectors(["a", "b", "c"])
self.assertEqual(ret, {"delete_count": 3})
def test_delete_none_delete_count_defaults_zero(self):
store, _ = self._store(result=MagicMock(delete_count=None))
ret = store.delete_vectors(["a"])
self.assertEqual(ret, {"delete_count": 0})
class MilvusErasureIntegrationTest(TestCase):
"""ErasureCoordinator reaches the real MilvusStore.delete_vectors path."""
def _bind_milvus_as_vector_store(self):
vs = VectorStore(backend="milvus", config={"dimension": 3})
milvus = MilvusStore()
coll = MagicMock()
coll.collection.delete.return_value = MagicMock(delete_count=0)
milvus.collection = coll
vs._backend_store = milvus
return vs, coll
def test_erasure_reports_erased_when_delete_runs(self):
vs, coll = self._bind_milvus_as_vector_store()
coord = ErasureCoordinator(vector_store=vs)
receipt = coord.erase_entity("customer-4471")
coll.collection.delete.assert_called()
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
def test_erasure_backend_name_is_milvus(self):
vs, _ = self._bind_milvus_as_vector_store()
coord = ErasureCoordinator(vector_store=vs)
receipt = coord.erase_entity("customer-4471")
self.assertEqual(receipt.stores["vectors"]["backend"], "milvus")
def test_facade_delete_vectors_forwards_to_milvus(self):
"""VectorStore.delete_vectors() delegates to MilvusStore and returns its dict.
ErasureCoordinator probes _backend_store directly, so this test
exercises the public VectorStore facade path that other callers use.
"""
vs, coll = self._bind_milvus_as_vector_store()
coll.collection.delete.return_value = MagicMock(delete_count=2)
ret = vs.delete_vectors(["id-1", "id-2"])
coll.collection.delete.assert_called_once()
self.assertEqual(ret, {"delete_count": 2})
@@ -0,0 +1,377 @@
"""Tests for QdrantCollection.search_points and QdrantStore.get_stats.
These cover the qdrant-client >=1.16.0 compatibility fixes:
1. search_points() must call client.query_points() (not the removed .search()),
read ScoredPoints from response.points, and map them to the documented
Semantica result shape.
2. get_stats() must not access vectors_count unconditionally; when the field
is absent (qdrant-client >=1.16), it falls back to points_count for
single-vector collections, and to None for named/multi-vector collections
where the per-point vector count is unknown.
All tests drive the real implementation against a MagicMock client, following
the established pattern in test_qdrant_store.py.
"""
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from semantica.utils.exceptions import ProcessingError
from semantica.vector_store.qdrant_store import QdrantCollection, QdrantStore
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _scored_point(point_id, score, payload=None):
"""Build a stand-in for a qdrant_client ScoredPoint."""
sp = MagicMock()
sp.id = point_id
sp.score = score
sp.payload = payload
return sp
def _query_response(*scored_points):
"""Build a stand-in for a qdrant_client QueryResponse."""
qr = MagicMock()
qr.points = list(scored_points)
return qr
def _collection_with_query_response(*scored_points):
"""QdrantCollection whose client.query_points() returns the given points."""
client = MagicMock()
client.query_points.return_value = _query_response(*scored_points)
return QdrantCollection(client, "test_collection")
# ---------------------------------------------------------------------------
# QdrantCollection.search_points — API call
# ---------------------------------------------------------------------------
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_calls_query_points_not_search():
"""search_points() must call .query_points(), NOT the removed .search()."""
collection = _collection_with_query_response()
query = np.array([0.1, 0.2, 0.3, 0.4])
collection.search_points(query, limit=5)
collection.client.query_points.assert_called_once()
collection.client.search.assert_not_called()
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_passes_correct_arguments():
"""query_points() must receive collection_name, query list, limit, and payload flag."""
collection = _collection_with_query_response()
query = np.array([0.1, 0.2, 0.3, 0.4])
collection.search_points(query, limit=7)
_, kwargs = collection.client.query_points.call_args
assert kwargs["collection_name"] == "test_collection"
assert kwargs["query"] == [0.1, 0.2, 0.3, 0.4]
assert kwargs["limit"] == 7
assert kwargs["with_payload"] is True
assert kwargs["with_vectors"] is False
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_passes_query_filter_through():
"""The query_filter argument must be forwarded verbatim to query_points()."""
collection = _collection_with_query_response()
mock_filter = MagicMock()
query = np.array([0.5, 0.6])
collection.search_points(query, limit=3, query_filter=mock_filter)
_, kwargs = collection.client.query_points.call_args
assert kwargs["query_filter"] is mock_filter
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_passes_none_filter_when_unfiltered():
"""query_filter=None must be passed through (not omitted) so the server
returns all matching vectors rather than raising a missing-argument error."""
collection = _collection_with_query_response()
query = np.array([0.1, 0.2])
collection.search_points(query, limit=5, query_filter=None)
_, kwargs = collection.client.query_points.call_args
assert kwargs["query_filter"] is None
# ---------------------------------------------------------------------------
# QdrantCollection.search_points — result shape
# ---------------------------------------------------------------------------
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_result_shape():
"""Each result dict must contain id, score, metadata, vector, distance."""
sp = _scored_point(42, 0.8, payload={"tag": "ml"})
collection = _collection_with_query_response(sp)
query = np.array([0.1, 0.2, 0.3])
results = collection.search_points(query, limit=1)
assert len(results) == 1
r = results[0]
assert set(r.keys()) == {"id", "score", "metadata", "vector", "distance"}
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_maps_id_and_payload():
"""id and metadata must come from ScoredPoint.id and ScoredPoint.payload."""
sp = _scored_point(99, 0.5, payload={"source": "wiki", "year": 2024})
collection = _collection_with_query_response(sp)
results = collection.search_points(np.array([0.1, 0.2]), limit=1)
assert results[0]["id"] == 99
assert results[0]["metadata"] == {"source": "wiki", "year": 2024}
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_null_payload_becomes_empty_dict():
"""A ScoredPoint with payload=None must produce metadata={}."""
sp = _scored_point(7, 0.9, payload=None)
collection = _collection_with_query_response(sp)
results = collection.search_points(np.array([0.1, 0.2]), limit=1)
assert results[0]["metadata"] == {}
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_vector_and_distance_are_none():
"""vector and distance fields must always be None (vectors are not fetched)."""
sp = _scored_point(1, 0.7, payload={})
collection = _collection_with_query_response(sp)
results = collection.search_points(np.array([0.1, 0.2]), limit=1)
assert results[0]["vector"] is None
assert results[0]["distance"] is None
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_score_normalization_midrange():
"""Score=0 must map to exactly 0.5 under the normalization formula."""
sp = _scored_point(1, 0.0)
collection = _collection_with_query_response(sp)
results = collection.search_points(np.array([0.1, 0.2]), limit=1)
assert results[0]["score"] == pytest.approx(0.5)
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_score_normalization_positive():
"""Positive raw scores must map to (0.5, 1.0) under the normalization formula."""
sp = _scored_point(1, 1.0)
collection = _collection_with_query_response(sp)
results = collection.search_points(np.array([0.1, 0.2]), limit=1)
# (1.0/(1+1.0) + 1.0) / 2.0 = (0.5 + 1.0) / 2.0 = 0.75
assert results[0]["score"] == pytest.approx(0.75)
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_score_normalization_negative():
"""Negative raw scores must map to (0.0, 0.5) under the normalization formula."""
sp = _scored_point(1, -1.0)
collection = _collection_with_query_response(sp)
results = collection.search_points(np.array([0.1, 0.2]), limit=1)
# (-1.0/(1+1.0) + 1.0) / 2.0 = (0.5 + 1.0) / 2.0 = 0.25
assert results[0]["score"] == pytest.approx(0.25)
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_multiple_results_preserve_order():
"""All ScoredPoints in response.points must appear in the output, in order."""
points = [_scored_point(i, 1.0 - i * 0.1) for i in range(5)]
collection = _collection_with_query_response(*points)
results = collection.search_points(np.array([0.1, 0.2]), limit=5)
assert len(results) == 5
assert [r["id"] for r in results] == [0, 1, 2, 3, 4]
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_empty_response():
"""An empty response.points list must produce an empty result list."""
collection = _collection_with_query_response() # zero points
results = collection.search_points(np.array([0.1, 0.2]), limit=10)
assert results == []
# ---------------------------------------------------------------------------
# QdrantCollection.search_points — error handling
# ---------------------------------------------------------------------------
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", False)
def test_search_points_raises_when_qdrant_unavailable():
client = MagicMock()
collection = QdrantCollection(client, "test_collection")
with pytest.raises(ProcessingError):
collection.search_points(np.array([0.1, 0.2]), limit=5)
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_search_points_wraps_client_errors_as_processing_error():
client = MagicMock()
client.query_points.side_effect = RuntimeError("network failure")
collection = QdrantCollection(client, "test_collection")
with pytest.raises(ProcessingError, match="network failure"):
collection.search_points(np.array([0.1, 0.2]), limit=5)
# ---------------------------------------------------------------------------
# QdrantStore.get_stats — vectors_count compatibility
# ---------------------------------------------------------------------------
def _store_with_collection_info(**info_attrs):
"""QdrantStore with a mocked client.get_collection() response."""
store = QdrantStore()
store.client = MagicMock()
store.collection = MagicMock()
store.collection.collection_name = "test_coll"
info = MagicMock(spec=list(info_attrs.keys()))
for attr, val in info_attrs.items():
setattr(info, attr, val)
store.client.get_collection.return_value = info
return store
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_get_stats_uses_vectors_count_when_present():
"""On qdrant-client <1.16, vectors_count exists and must be returned."""
store = _store_with_collection_info(
points_count=10, vectors_count=10, status="green"
)
stats = store.get_stats()
assert stats["points_count"] == 10
assert stats["vectors_count"] == 10
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_get_stats_uses_points_count_when_vectors_count_absent():
"""On qdrant-client >=1.16, vectors_count is absent.
For a single unnamed-vector collection (config.params.vectors is a
VectorParams instance), points_count is the correct substitute.
indexed_vectors_count must NOT be used: it counts only vectors
in optimised segments and is 0 for freshly-inserted data."""
from qdrant_client.models import VectorParams, Distance
store = QdrantStore()
store.client = MagicMock()
store.collection = MagicMock()
store.collection.collection_name = "test_coll"
info = MagicMock(spec=["points_count", "indexed_vectors_count", "config", "status"])
info.points_count = 5
info.indexed_vectors_count = 0 # typical for freshly-inserted, unoptimised data
info.config.params.vectors = VectorParams(size=4, distance=Distance.COSINE)
info.status = "green"
store.client.get_collection.return_value = info
stats = store.get_stats()
assert stats["points_count"] == 5
# Must equal points_count (5), NOT indexed_vectors_count (0)
assert stats["vectors_count"] == 5
assert stats["vectors_count"] != info.indexed_vectors_count
assert stats["status"] == "green"
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_get_stats_vectors_count_equals_points_count_when_vectors_count_absent():
"""On qdrant-client >=1.16, vectors_count is absent. For a single unnamed-
vector collection the fallback is points_count, so both keys are equal.
indexed_vectors_count is intentionally absent from this mock to confirm
it is not required by the fallback path."""
from qdrant_client.models import VectorParams, Distance
store = QdrantStore()
store.client = MagicMock()
store.collection = MagicMock()
store.collection.collection_name = "test_coll"
info = MagicMock(spec=["points_count", "config", "status"])
info.points_count = 7
info.config.params.vectors = VectorParams(size=8, distance=Distance.COSINE)
info.status = "green"
store.client.get_collection.return_value = info
stats = store.get_stats()
assert stats["points_count"] == 7
assert stats["vectors_count"] == 7
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_get_stats_vectors_count_is_none_for_named_multi_vector_collection():
"""When vectors_count is absent and the collection uses named/multi vectors
(config.params.vectors is a dict), the total cannot be inferred and
vectors_count must be None rather than a misleading points_count value."""
from qdrant_client.models import VectorParams, Distance
store = QdrantStore()
store.client = MagicMock()
store.collection = MagicMock()
store.collection.collection_name = "test_coll"
info = MagicMock(spec=["points_count", "config", "status"])
info.points_count = 4
# Named multi-vector: qdrant-client returns a dict of VectorParams
info.config.params.vectors = {
"text": VectorParams(size=4, distance=Distance.COSINE),
"image": VectorParams(size=8, distance=Distance.DOT),
}
info.status = "green"
store.client.get_collection.return_value = info
stats = store.get_stats()
assert stats["points_count"] == 4
# vectors_count must be None: total vectors = points * num_named_vectors,
# and that multiplier is unknown to the caller.
assert stats["vectors_count"] is None
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_get_stats_vectors_count_is_none_when_config_inaccessible():
"""If the collection config cannot be read (e.g. an older schema or
unexpected server response), vectors_count must fall back to None safely
without raising."""
store = QdrantStore()
store.client = MagicMock()
store.collection = MagicMock()
store.collection.collection_name = "test_coll"
# Simulate a CollectionInfo that has no config attribute at all
info = MagicMock(spec=["points_count", "status"])
info.points_count = 3
info.status = "green"
store.client.get_collection.return_value = info
stats = store.get_stats()
assert stats["points_count"] == 3
assert stats["vectors_count"] is None