Compare commits

..
Author SHA1 Message Date
Zohaib Hassnain fd95639bdd ci: refresh github/codeql-action pin to current v4
The pin was 5595ccaf..., but upstream has since moved the v4 tag to
ff2f1c62.... The Verify Action Pins workflow flags this drift on every
PR that touches any workflow file, regardless of whether that PR
changed codeql.yml or defender-for-devops.yml.

Verified the new SHA against the GitHub API directly (not just the CI
error text) and confirmed .github/scripts/verify-action-pins.sh passes
clean locally (40/40 action references OK, exit 0).
2026-08-14 22:15:50 +05:00
127 changed files with 909 additions and 13014 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
> **Before you submit:** make sure you followed the [issue workflow in CONTRIBUTING.md](https://github.com/semantica-agi/semantica/blob/main/CONTRIBUTING.md#-working-on-an-existing-issue) — wait for the issue to be assigned to you before opening a PR, to avoid duplicate work.
> **Before you submit:** make sure you followed the [issue workflow in CONTRIBUTING.md](https://github.com/semantica-agi/semantica/blob/main/CONTRIBUTING.md#-working-on-an-existing-issue) — comment on the issue and wait for assignment before opening a PR, to avoid duplicate work.
## Description
+1 -189
View File
@@ -9,42 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.6] - 2026-08-20
### Added
- **Semantica RDF vocabulary, and deterministic entity/relationship IRIs** (#1109, closes #1107, closes #1101) by @fabio-rovai, reviewed by @KaifAhmad1
- Every RDF/JSON-LD export mints terms in `https://semantica.dev/ns#`, and until now nothing declared what those terms meant — the namespace 404s and no vocabulary shipped with the package, so a consumer receiving an export had no way to tell `sem:text` from a typo of it, and no closed-world checker could validate an export at all
- `semantica/ontology/vocabulary/semantica-ns.ttl` declares the terms the exporters actually emit — drawn from the emitting call sites in `export/rdf_exporter.py`, `export/json_exporter.py` and `provenance/manager.py`, not from what a vocabulary "ought" to contain. Ships inside the package (`from semantica.ontology.vocabulary import vocabulary_turtle`) so it loads without a network round trip, and is the same document intended to be served at the namespace IRI once hosting/content-negotiation is sorted
- `tests/ontology/test_vocabulary.py` ties the document to the code: every term a serializer can write must be declared, so adding a term to an exporter without declaring it fails the build
- The missing-id fallback minted entity/relationship IRIs from Python's builtin `hash()`, randomised per process (`PYTHONHASHSEED`), so the same entity got a different IRI on every run and exports couldn't be diffed, deduplicated, or joined to an earlier provenance record. It also wrote `<semantica:entity_N>`, an IRI in the scheme `semantica` rather than the expansion of the declared prefix, so those nodes never joined with anything written through it. Minting now uses SHA-256 and writes a full IRI in the declared namespace; the same fix applies to the default entity/relationship types in the Turtle path
- **Fixed during review** (Qodo): the temporal fallback minted from `source_id` only, while the main serializer accepts `source_id` or `source` — relationships using the second form hashed two empty strings, which the previous randomised `hash()` masked by making the IRI unstable anyway; once deterministic, unrelated relationships at the same list index collided on one IRI across exports. Endpoints are now resolved the same way `serialize_to_turtle` resolves them, before minting. `sem:confidence` also lost its declared `xsd:decimal` range: the N-Triples serializer types the same value `xsd:float`, and the two are disjoint, so declaring either contradicted one of the exporters (tracked in #1100) — a new `test_declared_ranges_do_not_contradict_what_the_exporters_emit` guards the whole class of that mistake
- **Fixed in follow-up**: `serialize_to_rdfxml`'s default entity type still wrote the bare string `"semantica:Entity"` into an `rdf:resource` attribute, which (unlike a Turtle angle-bracket or an XML element name) is not namespace-expanded — the exact #1101 failure mode, just on the untested RDF/XML path. `json_exporter.py`'s `semantica:format` and `@type: "semantica:KnowledgeGraph"` were emitted but absent from both the vocabulary and the test's `EMITTED_TERMS` guard set, so the "undeclared terms fail the build" claim didn't actually cover them — both are now declared and guarded. `MANIFEST.in` didn't mirror the `pyproject.toml` package-data addition, so a source-distribution install could omit the vocabulary file. The cross-process minting-stability test replaced the subprocess's entire environment with a POSIX-only `PATH`, breaking it on Windows; now overrides only `PYTHONHASHSEED` on top of the inherited environment
- **Also fixed, on the JSON-LD paths**: the first fix covered the Turtle, N-Triples and RDF/XML serializers, and left both JSON-LD writers interpolating the entity's own text into `f"semantica:entity/{text}"` and the endpoints into `f"semantica:rel/{source}_{target}"`. Three consequences, all live in 0.6.5: an entity whose text contained a space produced an invalid IRI, and a JSON-LD parser dropped that node in full rather than reporting it, so the entity disappeared from the export; every relationship carrying `source`/`target` rather than `source_id`/`target_id` minted the identical `semantica:rel/_`, collapsing all of them onto one node whose types and endpoints merged; and the JSON-LD `@id` disagreed with the Turtle IRI for the same entity, so the two serializations of one knowledge graph were two different graphs. Both JSON-LD writers now use `mint_entity_iri`/`mint_relationship_iri`, and `JSONExporter.export_entities`/`export_relationships` declare the `semantica` prefix their `@context` was already writing `semantica:entities` against — without it a processor reads that as an IRI in the scheme `semantica`, which is the original #1101 defect on a third path
- `tests/export/test_jsonld_iri_minting.py` parses each export with a real JSON-LD processor and asserts the entity survives, the relationships stay distinct, no term expands into the `semantica` scheme, and the JSON-LD `@id` equals the Turtle IRI
- 236 export and ontology tests pass
- **First-class CrewAI integration** (#988, closes #962) by @Shindevrp
- New `pip install semantica[crewai]` extra (`crewai>=0.80.0`) — crewai core provides `BaseTool`/`BaseKnowledgeSource`, so `crewai-tools` is intentionally not included, and the extra is intentionally **not** part of the `all` bundle: crewai hard-requires `chromadb~=1.1.0`, which is affected by the unpatched pre-auth code-injection CVE-2026-45829 (see `integrations/crewai/README.md`)
- `integrations/crewai/SemanticaKGTool` — a CrewAI `BaseTool` exposing 5 KG actions (`extract_entities`, `extract_relations`, `add_to_graph`, `query_graph`, `find_related`) backed by `NERExtractor` / `RelationExtractor` / `ContextGraph`; supports both sync `run()` and async `arun()`
- `integrations/crewai/SemanticaDecisionTool` — a CrewAI `BaseTool` wrapping `AgentContext` with 5 decision-intelligence actions (`record_decision`, `find_precedents`, `trace_causal_chain`, `analyze_impact`, `check_policy`)
- `integrations/crewai/SemanticaKnowledgeSource` — a CrewAI `BaseKnowledgeSource` that serializes a `ContextGraph` into crew knowledge storage; implements both the legacy `load_content()` and current `validate_content()`/`aadd()` contracts so it works across `crewai>=0.80.0`
- All three classes degrade gracefully when `crewai` is not installed (still importable, full Semantica API available)
- New `tests/integrations/crewai/`: 70 tests covering stub-based present-case behavior (Pydantic/BaseTool subclassing, every action, knowledge-source chunking/storage) plus a subprocess isolation test for the crewai-absent degradation path
- Docs: `docs/integrations/crewai.md` page, `docs.json` Integrations nav entry, and README integration-matrix/install updates
- **Hardened during code review**: live `graph`/`context`/extractor state is excluded from CrewAI JSON serialization (`model_dump(mode="json")`) with `model_post_init` self-healing defaults, so checkpoint/resume no longer raises `PydanticSerializationError`; `query_graph` now searches node content (not just ids/types); `trace_causal_chain` returns an explicit error instead of substituting similarity precedents when causal tracing is unavailable, and calls `trace_decision_causality(..., max_depth=...)` with the correct argument name; `find_precedents` propagates `max_precedents` as the backend `limit`; `add_to_graph` writes are serialized under a module lock so concurrent agents can't double-count duplicate adds; nameless entities are skipped instead of creating `repr()`-junk nodes
- **Hardened during second code review**: `check_policy` rules are now coerced type-aware — `bool("false")` was truthy, so `enabled == false` reported a violation for `enabled: false`, and string datums like `"0.90"` were compared lexicographically instead of numerically; `trace_causal_chain` no longer raises `AttributeError` (which escaped the tool) when the decision context has no `knowledge_graph`, returning honest error JSON instead; knowledge-source storage failures log an actionable ERROR (a missing crew embedder otherwise silently left agents with empty retrieval); `add_to_graph` uses a per-graph re-entrant lock instead of a process-global one (independent graphs no longer serialize each other, and re-entrant extractors can't deadlock); entity/relation `confidence=None` normalizes to `1.0` instead of failing the whole extraction; added a subprocess integration test against the real `crewai` package covering `Crew`-level serialization round-trip and restore
- **`ContextGraph` gains retraction and purge — the graph previously had no way to remove a node or edge without discarding everything via `clear()`** (#957, closes #955) by @pravit-amp, reviewed by @KaifAhmad1
- `retract_node()`/`retract_edge()` close an entity's validity window rather than deleting it, reusing the existing `valid_from`/`valid_until`/`state_at()` machinery: the entity drops out of `find_active_nodes()` and future `state_at()` queries going forward, but `state_at()` calls before the retraction time still return it, so decisions recorded against it stay explainable. A `("kind", id)`-keyed retraction record captures who/why/when, retrievable via `get_retraction()`/`list_retractions()`
- `purge_node()`/`purge_edge()` are the destructive counterpart: the entity is removed outright, from history as well as the active view, for erasure obligations retraction alone cannot satisfy (e.g. GDPR Article 17). Only a tombstone remains — that a purge happened, when, and why — deliberately never the purged content, via `get_tombstone()`/`list_tombstones()`. Purge is graph-scope only: `AgentMemory` and any bound vector store are not reached, so it is one step of an erasure workflow rather than the whole of it
- Both operations default to `cascade=True` (also touching every incident edge, and for `purge_node`, the marker node of any cross-graph link the node exits through) since leaving edges active around an inactive/removed node produces an inconsistent active view or dangling endpoints; both accept `cascade=False` for callers that want to handle edges themselves
- Both are idempotent: retracting/purging an already-retracted/purged entity returns `False` rather than raising, and a repeat retraction preserves the original record's reason rather than overwriting it
- Retraction/purge closing a validity window never widens an existing one — a node or edge added with `valid_until` already in the past keeps that earlier bound rather than being pushed later by a subsequent retraction time
- Reuses the existing audit-trail path with no changes to `change_management`: `MutationRecord` already documented `REMOVE_NODE`/`REMOVE_EDGE` in its operation vocabulary; retraction now emits `UPDATE_NODE`/`UPDATE_EDGE`, purge emits `REMOVE_NODE`/`REMOVE_EDGE`, matching the documented contract. Mutation payloads are snapshotted inside the lock and the callback fires after it is released, so a callback that itself mutates the graph (e.g. `clear()`) can't observe or lose in-flight records
- **Fixed during review** (@KaifAhmad1): `retract_edge()`/`purge_edge()` resolved "the edge" for a given `edge_id` via the first matching object only. `edge_id` is content-derived and, prior to #926, was not guaranteed unique — a graph holding two identical `add_edge()` calls had two edge objects sharing one id. A direct `retract_edge()`/`purge_edge()` call would silently leave the second duplicate untouched (still live, still active) while returning `True` and recording a tombstone/retraction that claimed the edge was fully handled; repeat `purge_edge()` calls also silently overwrote the tombstone's `reason`/`purged_at` on each partial attempt instead of no-op'ing. The same gap let `retract_node()`'s cascade skip a duplicate outright, since it checked the live `_retractions` dict mid-loop and treated the first duplicate's just-written record as proof the second was already handled. `#926` (merged) stops *new* duplicates from being created, but any graph already holding one — loaded from a save made before that fix, or built during the window before it landed — could still trigger this. Now `retract_edge()`/`purge_edge()` act on every edge matching the id under one record, and the cascade's dedup check is snapshotted before the loop starts so within-call duplicates are still closed rather than skipped. 5 new regression tests in `TestDuplicateEdgeId`
- New `tests/context/test_context_graph_retraction.py`: 49 tests, covering retraction/purge semantics, cascade, idempotency, validity-window narrowing, id-keyspace collisions between node and edge ids, cross-graph link teardown, `clear()`/`load_from_file()` resetting retraction/tombstone state, audit-trail integration against a real `TemporalVersionManager`, mutation-emission ordering under a concurrent `clear()`, and concurrent purges
- Full `tests/context/` suite: 533 passed
- **`DistanceExporter.compute_pairs()` gains an opt-in `metric_errors` column to distinguish legitimate `None` results from computation failures** (#960, follow-up to #879) by @Karunasagar12
- Previously, a `None` in `hop_count`/`weighted_distance`/`semantic_similarity`/betweenness could mean either "no path exists" or "the underlying computation raised" — logged as a warning per #879, but not otherwise surfaced, so the two cases were indistinguishable in exported CSV/JSONL/DataFrame data. `include=["metric_errors"]` now adds a `metric_errors` field per row: `""` when all requested metrics succeeded, or a comma-separated list of metric names that raised (e.g. `"hop_count,weighted_distance"`)
- Opt-in only — default `compute_pairs()`/`to_csv()`/`to_dataframe()`/`to_jsonl()` schema is unchanged unless `"metric_errors"` is explicitly requested
@@ -54,11 +20,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New `tests/export/test_distance_exporter_metric_errors.py`: 6 tests covering success, single/multiple failures, opt-out, the no-path-vs-error distinction, and default-schema stability; existing `tests/export/test_distance_exporter.py` updated for the new tuple return type
- Full `tests/export/` suite: 77 passed
- **`ContextGraph.to_kg_dict()`: an adapter converting a `ContextGraph`'s internal `nodes`/`edges`/`source` shape into the canonical `entities`/`relationships`/`source_id` shape `RDFExporter` and `TemporalGraphQuery` consume** (#1081) by @cxzg007
- Previously there was no supported way to feed a `ContextGraph` into those consumers without hand-rolling the field remapping; `to_kg_dict()` does it once, with an `entities_only` option that drops relationships left dangling by the filter
- **Fixed during review** (Qodo): null `properties`/`metadata` on a node loaded from JSON raised `TypeError` when copied — both are now guarded with `or {}`; entity ids are coerced to `str(node_id)` to match `ContextEdge`'s already-str-coerced endpoints, so valid relationships were no longer dropped by `entities_only` filtering
- `RDFExporter`'s validator and `TemporalGraphQuery` now also accept `source_id`/`target_id` endpoints, the shape `to_kg_dict()` emits
### Changed
- **`GraphBuilder`'s 6 public methods now have Google-style docstrings** (#878, closes #876) by @cakeni
@@ -68,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Corrected during review**: `add_temporal_edge`/`create_temporal_snapshot` docstrings overclaimed numeric-timestamp support; `_parse_time()` only special-cases `str` and `datetime`, falling back to a bare `str()` cast for anything else (not true numeric parsing). Narrowed to "datetime or ISO-formatted string"
- **Fixed along the way**: `build()`'s `**options` documented a default only for `extract`; `extract_relations`, `extract_triplets`, `ner_method`, `relation_method`, and `triplet_method` all have concrete defaults in `_extract_from_text()` (`True`, `True`, `"llm"`, `"llm"`, `"llm"`) that were left unstated, inconsistent with CONTRIBUTING.md's own docstring example of noting defaults inline
- `python -m pytest tests/kg/test_kg.py tests/kg/test_graph_builder_external.py -q`: 45 passed
- **`GraphBuilder` raw-text extraction now defaults to local extractors instead of LLM extraction** (#941, closes #930) by @dex0shubham
- **`GraphBuilder` raw-text extraction now defaults to local extractors instead of LLM extraction** (closes #930) by @dex0shubham
- `GraphBuilder._extract_from_text()` defaulted `ner_method`, `relation_method`, and `triplet_method` to `"llm"`, and ran relation extraction unconditionally (`extract_relations` defaulted to `True`) — all four contradicting the defaults documented in the `build()` docstring at the time (`"ml"` / `"pattern"` / `False`), and diverging from the standalone extractors (`NERExtractor` defaults to `method="ml"`, `RelationExtractor` and `TripletExtractor` to `method="pattern"`). The practical effect was that any raw-text `build()` call silently required a configured provider, an API key, and network access
- Defaults are now `ner_method="ml"`, `relation_method="pattern"`, `triplet_method="pattern"`, and `extract_relations=False`, matching the docstring. LLM extraction remains fully available and is now opt-in
- **To restore the previous behaviour**, pass the methods explicitly:
@@ -89,75 +50,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New regression coverage in `tests/kg/test_graph_builder_extraction_defaults.py` pinning all four defaults, verifying that no default resolves to `"llm"`, confirming explicit LLM opt-in still routes correctly, asserting extractors are constructed once across repeated texts, covering fallback method lists (e.g. `ner_method=["pattern", "ml"]`) for all three extractors, asserting relations are forwarded to triplet extraction (and that `None` is forwarded when relation extraction is disabled or fails), and running the real default path end to end with no provider mocked. Verified to fail against the pre-fix code
- Full `kg` suite: 473 passed
- **Explorer graph canvas now renders edge labels** (#1013, closes #1009) by @yzxcj797
- `GraphCanvas.tsx` had no edge-label rendering path at all; Sigma's edge-label renderer draws `data.label`, but the graph state stored the relationship type under `edgeType`, so simply enabling the renderer would have left every edge blank. `graphSceneState`'s edge reducer now maps `edgeType` onto `label` (suppressed for hidden edges)
- Rendering is gated behind a new `edgeLabelsEnabled` entry in the Effects panel (default on), wired through the existing `GraphEffectToggle`/`GraphEffectsState` plumbing, so dense graphs can still turn labels off
- **Fixed during review** (Qodo): two follow-up passes closed gaps the first cut left — label rendering wasn't wired through `explorationEffectsPluginPhaseC.tsx`'s Phase C variant, and toggling the effect off mid-session didn't clear already-rendered labels
- New coverage in `explorer/tests/graphSceneState.display.test.ts`
- **Removed `GraphWorkspaceShell.tsx`, `GraphRuntimeStage.tsx`, and `useGraphData.ts` — a second, unused implementation of the graph-loading/error-handling logic already fixed in `GraphWorkspace.tsx`** (#984, resolves the cleanup tracked in #981 by #980's review note) by @lakshayxi
- 1,564 lines removed; the surviving `GraphWorkspace` path is now the only implementation, so the "two copies that drifted apart" root cause #980 fixed can't recur in the copy nobody was maintaining
- **Explorer README and `docs/explorer-setup.md` corrected to describe the authentication 0.6.5 actually shipped**, plus a documented `/ws/graph-updates` auth note (#1040, fixes #1028) by @Kyou12138
- Both docs still claimed the Explorer API had no built-in authentication after v0.6.5 added mandatory `SEMANTICA_API_KEY` enforcement with a `503` fail-closed default; corrected to describe the actual behavior, including that only protected routes require the key (`/api/health`/`/api/info` stay open), the non-loopback-bind CLI warning only fires in anonymous mode or when the key is unset, and `SEMANTICA_API_KEY`/`SEMANTICA_ALLOW_ANONYMOUS` are documented in the environment-variable table
- **CI: pinned `github/codeql-action` to current v4** (#986) by @ZohaibHassan16, and **pinned Python dependencies in `requirements-ci.txt` for reproducible CI runs** (#945) by @yunaremaia, closing the gap where an unpinned CI dependency could silently change behavior between runs
- **README now states up front that Semantica's explainability is system-level, not foundation-model-internal** (#1033, #1034) by @KaifAhmad1
- Nothing in the README previously scoped what "explainable" meant, leaving readers to assume Semantica could expose or reconstruct an LLM's internal reasoning. A callout now states explicitly that Semantica explains and audits what the AI *system* did — context fed in, decisions produced, provenance, relationships, policies applied — not the model's private internal reasoning, and moved the note near the top of the README rather than leaving it implicit
### Fixed
- **Every timestamp an export or a provenance record wrote was timezone-naive** (closes #1114) by @fabio-rovai
- `semantica/export/` stamped with `datetime.now().isoformat()`, which reads the machine's **local** clock; `semantica/provenance/` stamped with `datetime.utcnow().isoformat()`, which reads **UTC**. Both produce a naive value and both serialize identically, so nothing downstream can tell which zone a given timestamp belongs to — the same string means two different instants depending on which module wrote it
- In RDF the consequence is silent rather than loud. Under XSD 1.1 a value with no timezone compared against one with a timezone is indeterminate whenever the two fall inside the ±14 hour window; SPARQL turns an indeterminate comparison into an error, and `FILTER` discards errors as non-matches. A timezone-qualified query over an Oxigraph store returns an answer with every Semantica-written record quietly absent from it, which is a poor property for `prov:generatedAtTime`, `prov:startedAtTime`, `prov:endedAtTime` and `prov:atTime` to have
- New `utc_now()`/`utc_now_iso()` in `semantica/utils/helpers.py`, exported from `semantica.utils`, and used at all 29 call sites in `export/` (`json_exporter`, `yaml_exporter`, `report_generator`, `export_provenance`) and `provenance/` (`manager`, `schemas`, `bridge_axiom`). Values now read `2026-08-19T14:19:04.229937+00:00`: one unambiguous instant, comparable against any correctly stamped value, and valid `xsd:dateTimeStamp`. `sem:exportedAt`'s range in `semantica/ontology/vocabulary/semantica-ns.ttl` is tightened from `xsd:dateTime` accordingly, and its comment no longer has to explain why the weaker range was necessary
- `datetime.utcnow()` is deprecated as of Python 3.12 and scheduled for removal; constructing a `ProvenanceEntry` under `-W error::DeprecationWarning` on 3.13 raised, and no longer does
- New `tests/export/test_timestamp_timezones.py` and `tests/provenance/test_timestamp_timezones.py`: offset presence on every export and provenance path, PROV-O literals valid as `xsd:dateTimeStamp`, comparison against a timezone-aware instant without `TypeError`, the Oxigraph filter that dropped the naive value (with a bound inside the indeterminate window, so the test cannot pass by accident), and the document `@id` remaining a valid IRI with `+00:00` in it. 11 of the 13 fail on the parent commit
- **Fixed during review** (Qodo): once new entries carry `+00:00` and stored ones do not, `ProvenanceManager.query_recorded_between` and `audit_log` compared ISO timestamps as raw strings, so they ordered by spelling rather than by instant — an inclusive naive bound naming a stored offset-bearing timestamp sorted *below* it and dropped the record, and a bound written in another offset landed wherever its digits fell (`19:45+05:30` is 14:15Z, but sorted after 14:19Z). Both now compare instants through a new `to_utc_datetime()` helper that reads a missing offset as UTC, which is what the values written before this change actually were; a bound that cannot be read as a timestamp keeps the historical string comparison rather than raising on a call that used to work
- The remaining 147 naive call sites are in `context/`, `vector_store/`, `seed/` and elsewhere, where timestamps are compared against values parsed from previously stored naive strings. Converting those without a read-side migration would raise `TypeError: can't compare offset-naive and offset-aware datetimes` on existing data, so they are deliberately left for a separate change
- **`split`/chunking paths bypassed the centralized spaCy model cache, reloading the model on every call** (#1042, closes #998) by @Accute9, reviewed by @Sameer6305
- `semantica/split/methods.py`'s `split_by_sentences()` and `semantica/split/semantic_chunker.py`'s `SemanticChunker.__init__` each called `spacy.load()` directly instead of reusing the process-level cache added in #889/`semantic_extract/methods.py`'s `load_spacy_model()` — every call/construction re-paid the ~120ms model-load cost independently of `NERExtractor`, which already used the cache
- Both now route through `load_spacy_model()`, sharing one cached `Language` instance per model name across `split_by_sentences()`, `SemanticChunker`, and `NERExtractor`; a missing model still falls back to regex/paragraph chunking without poisoning the cache for a later successful load
- **Fixed during review** (@Sameer6305): `NERExtractor.__init__()` still had a direct `spacy.load()` call site with the same cache-bypass issue, outside the two files named in #998 but sharing the same root cause; routed through the cache alongside stale test patch targets and a strengthened cache-configuration assertion
- **Fixed during review** (@KaifAhmad1): `SemanticChunker.__init__` only caught `OSError` around `load_spacy_model()`, while the sibling fix to `NERExtractor` in this same PR added a broader `except Exception` for a model that is installed but fails at runtime (e.g. a config incompatible with the installed spaCy version). A broken-but-present model crashed `SemanticChunker()` outright instead of degrading to fallback chunking like every other path in this PR. Added the matching `except Exception` branch, leaving `self.nlp` as `None`; new `test_semantic_chunker_falls_back_when_spacy_runtime_is_broken` mirrors the existing `NERExtractor` regression test for the same scenario
- New `tests/split/test_spacy_model_cache.py`: cache reuse across repeated calls/instances, shared cache between `split_by_sentences()`/`SemanticChunker`/`NERExtractor`, distinct model names loading separately, missing-model fallback without poisoning the cache, and the broken-runtime fallback added above
- `pytest tests/split/test_spacy_model_cache.py tests/split/test_splitter.py tests/split/test_chunkers.py`: all passing (3 pre-existing, unrelated `tests/test_ner_configurations.py` failures confirmed present on `main` before this PR)
- **`export_yaml` raised a raw `AttributeError` on list input, silently wrote empty exports for unrecognized dict keys, and graph payloads were reconciled differently by every exporter** (#958, closes #956, #952, #953) by @pravit-amp, reviewed by @Sameer6305
- Graph payloads circulate under two vocabularies, `entities`/`relationships` and `nodes`/`edges`, and each exporter reconciled them locally with a different idiom — `LPGExporter` in particular dropped every entity whenever `nodes` was present but empty, the exact shape `JSONExporter` emits. A new `normalize_graph_payload()` in `utils/helpers.py` centralizes that decision once, adopted by `LPGExporter`, `ArangoAQLExporter`, `Neo4jCSVExporter`, and both YAML exporters; `ContextGraph.to_dict()` now round-trips through YAML correctly as a result
- `export_yaml(records, path)` on a bare list previously failed with `AttributeError` from inside the exporter; it and the other YAML methods now reject non-mapping input with an actionable `ProcessingError` naming the expected keys, since these formats distinguish entities/relationships/triplets and guessing which one a list represents would mislabel the records
- `export_yaml({"data": [...]}, path)` previously wrote a structurally valid file with every collection empty, no exception, no warning, and the progress log reporting a completed export. `export_semantic_network`, `export_for_pipeline`, and `export_ontology_schema` now raise `ValidationError` when the payload shares no recognized key with what the method reads, or resolves to nothing while an unread key still holds records — an empty mapping is still accepted, since a genuinely empty graph has no records to lose
- **Breaking**: the two cases above, plus a bare list, now raise instead of returning cleanly with data silently dropped or a raw `AttributeError` from exporter internals. Migration: pass records under a recognized key (`{"entities": [...]}` / `{"nodes": [...]}` for `semantic_network`, `{"classes": [...]}` for `schema`)
- **Fixed during review** (Qodo): progress tracking could report a completed export before the output directory existed or the file was written; `export()` now creates the directory and serializes before starting tracking, so a rejected export leaves nothing behind
- **Fixed during review** (@Sameer6305, round 1): `normalize_graph_payload()`'s collection resolver treated any truthy value as a collection — `{"entities": "abc"}` silently became three single-character records, `{"entities": 42}` leaked a raw `TypeError` from inside `list()`. Collection values are now validated before conversion, rejecting strings/bytes/mappings/non-iterable scalars by name. Separately, `Neo4jCSVExporter._normalize_graph` called the shared resolver with `require_recognized=False`, so it alone kept accepting an unrecognized mapping as a silent empty export; the opt-out (introduced earlier in this same PR, with no other caller) was removed
- **Fixed during review** (@Sameer6305, round 2): `YAMLSchemaExporter`'s usable-schema check could treat scalar schema metadata (`version`, `uri`, `title`, `description`) as evidence records had been exported, letting records under an unread key drop silently; and `_is_record()` accepted modules and class/type objects through the generic `__dict__` path, which would have reached exporter internals instead of failing at the boundary. Both closed, with regression coverage
- **Fixed during final maintainer review** (before merge): four more gaps in the shared boundary that the earlier rounds didn't reach
- `LPGExporter`/`ArangoAQLExporter` called `normalize_graph_payload()` with no type guard, so non-mapping input raised `ValidationError` from inside the resolver — while YAML and `Neo4jCSVExporter` raised `ProcessingError` for the identical mistake, per this PR's own stated contract. The `_require_mapping()` guard that already existed in `yaml_exporter.py` is now shared from `utils/helpers.py` and used by all three
- `Neo4jCSVExporter._normalize_graph` checked `isinstance(graph, dict)`, so a non-dict `Mapping` (`MappingProxyType`, `ChainMap`) fell through to the object-attribute branch and was rejected, even though the identical payload exported fine via `LPGExporter`/`ArangoAQLExporter`/YAML. Now checks `isinstance(graph, Mapping)`
- `normalize_graph_payload()` accepts dataclass and attribute-bearing object records (`Neo4jCSVExporter._record_to_dict` reads them), but `LPGExporter`/`ArangoAQLExporter` call `.get(...)` directly on resolved entities — an object-shaped record passed validation only to crash with a raw `AttributeError` once used, the exact failure this PR's boundary exists to prevent. Records are now converted to plain dicts at the boundary (`_coerce_records` → new `_record_to_dict`), so every consumer gets a uniform shape regardless of which reading the caller used
- Two non-empty spellings of the same collection (e.g. `entities` and `nodes`) holding identical records in a different order were rejected as conflicting, since the check used plain list equality; a caller round-tripping through a dict-keyed cache or a set has no reason to preserve order. Comparison is now an order-independent multiset of each record's canonical JSON form
- New regression coverage in `tests/utils/test_normalize_graph_payload.py`: exception-type parity for non-mapping input across `export_lpg`/`export_arango`/`export_neo4j_csv`, dataclass-record conversion verified end-to-end through the same three exporters, `Neo4jCSVExporter` accepting a `MappingProxyType` payload, and reordered-alias equality (plus a duplicate-count case confirming the multiset check still catches real conflicts); 4 existing tests updated to assert the corrected dict-conversion behavior instead of the previous object passthrough
- `pytest tests/export tests/utils tests/context tests/test_export_module.py tests/test_export_methods_wrapper.py tests/test_notebooks_simulation.py`: 718 passed, 4 skipped (up from 641 passed, 62 subtests at PR submission); `black`/`isort`/`flake8 --max-line-length=88` clean on every line this PR touches; `python -m build`: succeeds
- **`ContextGraph.add_edge` had no dedupe — identical edges were stored repeatedly under one shared edge ID, and re-ingest doubled the edge set** (#926, closes #922) by @pravit-amp
- `_add_internal_edge` appended to `self.edges`, `edge_type_index`, and `_adjacency` unconditionally, with no check for an edge already present. Edge identity is content-derived (`_resolve_edge_identity` builds `edge_id` from `source_id`/`target_id`/`edge_type`/`weight`/`metadata`/`valid_from`/`valid_until`), so two identical `add_edge` calls produced two edge objects sharing one `edge_id` — the graph already considered them the same edge, it just kept both copies. `self.nodes` already deduped by ID; edges did not, so `stats()["edge_count"]` inflated, `density()` could exceed its mathematical maximum of `1.0`, and a refresh/restore job calling `build_from_entities_and_relationships()` (or reloading a saved graph) doubled the edge set on every cycle
- Added an `edge_id -> ContextEdge` index (`_edge_index`), mirroring how `self.nodes` dedupes by node ID. `_add_internal_edge` now returns `False` when the `edge_id` already exists, checked before touching `edges`/`edge_type_index`/`_adjacency` and before firing the mutation callback, so a repeat `add_edge` is a silent no-op with no phantom `ADD_EDGE` audit event
- Genuinely parallel edges are unaffected: differing type/weight/metadata/validity still produce distinct content-derived `edge_id`s, so multigraph semantics are preserved
- Both state-reset paths (`load_from_file()` and `clear()`) also clear `_edge_index`
- New tests: repeat `add_edge` is a no-op, parallel edges with distinct attributes are preserved, re-ingest via `build_from_entities_and_relationships()` stays at one edge, and `clear()` resets the dedupe index
- `pytest tests/context/test_context.py`: 31 passed
- **`POST /api/enrich/extract` returned 503 on every request; the whole `/api/decisions*` family returned 500 as soon as a decision existed** (#886, closes #883, closes #884, closes #889) by @joseedson18jc, reviewed by @Sameer6305
- `semantica/explorer/routes/enrich.py` imported `extract_entities`/`extract_relations` from `semantica.semantic_extract.methods`, names that module never defined (only per-strategy variants like `extract_entities_ml` exist) — the `except ImportError` handler reported this as `"semantic_extract module not available"`, masking a wiring bug as a missing dependency. The route now calls `NamedEntityRecognizer`/`RelationExtractor` directly and forwards extracted entities into relation extraction instead of re-deriving them
- `ContextGraph.record_decision()` stores `timestamp` as `datetime.now().timestamp()` (a float), while `DecisionResponse.timestamp` was typed `Optional[str]`; passing the value through unconverted failed pydantic validation on every decision route (`/api/decisions`, `/{id}`, `/{id}/chain`, `/{id}/precedents`, `/{id}/compliance`). Added a `field_validator(mode="before")` on `DecisionResponse` normalizing float/int/datetime inputs to ISO-8601
- Folds in the fix for #889: `extract_entities_ml`/`extract_relations_similarity`/`extract_relations_dependency` called `spacy.load()` on every invocation (~120ms of a ~132ms call, ~60x the actual extraction work). Added a process-level, lock-guarded `load_spacy_model()` cache in `semantic_extract/methods.py`, keyed by model name; failed loads are not cached, and the separate `get_nlp_model()` cache (different `disable=` pipeline config for similarity work) is kept independent to avoid handing one caller's spaCy pipeline to another
- **Fixed during review** (@Sameer6305): capped previously-unbounded input text on `/api/enrich/extract`; tightened the route's exception handling
- **Fixed during review** (@KaifAhmad1): the timestamp validator's `math.isfinite()` guard only rejected NaN/inf — a finite-but-out-of-range epoch (e.g. milliseconds mistakenly stored instead of seconds, such as `1723600000000`) still raised an uncaught `OverflowError`/`OSError` from `datetime.fromtimestamp()`, reintroducing an unhandled 500 on `/api/decisions*` for exactly the class of bug this PR closes. Now caught and re-raised as a `ValueError`. Also excluded `bool` from the numeric branch (`isinstance(True, int)` is `True` in Python, so `timestamp=True` was silently coerced to epoch 1 instead of being rejected)
- New/updated tests: `tests/explorer/test_explorer_api.py` (`TestRecordedDecisions`, extraction coverage, 4 new `TestDecisionResponseTimestampValidator` cases for the range/bool fixes), `tests/semantic_extract/test_spacy_model_cache.py` (6 tests)
- `pytest tests/explorer tests/semantic_extract/test_spacy_model_cache.py`: 266 passed
- **Explorer UI hid backend failures: graph load hung forever, landing page always showed "System Online"** (#980, closes #977) by @ZohaibHassan16, reviewed by @Sameer6305
- `GraphWorkspace.tsx` only destructured `{ data, isLoading, isFetching }` from `useLoadGraph()`, ignoring the `isError`/`error`/`refetch` that `useQuery` (`retry: 0`) already returned. Combined with `GraphLoadingOverlay` having no error prop and `showLoadingOverlay` staying true whenever `loadingProgress` held a stale frame, a backend-down or failed fetch left the graph workspace stuck on the last progress frame indefinitely, with no error message and no way to recover short of a full page reload
- `GraphLoadingOverlay` now accepts `error`/`onRetry` and renders an error card with the real fetch error message and a Retry button (`refetch()`) instead of the stuck progress UI
@@ -218,86 +112,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New regression coverage in `tests/export/test_distance_exporter.py`: warnings fire on exception for all four helpers, exported sentinel values/shape stay unchanged, and the legitimate "no KG backend" `None` path still logs nothing
- Full `tests/export/` suite: 71 passed
- **`explain_violations` rendered hardcoded placeholders (`min_count=1`, `max_count=1`) instead of the SHACL shape's real constraint values, and misused the violation message text as the datatype/class value** (#1094) by @cxzg007
- `_run_pyshacl` never read `sh:minCount`/`sh:maxCount`/`sh:datatype`/`sh:class` back from the violation's `sh:sourceShape`, so every plain-English explanation was wrong regardless of what the shape actually declared. `SHACLViolation` now carries those four fields (also exposed via `to_dict()`), populated by back-referencing `sh:sourceShape`; `explain_violations` renders the real values, falling back to `"?"` when a value is genuinely absent
- **Known limitation**: `sh:qualifiedMinCount`/`sh:qualifiedMaxCount` are not handled yet and still fall back to the `"?"` placeholder
- New regression tests cover both the rendering path and the `sh:sourceShape` back-reference (skipped when `pyshacl`/`rdflib` are absent)
- **Entity merging silently dropped `entity_id` aliases, and exact-match entity resolution had three correctness gaps** (#1086, #1026) by @T1mn
- `entity_merger.py`/`merge_strategy.py`/`entity_resolver.py` used inconsistent logic for extracting an entity's id across the merge path, so a merged entity could lose the `entity_id` aliases that let later lookups find it under its old identity. A new `semantica/utils/entity_ids.py` unifies id extraction across all three call sites
- `EntityResolver`'s exact-match path is now honored rather than silently falling through to fuzzy matching in some cases; entities with no identifier are preserved instead of being dropped, and blank exact-match names are ignored rather than matching every other blank name
- New/expanded coverage in `tests/kg/test_entity_pipeline.py` and `tests/kg/test_entity_resolver_exact.py`
- **`flatten_dict()` silently collided keys when a flattened path from one branch matched a literal key already present at the target depth** (#1062) by @shahzaib-ahmadcs
- Two differently-shaped inputs could flatten to the same output key, with the second write silently overwriting the first — no error, no warning, just a dropped value. Collisions are now detected and handled explicitly instead of overwriting
- **`ExcelParser.__init__` raised `NameError` on every instantiation — `get_progress_tracker()` was called but never imported** (#1016, closes #1014) by @pravit-amp
- Same defect as the one fixed for `SimilarityCalculator` in #530, this time in `semantica/parse/excel_parser.py`; the existing test imported the class but never constructed it, so nothing caught the missing import. Added construction coverage for every parser exported from `semantica.parse`, driven off `__all__` so future additions are covered automatically, living outside `test_parse_comprehensive.py` (whose `setUp` mocks `get_progress_tracker` into each module and would mock away the exact interaction under test)
- **Graph analytics (`centrality_calculator.py`, `community_detector.py`, `connectivity_analyzer.py`) dropped isolated nodes and diverged on how each computed its working view of the graph** (#1011) by @T1mn
- Each analyzer had its own ad hoc logic for building the node/edge set it operated over, and none of them included nodes with no edges — a node with zero connections simply vanished from centrality scores, community assignments, and connectivity reports instead of appearing with a zero/singleton value. A new shared `semantica/kg/_graph_view.py` centralizes graph-view construction (including node fallbacks and community payload shaping) for all three analyzers, which are now ~250 lines lighter combined
- New `tests/kg/test_analytics_node_scope.py` covering isolated-node presence across all three analyzers
- **Explorer fired temporal-bounds and snapshot requests before the graph itself had loaded, tripling failed requests when the backend was down and leaving the timeline scrubber with nothing to scrub** (#1003) by @lakshayxi
- Two new predicate functions gate the temporal effects on the graph having actually loaded (an empty graph still counts as loaded); confirmed against a downed backend that this cuts three failing requests per page load down to one
- **`SeedDataManager.load_from_database()` never actually reached the database, and connection failures were mislabeled as a missing optional dependency** (#995, closes #973) by @yzxcj797
- `DBIngestor.execute_query`/`export_table` need the connection string as their first positional argument; `load_from_database()` only passed it into the constructor's config dict, which those methods never read, so every call raised `TypeError` before connecting. Also split the combined `except (ImportError, OSError)` handling apart — a genuine connection failure was reported as `"module not available"`, sending debugging in the wrong direction; `OSError` now propagates as an actual failure, chained via `from e`
- **SPARQL `CONSTRUCT` detection matched inside a leading `#`-comment, misclassifying `SELECT`/`ASK` queries as `CONSTRUCT` across all four SPARQL backends** (#951) by @pravit-amp
- `CONSTRUCT_QUERY_RE` skipped comments with a bare `\#[^\n]*`, whose backtracking `*` let a `# CONSTRUCT ...` comment line "swallow" the real query-form keyword on the next line for a query like `# CONSTRUCT ...\nSELECT ...`. The mistaken `CONSTRUCT` classification sent `Accept: text/turtle` and tried to parse a SELECT/ASK response body as Turtle, failing with a misleading parse error. The regex now requires a comment to reach a line terminator (LF or CR, per the SPARQL grammar) before matching
- **`k_shortest_paths` mutated caller-visible graph state during traversal and ignored direction when excluding already-used edges** (#1000) by @T1mn
- `semantica/kg/path_finder.py`'s search left side effects behind after returning, and edge exclusion during Yen's-algorithm-style path removal didn't respect the traversal direction of directed graphs, letting a later search see edges that should have been available. Both fixed; new coverage in `tests/kg/test_path_finder.py`
- **`trace_decision_causality()` ignored explicitly recorded causal edges, inferring causes only from shared NER entities plus timestamp ordering** (#983) by @hsd2514
- A `CAUSED`/`INFLUENCED`/`PRECEDENT_FOR` edge added via `add_causal_relationship()` had no effect on the trace — when entity extraction found nothing in common between two decisions, `trace_decision_chain()` came back empty even with an explicit edge stored in the graph. Explicit causal edges are now traversed first as ground truth, with entity/timestamp inference kept as an additive fallback for pairs with no explicit link; edges whose source has no decision record (e.g. a graph restored via `from_dict`) are skipped so a stale edge can't abort the trace
- **`RepoIngestor`'s module-level DNS resolve cache had no lock, raising `RuntimeError: OrderedDict mutated during iteration` under concurrent `ingest_repository()` calls** (#979) by @manjunathbhaskar
- `_REPO_HOST_RESOLVE_CACHE` is a shared `OrderedDict` read, written, and pruned by every thread with no synchronization — reliably reproduced with 32 threads hammering resolution under a low TTL and small cache cap. Now guarded by a lock
- **`GraphBuilder` didn't remap relationship endpoints after entity resolution merged nodes, leaving relationships pointing at ids that no longer existed in the resolved graph** (#978) by @T1mn
- New coverage in `tests/kg/test_graph_builder_external.py`; a follow-up commit hardens the remapping against edge cases found during review
- **Explorer's dev server esbuild target didn't match the browser targets the production build declares**, occasionally producing dev-only syntax errors on older browsers (#966) by @le-czs
- `explorer/vite.config.ts` now sets the dev esbuild target explicitly to match
- **`normalize`'s number normalizer accepted currency symbols without validating them against the surrounding text, and an earlier fix's currency-code matching wasn't token-bounded** (#940) by @Mr-Neutr0n, reviewed by @ZohaibHassan16
- Symbol currencies are now validated before being accepted; currency codes are matched on token boundaries so a code embedded inside a longer token no longer false-positives
- **`ContextGraph.to_dict()` was the one reader on the class that didn't hold `self._lock`, raising `RuntimeError: dictionary changed size during iteration` under a concurrent writer and risking a torn snapshot otherwise** (#929) by @pravit-amp
- Every other reader (`stats()`, `density()`, `find_nodes()`, `find_edges()`, `get_neighbors()`, `get_nodes_by_label()`, `state_at()`, `save_to_file()`) already took the lock after it was introduced; `to_dict()` predated that change and was missed. `save_to_file()` was safe only incidentally, since it builds its payload inline under its own lock rather than delegating to `to_dict()`
- **`PipelineWithProvenance` had a broken import and no working `run()` method** (#862) by @Karunasagar12
- `from .pipeline import Pipeline` failed because `Pipeline` lives in `pipeline_builder.py`, not a nonexistent `pipeline.py` — fixed to `from .pipeline_builder import Pipeline`. The class also had no `run()`; it now delegates to `ExecutionEngine.execute_pipeline()`, the intended execution path for a built `Pipeline`. The constructor now accepts a built `Pipeline` instance directly
### Security
- **Tarball restore path traversal, latent SQL injection, DNS-rebinding TOCTOU in the shared SSRF guard, stored XSS in report generation, and unvalidated SPARQL object IRIs in AnzoStore** (#1079) by @KaifAhmad1
- `semantica backup restore`'s tar extraction (`cli.py`) stripped only the literal `semantica-backup/` prefix and called `tar.extract()` with no path-containment check, no symlink/hardlink validation, and (on Python <3.12) no extraction filter — a crafted archive member (`../../<file>`, or a symlink pointing outside the restore root) could write arbitrary files above the restore directory. Every member is now validated for resolved-path containment before extraction, symlink/hardlink targets are rejected both lexically (absolute path, `..` segments) and by resolution, and `filter="data"` is applied on Python ≥3.12
- `DataExporter.export_table_data()` (`db_ingestor.py`) was missing the `text` import from `sqlalchemy` — a `NameError` that made the method non-functional, but latently: the query it built from raw f-string interpolation of `table_name`/`schema`/`where`/`order_by` was already injectable, so fixing the import alone (without also fixing the injection) would have silently armed it. Both are fixed together: the import is restored, `table_name`/`schema` are now validated against a strict identifier allowlist, and `where`/`order_by` are checked against a blocklist (statement separators, comments, UNION, DDL/DML keywords, time-based blind-injection primitives, schema-enumeration terms). This is a blocklist, not a grammar — it closes the concrete UNION-exfiltration path and common injection primitives, but a boolean-blind subquery using none of the blocked keywords could still get through; `where`/`order_by` must be treated as trusted/operator input, not exposed to untrusted end users, and the docstrings now say so explicitly
- `request_with_ssrf_guard()` (`ssrf.py`) validated a hostname's resolved IPs, then let the underlying HTTP client re-resolve the same hostname independently at connect time — a low-TTL or DNS-rebinding answer could differ between the two lookups, so a hostname that validated as public could still connect to a private/internal address. Ported the IP-pinning pattern already used by `explorer/routes/ontology.py`'s `_make_pinned_session` into the shared ingest guard: the one resolution that decides accept/reject is now also the one the connection is pinned to, via a custom `HTTPAdapter` that presents the real hostname over TLS SNI / Host header while connecting only to the validated IPs. Also closes the RFC 6598 Carrier-Grade NAT gap noted as a known limitation in #905/#868: `100.64.0.0/10` is now in `BLOCKED_NETWORKS`
- `ReportGenerator._generate_html()` (`export/report_generator.py`) f-string-interpolated report title/summary/metrics into HTML with no escaping — an ingested entity or document whose content flowed into a report (e.g. `<img src=x onerror=...>`) executed as stored XSS when the report was opened. All interpolated values are now `html.escape()`d
- `AnzoStore._format_object_for_sparql()` (`triplet_store/anzo_store.py`) validated the subject/predicate of a triplet via `sparql_escaping.validate_uri()` before interpolating them into a SPARQL `INSERT DATA` clause, but delegated the **object** position to a separate formatter that wrapped it as `<{obj}>` without the same validation — an object value containing `>`/`}`/`{`/`"` could close the intended `<...>` token early and inject additional SPARQL Update operations. The Blazegraph/RDF4J backends were hardened for the equivalent gap previously; Anzo's object position now goes through the same `validate_uri()` check
- Also hardened in the same pass: Apache AGE's `create_index()` `index_type` parameter is now allowlisted (was interpolated raw into a `USING` clause); Neo4j's `limit` is now explicitly validated (raises `ValidationError` for non-integer input instead of falling through to a generic `ProcessingError`); the `ffprobe` metadata-extraction subprocess call is guarded against a filename starting with `-` being parsed as an option; the MCP server no longer echoes raw exception text to JSON-RPC clients, logging full details server-side and returning a generic message plus the exception class name instead
- **Fixed during review** (@KaifAhmad1): the SSRF IP-pinning change introduced a connection-pool leak of its own — `requests.Session.mount()` silently drops whatever adapter it replaces without closing it, so a multi-hop redirect chain on a reused session leaked one pooled connection per hop. Pinned adapters are now tagged and explicitly closed before being replaced, both per-hop and on final restore
- **Fixed during review** (@KaifAhmad1): mounting a pinned adapter and setting a Host header on a caller-supplied `Session` is not inherently thread-safe — two guarded calls sharing the same session from different threads could interleave their mount/restore cycles. Added a per-session lock (`_get_session_lock`) so concurrent guarded calls on the same session now serialize instead of racing; verified with a two-thread test showing correct serialization and zero cross-contamination of per-request Host headers
- **Fixed during automated PR review** (Qodo): `export_table_data()`'s new identifier/fragment validation raised `ValidationError` from inside a `try` whose blanket `except Exception` re-wrapped it as `ProcessingError`, masking the distinction between "bad input" and "the export itself failed" that callers rely on elsewhere in this module. Added the `except ValidationError: raise` guard already used by its sibling methods
- **Fixed during automated PR review** (Qodo): on a hop where IP pinning doesn't apply (`allow_private_ips=True`), `_apply_connection_pin()` unconditionally popped the session's `Host` header instead of restoring whatever it was before pinning touched it — a caller-supplied session carrying its own legitimate `Host` override (e.g. fronting a private endpoint under a different name) had that override silently dropped for the in-flight request, only reappearing afterward via the outer `finally` restore. It now restores the session's own pre-call header state (set back if present, popped only if it was truly absent) instead of always popping
- **Fixed during automated PR review** (Qodo): the `where`/`order_by` blocklist matched keywords/punctuation inside properly quoted string literals and identifiers too, so legitimate data like `status = 'union'` or `name = 'a--b'` was rejected as if it were SQL syntax. The blocklist now runs against a copy with quoted-literal contents masked out (`_mask_sql_literals`) — a malformed/unterminated quote sequence doesn't match the masking pattern and is left fully exposed to the blocklist, so this closes false positives without opening a masking-based bypass; the fragment actually used in the query is unchanged
- Re-ran each finding's proof-of-concept (or an equivalent adversarial test) against the fix and confirmed it is blocked: tar path/symlink traversal (both lexical and resolved-path forms), SQL UNION exfiltration and identifier breakout, DNS-rebinding TOCTOU (including under a configured `HTTP_PROXY`, which the pinning adapter also rejects outright since a proxy would resolve DNS itself), stored XSS, and the AnzoStore SPARQL injection
- `pytest tests/ingest/`: 266 passed, 2 skipped (10 pre-existing failures unrelated to this change — identical failure set confirmed on unmodified `main`); full regression sweep across `graph_store`, `export`, `triplet_store`, `parse`, and backup/restore: 313 passed
- **`Authorization`/`Proxy-Authorization` credentials could leak to a different origin across HTTP redirects, and several ingest paths bypassed the shared SSRF/redirect guard entirely** (#1067, closes #947) by @Sameer6305, reviewed by @KaifAhmad1
- `request_with_ssrf_guard()` previously only stripped sensitive headers from per-request `kwargs["headers"]` on a cross-origin redirect; session-level `Authorization`/`Proxy-Authorization` headers, `session.auth`, and `session.trust_env` (`.netrc` lookup) could all still resurrect credentials on the hop to a foreign origin. All five credential sources are now stripped case-insensitively, kept stripped for the remainder of a multi-hop redirect chain (no resurrection even if a later hop returns to the original host), and unconditionally restored via `finally` — including on exceptions and redirect-limit errors
- `MCPClient._send_request_http()` and `PublicAPIIngestor.detect_public_api()`/`ingest_public_api()` called `httpx.post()`/`requests.post()`/`session.request()` directly, bypassing `request_with_ssrf_guard()` entirely. Both now route through the shared guard, including when `validate_no_auth=False`
- `SeedDataManager.load_from_api()` mutated the caller-supplied `headers` dict in place when adding an API-key `Authorization` header, silently leaking the key back into a dict the caller might reuse elsewhere. Now copies before modifying
- **Fixed during review** (@KaifAhmad1): `allow_private_ips=True` (used to let MCP servers run on localhost/internal networks) was applied to every redirect hop, not just the operator-configured host — a compromised or malicious MCP server could 302-redirect to an internal address (e.g. `169.254.169.254` cloud metadata) and the guard would follow it unchecked, defeating the SSRF protection this PR otherwise adds. Added `allow_private_ips_on_redirect` to `request_with_ssrf_guard()`: a redirect target inherits the original host's private-IP trust only when it matches that host; any other host falls back to strict validation. `MCPClient` now pins `allow_private_ips_on_redirect=False`, so only same-host redirects on a trusted MCP server keep working — a cross-host hop into private address space is blocked
- **Fixed during review** (@KaifAhmad1): `detect_public_api()` only caught `requests.exceptions.RequestException`, but `request_with_ssrf_guard()` raises `ValidationError` (a disjoint hierarchy) for SSRF-blocked hosts, blocked redirect targets, missing `Location`, or exceeded redirect limits — unlike its sibling `ingest_public_api()`, which already caught it. Callers (including `is_public_api()`) got an undocumented raw `ValidationError` instead of `ProcessingError`, and the error-logging call was skipped. Now catches `(ValidationError, ProcessingError)` and re-raises, matching the sibling method
- **Fixed during review** (@KaifAhmad1): `detect_public_api()`/`ingest_public_api()` forwarded `**options` into `request_with_ssrf_guard(..., session=self.session, allow_private_ips=self.allow_private_ips, **request_options)` without stripping `session`/`allow_private_ips` from `request_options` first — a caller passing either through the per-call `**options` (a plausible mistake, since `allow_private_ips` is also a documented constructor-level knob) got a raw `TypeError: got multiple values for keyword argument`. Both are now popped from `request_options` before the call
- New regression coverage added during review: `TestAllowPrivateIpsOnRedirect` (cross-host redirect into private space blocked, same-host redirect trust preserved, default behavior unchanged for existing callers that don't pass the new kwarg) and `TestMCPClientAuthRedirect::test_redirect_to_private_ip_is_blocked`/`test_same_host_redirect_on_private_mcp_server_is_not_blocked` in `tests/ingest/test_auth_header_redirect_security.py`; `test_detect_public_api_propagates_ssrf_validation_error` and duplicate-kwarg regression tests for both methods in `tests/ingest/test_public_api_ingestor.py`
- `pytest tests/ingest/test_auth_header_redirect_security.py tests/ingest/test_public_api_ingestor.py tests/test_seed_manager.py tests/ingest/test_submodules.py tests/ingest/test_cookbook_integration.py`: 111 passed
- **`FeedIngestor`/`FeedMonitor` (RSS/Atom feed ingestion) had no SSRF protection, allowing requests to internal/private network targets** (#928, closes #927) by @ZohaibHassan16
- `FeedIngestor.ingest_feed()`, `discover_feeds()` (link-tag fetch, common-path HEAD probe, and feed-validation GET), and `FeedMonitor.check_updates()` all called `requests.get()`/`requests.head()` directly with default redirect-following and no scheme allowlist or private/loopback/link-local IP validation — despite `semantica/ingest/ssrf.py`'s `request_with_ssrf_guard()` already existing and being used by `web_ingestor.py`/`api_ingestor.py`. `ingest_feed()`'s own URL check only verified `urlparse(url).scheme`/`.netloc` were non-empty, never that the scheme was http/https or that the resolved target IP was safe. Reachable via the public `ingest_feed()`/`ingest()` entry points with any caller-supplied feed URL
- All 5 call sites now route through `request_with_ssrf_guard()`, which validates scheme (http/https only) and resolved IP before the request, and re-validates every redirect `Location` before following it — closing both the direct-IP and redirect-chain SSRF paths. Added an `allow_private_ips` config option to both `FeedIngestor` and `FeedMonitor`, consistent with the other ingestors
@@ -331,10 +147,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Caught by the new gate on its first run**: `python -m pip install -e ".[all]"` pulled in `setuptools==79.0.1`, vulnerable to CVE-2026-59890/GHSA-h35f-9h28-mq5c/PYSEC-2026-3447 (Unicode-normalization bypass of `MANIFEST.in` exclude/prune patterns on macOS APFS/HFS+, letting excluded files leak into a built sdist), fixed in `83.0.0`. `[build-system] requires` had the exact same too-permissive-floor pattern this whole entry is about (`setuptools>=61.0`), and `actions/setup-python`'s baked-in `setuptools` isn't governed by that pin at all since it's outside any isolated build. Bumped `[build-system] requires` to `setuptools>=83.0.0`, and the `Security` workflow now runs `pip install --upgrade pip setuptools` before auditing so the scanned environment can't have a stale ambient copy regardless of what governs it
- Full `explorer` suite: 241 passed
- **`SeedDataManager.load_from_api()` made unguarded HTTP requests, with no SSRF protection at all** (#942) by @ZohaibHassan16
- `load_from_api()` called `requests.get()` directly instead of going through `semantica/ingest/ssrf.py`'s `request_with_ssrf_guard()`, unlike every other ingestor in this module — a caller-supplied `api_url` could target internal/private network addresses with no validation. Now routes through the shared guard, gaining redirect validation and bounded DNS resolution for free
- **Follow-up** (#959, closes #943) by @yunaremaia: added an `allow_private_ips` opt-in (parsed via the shared `parse_bool` helper) for trusted internal deployments that legitimately need to load from a private-network API, while keeping the guard's block-by-default behavior for everyone else
## [0.6.5] - 2026-08-11
### Added
+3 -17
View File
@@ -25,9 +25,9 @@ If you want to work on an open GitHub issue, please follow these steps to keep t
1. **Check the issue.** Look at the issue's assignees and recent comments. If someone is already actively working on it, consider a different issue or ask in the comments whether help is welcome.
2. **Comment if you'd like the issue reserved.** Leaving a comment like *"I'd like to take this on"* is the fastest way to get assigned, but it isn't required — maintainers can also assign an issue directly to a contributor (e.g., based on recent activity in the repo) without waiting for a comment first.
2. **Comment before you start.** Leave a comment on the issue saying you'd like to work on it — something like *"I'd like to take this on"* is enough. This gives maintainers the context they need to assign the issue appropriately.
3. **Wait for assignment.** A maintainer will assign the issue when appropriate, whether or not a comment was left. Please wait for this before investing significant time in implementation, as priorities and approaches can shift.
3. **Wait for assignment.** A maintainer will review the request and assign the issue when appropriate. Please wait for this before investing significant time in implementation, as priorities and approaches can shift.
4. **Create a branch and implement.** Once assigned, fork the repository (if you haven't already), create a dedicated branch, and begin your work.
@@ -37,26 +37,12 @@ If you want to work on an open GitHub issue, please follow these steps to keep t
5. **Open a focused PR and link the issue.** When you're ready, open a pull request and reference the issue in the description (e.g., `Closes #123`). Keep the PR scoped to the work described in the issue.
> **Why this matters:** Assignment (with or without a comment) helps maintainers track who is working on what and prevent two contributors from solving the same problem independently. It also gives you a chance to align on the expected approach before writing code.
> **Why this matters:** Commenting before opening a PR helps maintainers track who is working on what, assign issues correctly, and prevent two contributors from solving the same problem independently. It also gives you a chance to align on the expected approach before writing code.
Not sure where to start? Try a [`good first issue`](https://github.com/semantica-agi/semantica/labels/good%20first%20issue) or ask in [Discord](https://discord.gg/sV34vps5hH).
---
## 🔀 Duplicate PRs & Issue Priority
When more than one pull request targets the same issue, maintainers triage using this order of priority. These rules decide between PRs that are otherwise following the [assignment workflow above](#-working-on-an-existing-issue) — opening a PR before being assigned doesn't grant priority on its own, and an unassigned PR can still be closed as a duplicate once someone else is assigned to the issue.
1. **Contributor-raised issue with an existing PR.** If the person who opened the issue has also opened a PR for it, that PR is prioritized (they still need to be assigned before it's merged).
2. **Maintainer-raised issue with a claim comment.** If we opened the issue and someone has commented asking to work on it, we assign it to them and check their PR before picking up any other PR for the same issue.
3. **No prior assignment or comment.** If multiple PRs exist and no one was assigned or claimed the issue first, priority goes to whichever contributor has the most consistent activity in the repo over the last 60 days (e.g., merged PRs, substantive reviews, or issue triage participation) — not just PR volume.
4. **Late duplicate PRs.** If a PR is opened after another contributor has already been assigned to the issue, we close the duplicate early rather than let it sit open, and point the author to another open issue (or ask them to check `main` for newly opened ones). This avoids contributors spending time updating a PR that won't be merged.
5. **Overlapping scope.** If a PR covers multiple issues, or there's genuine overlap between competing PRs, maintainers discuss it on [Discord](https://discord.gg/sV34vps5hH) before deciding rather than resolving it unilaterally.
**Why this matters:** it keeps triage predictable, avoids wasted contributor effort on PRs that won't merge, and helps retain active contributors.
---
## 🎯 Ways to Contribute
### 💻 Code
+1 -1
View File
@@ -101,7 +101,7 @@ When using the all-contributors bot, use these codes:
- `infra` - Infrastructure
- `maintenance` - Maintenance
See [all-contributors specification](https://github.com/all-contributors/all-contributors#emoji-key) for complete list.
See [all-contributors specification](https://allcontributors.org/docs/en/emoji-key) for complete list.
---
-1
View File
@@ -1,2 +1 @@
recursive-include semantica/static *
recursive-include semantica/ontology/vocabulary *.ttl
+45 -40
View File
@@ -2,15 +2,7 @@
<img src="Semantica Logo.png" alt="Semantica" width="420"/>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<a href="https://trendshift.io/repositories/18986?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-18986" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/repositories/18986" alt="semantica-agi/semantica | Trendshift" width="250" height="55"/>
</a>
<a href="https://trendshift.io/repositories/18986?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-18986" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/trendshift/repositories/18986/weekly?language=Python" alt="semantica-agi/semantica | Trendshift" width="250" height="55"/>
</a>
</div>
<a href="https://trendshift.io/repositories/18986?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-18986" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/18986" alt="semantica-agi%2Fsemantica | Trendshift" width="250" height="55"/></a>
### Graph-Native Infrastructure for Context and Accountable AI Systems
@@ -60,8 +52,6 @@ Most AI agents act without a trail. They store embeddings, not meaning: context
Semantica sits underneath your LLM, vector store, and agent framework as a deterministic infrastructure layer: no LLM required for graph construction, reasoning, or provenance.
> ⚠️ **System-level explainability, not foundation-model explainability.** Semantica does not expose or reconstruct what happens *inside* the LLM — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. Semantica explains what's *outside* the model: the context and data fed in, the decision produced, its provenance, relevant relationships, applied policies, and the full execution trail.
**Who it's for:**
- **AI/ML platform teams** shipping agents that make consequential decisions and need structured, queryable context built from fragmented raw data, not just a vector index
@@ -87,7 +77,7 @@ Semantica sits underneath your LLM, vector store, and agent framework as a deter
- **Graph Analytics:** Centrality, community detection, link prediction, and shortest-path queries over the graph you just built
- **Polyglot Graph Storage:** Native RDF (embedded Oxigraph, Blazegraph, Apache Jena, Eclipse RDF4J via SPARQL) and Labeled Property Graphs (Neo4j, FalkorDB, Apache AGE, AWS Neptune via Cypher), plus vector stores, all swappable without touching your code
- **Visualization:** Explore any graph, ontology, or timeline in an interactive browser workbench
- **Drop-in Integrations:** Native Agno and CrewAI support, a full-featured MCP server, a comprehensive CLI, a REST API, and plugins across major editors
- **Drop-in Integrations:** Native Agno support, a full-featured MCP server, a comprehensive CLI, a REST API, and plugins across major editors
---
@@ -142,7 +132,7 @@ compliant = graph.check_decision_rules({"category": "vendor_selection"}) # poli
```bash
semantica doctor
# Python 3.11.9 pass
# semantica 0.6.6 pass
# semantica 0.6.5 pass
# faiss vector store pass
# Config file pass ~/.semantica/config.yaml
```
@@ -303,10 +293,17 @@ graph.add_causal_relationship(d1, d2, relationship_type="CAUSED")
prov.track_entity("patient_P4821", source="ehr/medication_orders_2024.json",
metadata={"extractor": "NamedEntityRecognizer"})
# Export W3C PROV-O for regulator submission - to_kg_dict() is the official
# adapter that emits the {"entities": [...], "relationships": [...]} /
# source_id shape RDFExporter expects, so no manual field mapping is needed
kg = graph.to_kg_dict()
# Export W3C PROV-O for regulator submission - RDFExporter expects
# {"entities": [...], "relationships": [...]}, so map ContextGraph.to_dict()'s
# {"nodes": [...], "edges": [...]} shape onto it first
graph_dict = graph.to_dict()
kg = {
"entities": [{"id": n["id"], "type": n["type"], "text": n["content"]} for n in graph_dict["nodes"]],
"relationships": [
{"source_id": e["source"], "target_id": e["target"], "type": e["type"]}
for e in graph_dict["edges"]
],
}
RDFExporter().export(kg, "audit_trail.ttl", format="turtle")
```
@@ -880,14 +877,20 @@ fact = BiTemporalFact(
recorded_at=datetime(2024, 3, 5),
)
# Query facts valid within a time window - to_kg_dict() is the official
# adapter that emits {"entities", "relationships"} with source_id/target_id
# keys, the shape query_time_range() expects (no manual mapping required)
kg = graph.to_kg_dict()
# Query facts valid within a time window - query_time_range() expects
# {"relationships": [...]} with source_id/target_id keys, which differs from
# ContextGraph.to_dict()'s {"nodes", "edges"} shape, so map it first
graph_dict = graph.to_dict()
kg_relationships = {
"relationships": [
{**e, "source_id": e["source"], "target_id": e["target"]}
for e in graph_dict["edges"]
]
}
tq = TemporalGraphQuery()
facts_in_window = tq.query_time_range(
kg, query="valid_facts", start_time="2024-01-01", end_time="2024-12-31"
kg_relationships, query="valid_facts", start_time="2024-01-01", end_time="2024-12-31"
)
# Normalize natural language temporal expressions - returns a (start, end) range
@@ -1186,7 +1189,7 @@ Start with `semantica`, verify with `doctor`, build a graph, and explore the com
## Integrations
Native plugin bundles for Claude Code, Cursor, Codex, Windsurf, Cline, Continue, VS Code, and OpenClaw; a full-featured MCP server for any MCP-compatible client; a comprehensive REST API; and first-class Agno and CrewAI support for agentic frameworks. Every major LLM provider is already supported via `semantica.llms` and LiteLLM: OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Azure, Bedrock, Ollama, DeepSeek, HuggingFace, and more.
Native plugin bundles for Claude Code, Cursor, Codex, Windsurf, Cline, Continue, VS Code, and OpenClaw; a full-featured MCP server for any MCP-compatible client; a comprehensive REST API; and first-class Agno support for multi-agent shared context. Every major LLM provider is already supported via `semantica.llms` and LiteLLM: OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Azure, Bedrock, Ollama, DeepSeek, HuggingFace, and more.
MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
@@ -1300,11 +1303,6 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
<strong>Agno</strong><br/>
<sub>First-class · <code>pip install semantica[agno]</code></sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/crewAIInc/crewAI"><img src="https://github.com/crewAIInc.png?size=120" alt="CrewAI" width="48" height="48" /></a><br/>
<strong>CrewAI</strong><br/>
<sub>First-class · <code>pip install semantica[crewai]</code></sub>
</td>
</tr>
<tr>
<th colspan="8" align="left">Already Supported via REST API &amp; MCP</th>
@@ -1321,6 +1319,11 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
<sub>REST API · MCP</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/crewAIInc/crewAI"><img src="https://github.com/crewAIInc.png?size=120" alt="CrewAI" width="48" height="48" /></a><br/>
<strong>CrewAI</strong><br/>
<sub>REST API · MCP</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/run-llama/llama_index"><img src="https://github.com/run-llama.png?size=120" alt="LlamaIndex" width="48" height="48" /></a><br/>
<strong>LlamaIndex</strong><br/>
<sub>REST API · MCP</sub>
@@ -1351,6 +1354,11 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
<sub>Dedicated toolkit</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/crewAIInc/crewAI"><img src="https://github.com/crewAIInc.png?size=120" alt="CrewAI" width="48" height="48" /></a><br/>
<strong>CrewAI</strong><br/>
<sub>Dedicated toolkit</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/run-llama/llama_index"><img src="https://github.com/run-llama.png?size=120" alt="LlamaIndex" width="48" height="48" /></a><br/>
<strong>LlamaIndex</strong><br/>
<sub>Dedicated toolkit</sub>
@@ -1466,18 +1474,18 @@ For contributor / dev-server setup: **[explorer/README.md: Local Setup Guide](ex
---
## What's New in v0.6.6
## What's New in v0.6.5
**Security release — upgrading is strongly recommended.** Fixes for a privately disclosed batch of vulnerabilities spanning backup/restore, database export, outbound requests, and triplet-store backends, plus SSRF hardening across ingestion:
**Security release — upgrading is strongly recommended.** Fixes for 5 externally-reported vulnerabilities in the Explorer API and graph/triplet store backends, plus a CodeQL-flagged ReDoS:
- **Tarball restore path traversal**: `semantica backup restore` now validates every archive member for path containment and rejects symlink/hardlink escapes before extraction
- **Latent SQL injection in `DataExporter.export_table_data()`**: table/schema names are now identifier-allowlisted and `where`/`order_by` fragments are blocklist-checked
- **DNS-rebinding TOCTOU in the shared SSRF guard**: the resolved IP that passes validation is now the one the connection is pinned to, closing the check-then-use race (also closes the `100.64.0.0/10` CGNAT gap)
- **Stored XSS in HTML report generation** and **unvalidated SPARQL object IRIs in AnzoStore** (SPARQL injection): both now escape/validate before interpolation
- **`Authorization`/`Proxy-Authorization` credential leakage across redirects**, plus **SSRF gaps in `FeedIngestor`/`FeedMonitor`, `RepoIngestor`, and the MCP/public-API ingest paths**: all now route through the shared, redirect-safe SSRF guard
- **HTTP response header injection and an unbounded-memory DoS** in the Explorer API, and a **`fastapi`/`python-multipart` ReDoS** (PYSEC-2024-38): floors raised, inputs sanitized, candidate pools capped
- **Missing authentication on all Explorer API routes** (GHSA-j4mq-hprp-987v, Critical): every route now requires `SEMANTICA_API_KEY`, fails closed (503) rather than open when unconfigured
- **SSRF via redirect bypass in ontology URL fetching** (GHSA-8c7v-62gr-hj6g, High): redirect targets are now re-validated at every hop and the connection is pinned to the validated address, closing a DNS check-then-use race
- **Cypher injection via unvalidated node labels and property keys** (GHSA-482h-hw99-h62p, Critical): Neptune, Neo4j, and FalkorDB now sanitize every label/relationship-type/property-key interpolation site
- **SPARQL injection via unvalidated triplet IRIs** (GHSA-8vgg-8mr4-r236, Critical): Blazegraph, RDF4J, and Jena now validate subject/predicate/object IRIs before interpolation
- **Missing Origin validation on the WebSocket handshake** (GHSA-4643-wpgq-w329, Moderate, anonymous-mode only): `/ws/graph-updates` now checks `Origin` against the same allowlist `CORSMiddleware` enforces for HTTP
- **Polynomial ReDoS in SPARQL query validation** (CodeQL `py/polynomial-redos`): fixed a backtracking regex in the Explorer's SPARQL route
Also ships: **first-class CrewAI integration** (`semantica[crewai]`, extraction/decision tools + a knowledge source), **`ContextGraph` retraction and purge** (GDPR-style erasure without a full `clear()`), a declared **Semantica RDF vocabulary with deterministic entity/relationship IRIs** (stable, diffable exports), and **timezone-aware timestamps** across `export/` and `provenance/`.
Also includes: embedded Oxigraph backend for `TripletStore`, PROV-O trust/spec completeness for `ProvenanceManager`, and the Altair Anzo triplet store backend.
→ [Full release notes](RELEASE_NOTES.md) · [Changelog](CHANGELOG.md)
@@ -1495,8 +1503,6 @@ Semantica is designed for environments where AI outputs must be explainable, aud
- **Cybersecurity:** Threat attribution, incident response timelines, and IOC provenance tracking
- **Autonomous Systems:** Decision logs, safety validation, and explainable AI for certification
> ⚠️ **This is system-level explainability, not foundation-model explainability.** Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. What Semantica explains is *outside* the model: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. In short, Semantica explains and audits what the AI system did, not the LLM's private internal reasoning.
---
## Installation
@@ -1508,7 +1514,6 @@ pip install semantica[all] # everything
```bash
pip install semantica[agno] # Agno multi-agent integration
pip install semantica[crewai] # CrewAI integration
pip install semantica[llm-litellm] # OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Bedrock, Ollama, DeepSeek, and more
pip install semantica[graph-neo4j] # Neo4j graph store (LPG)
pip install semantica[graph-falkordb] # FalkorDB graph store (LPG)
+5 -5
View File
@@ -17,22 +17,22 @@ icon: "quote-left"
author = {Semantica},
year = {2026},
url = {https://github.com/semantica-agi/semantica},
version = {0.6.6},
version = {0.6.5},
doi = {10.5281/zenodo.XXXXXXX}
}
```
</Tab>
<Tab title="APA">
Semantica. (2026). *Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems* (Version 0.6.6) \[Computer software\]. https://github.com/semantica-agi/semantica
Semantica. (2026). *Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems* (Version 0.6.5) \[Computer software\]. https://github.com/semantica-agi/semantica
</Tab>
<Tab title="MLA">
Semantica. *Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems*. Version 0.6.6, GitHub, 2026, https://github.com/semantica-agi/semantica.
Semantica. *Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems*. Version 0.6.5, GitHub, 2026, https://github.com/semantica-agi/semantica.
</Tab>
<Tab title="Chicago">
Semantica. *Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems*. Version 0.6.6. GitHub, 2026. https://github.com/semantica-agi/semantica.
Semantica. *Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems*. Version 0.6.5. GitHub, 2026. https://github.com/semantica-agi/semantica.
</Tab>
<Tab title="IEEE">
Semantica, "Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems," Version 0.6.6, GitHub, 2026. \[Online\]. Available: https://github.com/semantica-agi/semantica
Semantica, "Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems," Version 0.6.5, GitHub, 2026. \[Online\]. Available: https://github.com/semantica-agi/semantica
</Tab>
</Tabs>
-3
View File
@@ -16,9 +16,6 @@ At its core, Semantica adds a **context and accountability layer** on top of you
- **Accountability Layer** — Provenance tracking, decision intelligence, conflict detection, and W3C PROV-O compliance make every claim in your AI stack auditable and explainable.
- **Extension Layer** — `PluginRegistry` and `MethodRegistry` let you replace or augment any component: ingestors, extractors, reasoning engines, backends: without changing framework code.
<Warning>
**This is system-level explainability, not foundation-model explainability.** Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. What Semantica explains is *outside* the model: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. In short, Semantica explains and audits *what the AI system did*, not the foundation model's private internal reasoning.
</Warning>
## Knowledge Graphs
-1
View File
@@ -102,7 +102,6 @@
"group": "Integrations",
"pages": [
"integrations/agno",
"integrations/crewai",
"integrations/docling",
"integrations/snowflake",
"integrations/databricks"
+1 -1
View File
@@ -162,7 +162,7 @@ semantica-explorer --graph my_graph.json --no-browser
```
<Warning>
`--host 0.0.0.0` makes Explorer reachable on every network interface. Since v0.6.5 the Explorer API requires `SEMANTICA_API_KEY` (sent as the `X-API-Key` header) and fails closed with `503` when unconfigured; unauthenticated access is only possible when `SEMANTICA_ALLOW_ANONYMOUS=true` is set explicitly. Only use this on a trusted private network.
`--host 0.0.0.0` makes Explorer reachable on every network interface. The server has no built-in authentication. Only use this on a trusted private network.
</Warning>
+1 -11
View File
@@ -17,7 +17,7 @@ icon: "circle-question"
| API key required? | Optional: pattern extraction works with no keys |
| Works with LangChain / LlamaIndex? | Yes: Semantica is a layer on top, not a replacement |
| Production-ready? | Yes: 1,000+ tests, v0.5.0 ships with 12 security fixes |
| Latest version? | **v0.6.6** (August 2026) |
| Latest version? | **v0.6.5** (August 2026) |
| Local LLMs? | Yes: Ollama via LiteLLM, HuggingFaceLLM for air-gapped |
@@ -52,16 +52,6 @@ Semantica works alongside these frameworks, not against them.
</Accordion>
<Accordion title="Does Semantica explain an LLM's internal reasoning or chain-of-thought?" icon="triangle-exclamation">
No. This is **system-level explainability, not foundation-model explainability**. Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system.
What Semantica explains is *outside* the model: what context and data were used, what decision was produced, the provenance behind it, the relevant relationships, the policies applied, and the resulting decision trail.
In short: Semantica explains and audits *what the AI system did* — not the foundation model's private internal reasoning.
</Accordion>
<Accordion title="Is Semantica free?" icon="tag">
Yes: MIT licensed, no vendor lock-in, no paywalled features. Some capabilities require third-party API keys (e.g., OpenAI embeddings, Groq inference), but Semantica itself is always free and open source.
+1 -1
View File
@@ -42,7 +42,7 @@ icon: "rocket"
Verify installation:
```python
import semantica
print(semantica.__version__) # 0.6.6
print(semantica.__version__) # 0.6.5
```
</Check>
</Step>
+1 -5
View File
@@ -192,11 +192,7 @@ decision_id = context.record_decision(
## Built for Where Mistakes Have Consequences
Semantica was designed for domains where every decision must be explainable and every fact must be traceable.
<Warning>
**This is system-level explainability, not foundation-model explainability.** Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. What Semantica explains is *outside* the model: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. See [Core Concepts](concepts) for the full scope note.
</Warning>
Semantica was designed for domains where every decision must be explainable and every fact must be traceable:
**Healthcare & Life Sciences**
- Clinical decision support with full audit trails
-147
View File
@@ -1,147 +0,0 @@
---
title: "CrewAI Integration"
description: "Give CrewAI crews a shared semantic knowledge graph, decision intelligence, and graph-based retrieval via three drop-in components."
icon: "users"
---
> Three drop-in components that bring Semantica's knowledge graph and decision intelligence into any CrewAI crew.
## Installation
```bash
pip install "semantica[crewai]"
```
Requires `crewai >= 0.80.0`. If `crewai` is not installed, the integration still imports — every class carries the full Semantica API and degrades gracefully, but cannot be passed to a `Crew`.
## Components at a Glance
- **SemanticaKGTool** — `Agent(tools=[…])`: 5 KG construction/query actions: extract entities, extract relations, add to graph, query graph, find related.
- **SemanticaDecisionTool** — `Agent(tools=[…])`: 5 decision intelligence actions: record decisions, find precedents, trace causal chains, analyze impact, check policies.
- **SemanticaKnowledgeSource** — `Crew(knowledge_sources=[…])`: Serializes a `ContextGraph` into CrewAI knowledge storage so every agent gets retrieval access to the graph.
## Component Details
<Tabs>
<Tab title="SemanticaKGTool">
Lets agents actively **build and query** a shared `ContextGraph` mid-reasoning.
```python
from crewai import Agent, Crew, Task
from semantica.context import ContextGraph
from integrations.crewai import SemanticaKGTool
graph = ContextGraph()
analyst = Agent(
role="Knowledge Analyst",
goal="Build and explore a knowledge graph from documents",
backstory="You map entities and relationships into a shared graph.",
tools=[SemanticaKGTool(graph=graph)],
)
crew = Crew(
agents=[analyst],
tasks=[Task(
description="Extract and link key entities from the brief",
expected_output="JSON",
agent=analyst,
)],
)
crew.kickoff()
```
| Tool | Description |
| :------ | :------------- |
| `extract_entities` | Extract named entities from `text` |
| `extract_relations` | Extract relationships between entities in `text` |
| `add_to_graph` | Extract entities/relations from `text` and add them to the shared graph |
| `query_graph` | Keyword-search the graph by node id, type, and content using `query` |
| `find_related` | Find concepts related to `entity` within `hops` hops |
All actions return JSON so agents get parseable results.
**Sharing a graph:** the tool reads/writes whatever `graph` you pass in. When no `graph` is given, a fresh in-memory `ContextGraph()` is created (and a warning is logged) — two tool instances that each auto-create their own graph do **not** share knowledge. Pass the same `ContextGraph` to every agent that must share state.
</Tab>
<Tab title="SemanticaDecisionTool">
Exposes Semantica's decision intelligence as a native CrewAI tool, backed by `AgentContext`.
```python
from crewai import Agent, Crew, Task
from integrations.crewai import SemanticaDecisionTool
planner = Agent(
role="Decision Planner",
goal="Make grounded, precedented decisions",
backstory="You record decisions and validate them against policy.",
tools=[SemanticaDecisionTool()],
)
crew = Crew(agents=[planner], tasks=[...])
```
When no `AgentContext` is passed, one is created in-memory with `decision_tracking=True` and its own `ContextGraph`, so decision actions work out of the box (a warning is logged — pass the same `AgentContext` to every agent that must share decision state). Missing optional fields in `record_decision` fall back to `category="general"`, `reasoning="agent decision"`, and `outcome="recorded"`. `find_precedents` returns up to `max_precedents` results. If a knowledge graph cannot trace causality, `trace_causal_chain` returns an explicit error rather than substituting similarity-based results.
| Tool | Description |
| :------ | :------------- |
| `record_decision` | Record a decision with reasoning, outcome, and confidence |
| `find_precedents` | Search for similar past decisions |
| `trace_causal_chain` | Trace the causal chain from a decision |
| `analyze_impact` | Assess downstream influence of a decision |
| `check_policy` | Validate a proposed decision against policy rules |
</Tab>
<Tab title="SemanticaKnowledgeSource">
Gives **every agent in the crew** retrieval access to a `ContextGraph`.
```python
from crewai import Agent, Crew, Task
from semantica.context import ContextGraph
from integrations.crewai import SemanticaKnowledgeSource
graph = ContextGraph()
graph.add_node(node_id="privacy", node_type="policy", content="...")
researcher = Agent(
role="Policy Researcher",
goal="Answer questions from the knowledge base",
backstory="You retrieve from graph knowledge to answer accurately.",
)
crew = Crew(
agents=[researcher],
tasks=[...],
knowledge_sources=[SemanticaKnowledgeSource(graph=graph)],
)
```
On kickoff the graph's nodes and edges are serialized, chunked, and stored through CrewAI's knowledge pipeline.
> **Embedder required:** storing chunks goes through CrewAI's knowledge pipeline, which needs an embedder to be configured. Set `Crew(embedder=...)` (or provide the default credentials CrewAI falls back to, e.g. `OPENAI_API_KEY`). If no working embedder is configured, storage fails, an ERROR is logged, and agents will retrieve **nothing** — the crew still runs, but its knowledge queries return empty.
**Compatibility:** CrewAI's `BaseKnowledgeSource` contract changed between `0.80.x` and current releases (`load_content()` → `validate_content()`/`aadd()`). `SemanticaKnowledgeSource` implements both legacy and current methods, so it works across `crewai>=0.80.0`.
</Tab>
</Tabs>
## Checkpoints & Serialization
CrewAI serializes tools and knowledge sources to JSON for checkpointing/resume. Live Semantica state (`ContextGraph`, `AgentContext`, extractors) is **excluded from that serialization** — a restored tool/source comes back with a fresh in-memory `ContextGraph` and logs a warning. Until you re-attach the live graph/context, the restored objects answer queries against an **empty** graph, so re-wire them after resuming (e.g. `restored_tool.graph = live_graph`) before agents continue.
## API Reference
```python
from integrations.crewai import (
SemanticaKGTool, # BaseTool: KG construction/query actions
SemanticaDecisionTool, # BaseTool: decision intelligence actions
SemanticaKnowledgeSource, # BaseKnowledgeSource: graph → crew knowledge
CREWAI_AVAILABLE, # bool: True if crewai is installed
)
```
All three classes are usable without `crewai` installed: they carry the full Semantica API and degrade gracefully.
## See Also
- [Context Module](../reference/context) — AgentContext and ContextGraph backing the integration.
- [Semantic Extraction](../reference/semantic_extract) — NERExtractor / RelationExtractor used by SemanticaKGTool.
- [LLMs](../reference/llms) — Configure LLM providers for your crew's agents.
- [Vector Store](../reference/vector_store) — Vector backend used by SemanticaDecisionTool.
-13
View File
@@ -203,13 +203,6 @@ export_lpg(graph, "import.cypher", method="cypher")
exporter = SemanticNetworkYAMLExporter()
exporter.export(graph, "graph.yaml")
```
The YAML exporters read `entities`/`relationships`/`triplets` (with
`nodes`/`edges` accepted as aliases, so `ContextGraph.to_dict()` exports
directly). A non-empty mapping supplying none of them raises
`ValidationError` rather than writing a file with every collection empty,
as does one whose collection value is not a list of records
(`{"entities": "abc"}`).
</Tab>
<Tab title="Graph DB Import">
**LPGExporter** writes Cypher `CREATE` statements for Neo4j and Memgraph:
@@ -243,12 +236,6 @@ export_lpg(graph, "import.cypher", method="cypher")
Both exporters write to a file and return `None`.
`LPGExporter`, `ArangoAQLExporter`, and `Neo4jCSVExporter` resolve mapping
payloads on the same terms as the YAML exporters above, so an unrecognized
or malformed mapping is rejected instead of exported as an empty graph.
`Neo4jCSVExporter` still reads graph *objects* off their
`nodes`/`entities` and `edges`/`relationships` attributes.
<Warning>
**`ArangoAQLExporter.export()` and `LPGExporter.export()` write to a file and return `None`.** They do not return the AQL/Cypher string. Write to a file and read it back if you need the string.
</Warning>
+1 -10
View File
@@ -63,9 +63,7 @@ semantica-explorer --graph my_graph.json --no-browser
python -m semantica.explorer --graph my_graph.json
```
> **Security note:** Since v0.6.5 the Explorer API requires an API key on protected routes. Set the `SEMANTICA_API_KEY` environment variable and send it as the `X-API-Key` header; without a configured key, protected routes fail closed with `503` rather than serving anonymously. To opt into unauthenticated access for local development only, set `SEMANTICA_ALLOW_ANONYMOUS=true` explicitly. (`/api/health` and `/api/info` are intentionally unauthenticated.)
>
> The default `--host 127.0.0.1` binds to localhost only, so it is not reachable from other machines on your network. If you bind to `0.0.0.0`, all graph data is readable and writable by any host that can reach the port (subject to API-key auth). The CLI prints a warning when binding to a non-loopback host in anonymous mode or when `SEMANTICA_API_KEY` is unset.
> **Security note:** The Explorer API has no built-in authentication. The default `--host 127.0.0.1` binds to localhost only, so it is not reachable from other machines on your network. If you bind to `0.0.0.0`, all graph data is readable and writable by any host that can reach the port. The CLI will print a warning in that case.
---
@@ -150,8 +148,6 @@ This writes the compiled assets to `../semantica/static/`. The Python server the
| --- | --- | --- |
| `EXPLORER_CORS_ORIGINS` | `http://localhost:5173,http://127.0.0.1:5173` | Comma-separated list of allowed CORS origins |
| `EXPLORER_CORS_CREDENTIALS` | `false` | Set to `true` to allow credentialed cross-origin requests (only needed behind an authenticating reverse proxy) |
| `SEMANTICA_API_KEY` | *(unset)* | API key required on protected routes since v0.6.5; send it as the `X-API-Key` header. When unset, protected routes fail closed with `503`. |
| `SEMANTICA_ALLOW_ANONYMOUS` | `false` | Set to `true` to opt into unauthenticated access (local development only). |
---
@@ -255,11 +251,6 @@ Vite automatically tries the next available port and prints the actual URL in th
- Confirm the backend exposes the `/ws/graph-updates` WebSocket endpoint.
- Check DevTools → Network → WS tab for the connection status and error code.
- Ensure the backend version matches the frontend — mixing major versions can cause protocol mismatches.
- **Authentication:** `/ws/graph-updates` enforces the same API key as the REST routes. Browsers cannot set custom headers on a WebSocket handshake, so pass the key as a query parameter instead:
```
ws://127.0.0.1:8000/ws/graph-updates?api_key=<your-key>
```
Non-browser clients (native apps, scripts) may send it as the `X-API-Key` header. A missing or incorrect key results in close code `4401`; if `SEMANTICA_API_KEY` is unset and `SEMANTICA_ALLOW_ANONYMOUS` is not `true`, the connection is also rejected. Note that API keys in URLs appear in server logs — prefer the header for non-browser clients.
---
+1 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint .",
"preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts",
"test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts",
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
},
"dependencies": {
@@ -162,11 +162,7 @@ const SIGMA_SETTINGS = {
hideLabelsOnMove: true,
hideEdgesOnMove: true,
enableEdgeEvents: true,
// #1009: edge labels (the edge `type` — "works_for", "leads", ...) were
// hardcoded off, so edge text never rendered regardless of data. The
// labelDensity / labelGridCellSize / labelRenderedSizeThreshold settings
// below already throttle label density for both nodes and edges.
renderEdgeLabels: true,
renderEdgeLabels: false,
labelDensity: 0.7,
labelGridCellSize: 140,
zIndex: true,
@@ -745,12 +741,6 @@ function buildEffectAvailability(
? { enabled: true, available: true, reason: "Panel enabled" }
: { enabled: false, available: false, reason: "Disabled by toggle" };
// #1009: edge labels are immediately available once the graph is loaded —
// they have no async analytics or zoom-tier dependency.
const edgeLabels = effectsState.edgeLabelsEnabled
? { enabled: true, available: true, reason: "Ready" }
: { enabled: false, available: false, reason: "Disabled by toggle" };
const diagnostics = !GRAPH_THEME.effects.diagnostics.enabledInDev
? { enabled: false, available: false, reason: "Disabled in production" }
: effectsState.diagnosticsEnabled
@@ -768,7 +758,6 @@ function buildEffectAvailability(
communities,
centrality,
legend,
edgeLabels,
diagnostics,
};
}
@@ -1222,12 +1211,6 @@ function applySceneState(
size: resolvedStyle.size,
zIndex: resolvedStyle.zIndex,
curvature: resolvedStyle.curvature,
// #1009: Sigma's edge label renderer draws data.label — the graph
// stores the relationship type in edgeType, which the renderer never
// saw, so enabling renderEdgeLabels alone left edges blank.
// Use || rather than ?? so that an empty-string edgeType (possible
// when the API returns type: "") does not produce a blank label.
label: resolvedStyle.hidden ? undefined : String(attrs.edgeType || data.label || ""),
};
});
@@ -1312,9 +1295,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const onEdgeClickRef = useRef(onEdgeClick);
const onSceneRuntimeChangeRef = useRef(onSceneRuntimeChange);
const onCameraStateChangeRef = useRef(onCameraStateChange);
// #1009: tracked as a ref so the Sigma creation effect always reads the
// current value without needing effectsState in its dependency array.
const effectsStateRef = useRef(effectsState);
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [zoomTier, setZoomTier] = useState<GraphZoomTier>("overview");
const [analyticsSnapshot, setAnalyticsSnapshot] = useState<GraphAnalyticsSnapshot | null>(null);
@@ -1343,7 +1323,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
onEdgeClickRef.current = onEdgeClick;
onSceneRuntimeChangeRef.current = onSceneRuntimeChange;
onCameraStateChangeRef.current = onCameraStateChange;
effectsStateRef.current = effectsState;
const behaviors = useMemo<GraphBehavior[]>(
() => [
@@ -1856,13 +1835,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
return;
}
const sigma = new Sigma(displayGraphRef.current, containerRef.current, {
...SIGMA_SETTINGS,
// #1009: initialize with the current toggle value rather than the
// static default so that a user who disabled Edge Labels before
// graph/Sigma initialization sees the correct state after mount.
renderEdgeLabels: effectsStateRef.current.edgeLabelsEnabled,
});
const sigma = new Sigma(displayGraphRef.current, containerRef.current, SIGMA_SETTINGS);
sigmaRef.current = sigma;
appliedGraphVersionRef.current = graphVersionRef.current;
@@ -1964,17 +1937,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
});
}, [behaviors, dispatchToBehaviors, getBehaviorContext, graphReady, syncCameraState]);
// #1009: renderEdgeLabels follows the Effects-panel toggle instead of
// staying hardcoded — dense graphs get their label-free edges back.
useEffect(() => {
const sigma = sigmaRef.current;
if (!sigma) {
return;
}
sigma.setSetting("renderEdgeLabels", effectsState.edgeLabelsEnabled);
sigma.scheduleRefresh();
}, [effectsState.edgeLabelsEnabled]);
useEffect(() => {
return () => {
const sigma = sigmaRef.current;
@@ -40,7 +40,6 @@ import {
type GraphPluginToolbarItem,
} from "./plugins";
import { explorationEffectsShouldLoad, neighborhoodPanelShouldLoad, temporalOverlayShouldLoad } from "./pluginRegistryPredicates";
import { shouldFetchTemporalBounds, shouldFetchTemporalSnapshot } from "./temporalLifecyclePredicates";
import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
import type {
@@ -148,7 +147,6 @@ const DEFAULT_EFFECTS_STATE: GraphEffectsState = {
communitiesEnabled: false,
centralityEnabled: false,
legendEnabled: false,
edgeLabelsEnabled: true,
diagnosticsEnabled: false,
lensMode: "neighborhood",
effectQuality: "bounded",
@@ -1442,18 +1440,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
applyGraphReadySummary(summary);
}, [applyGraphReadySummary, graphReady, summary]);
const canFetchTemporalBounds = shouldFetchTemporalBounds(summary);
const canFetchTemporalSnapshot = shouldFetchTemporalSnapshot({
debouncedTime,
isLoading,
summary,
});
useEffect(() => {
if (!canFetchTemporalBounds) {
return;
}
let cancelled = false;
const loadBounds = async () => {
try {
@@ -1473,21 +1460,10 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
return () => {
cancelled = true;
};
}, [
canFetchTemporalBounds,
summary?.nodeCount,
summary?.edgeCount,
]);
}, [summary?.nodeCount, summary?.edgeCount]);
useEffect(() => {
if (!canFetchTemporalSnapshot) {
return;
}
if (!debouncedTime) {
return;
}
if (!debouncedTime || isLoading) return;
let cancelled = false;
const applySnapshot = async () => {
@@ -1529,10 +1505,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
return () => {
cancelled = true;
};
}, [
canFetchTemporalSnapshot,
debouncedTime,
]);
}, [debouncedTime, isLoading]);
const resolveNodeIdForFocusedMode = useCallback((
nodeId: string,
@@ -2099,15 +2099,6 @@ function createCollapsedNeighborhoodGraph(
return collapsedGraph;
}
// Normalize an edge relationship type: empty string, null, and undefined all
// fall back to the project-wide default used consistently across every
// aggregation path. Keep this local — it exists only to guarantee that the
// three code paths (single-entry, multi-entry, community-grouped) produce the
// same semantics and do not diverge again.
function normalizeEdgeType(value: string | null | undefined): string {
return value || "related_to";
}
function aggregateDisplayGraph(graphRef: GraphRef): Graph<NodeAttributes, EdgeAttributes> {
const aggregated = new Graph<NodeAttributes, EdgeAttributes>({
type: "directed",
@@ -2133,13 +2124,10 @@ function aggregateDisplayGraph(graphRef: GraphRef): Graph<NodeAttributes, EdgeAt
const [{ edgeId, attrs }] = entries;
aggregated.mergeDirectedEdgeWithKey(edgeId, sourceId, targetId, {
...attrs,
// #1009: normalize empty/null/undefined edgeType so Sigma's label
// renderer never receives a blank string on the single-entry path.
edgeType: normalizeEdgeType(attrs.edgeType),
dominantEdgeType: normalizeEdgeType(attrs.dominantEdgeType ?? attrs.edgeType),
rawEdgeIds: collectRawEdgeIds(attrs, edgeId),
isAggregated: isAggregatedEdgeAttributes(attrs),
aggregateCount: attrs.aggregateCount ?? collectRawEdgeIds(attrs, edgeId).length,
dominantEdgeType: attrs.dominantEdgeType ?? attrs.edgeType,
representativeWeight: attrs.representativeWeight ?? Number(attrs.weight ?? 1),
});
return;
@@ -2162,11 +2150,10 @@ function aggregateDisplayGraph(graphRef: GraphRef): Graph<NodeAttributes, EdgeAt
const rawEdgeIds = entries.flatMap(({ edgeId, attrs }) => collectRawEdgeIds(attrs, edgeId));
const typeCounts = new Map<string, number>();
entries.forEach(({ attrs }) => {
const edgeType = normalizeEdgeType(attrs.edgeType);
const edgeType = String(attrs.edgeType ?? "related_to");
typeCounts.set(edgeType, (typeCounts.get(edgeType) ?? 0) + 1);
});
const dominantEdgeType = [...typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0]
?? normalizeEdgeType(representative.attrs.edgeType);
const dominantEdgeType = [...typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? representative.attrs.edgeType ?? "related_to";
const reverseKey = `${targetId}${sourceId}`;
const isBidirectionalBundle = groupedEdges.has(reverseKey);
const syntheticEdgeId = `${AGGREGATED_EDGE_PREFIX}${sourceId}::${targetId}`;
@@ -2180,10 +2167,10 @@ function aggregateDisplayGraph(graphRef: GraphRef): Graph<NodeAttributes, EdgeAt
rawEdgeIds,
isAggregated: true,
aggregateCount: rawEdgeIds.length,
dominantEdgeType: dominantEdgeType,
dominantEdgeType: String(dominantEdgeType),
representativeWeight: Number(representative.attrs.weight ?? 1),
weight: Number(representative.attrs.weight ?? 1),
edgeType: representative.attrs.edgeType || dominantEdgeType,
edgeType: String(representative.attrs.edgeType ?? dominantEdgeType ?? "related_to"),
parallelCount: rawEdgeIds.length,
familySize: rawEdgeIds.length,
bundleKind: isBidirectionalBundle ? "bidirectional" : "parallel",
@@ -2293,7 +2280,7 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
};
bucket.rawEdgeIds.push(String(edgeId));
bucket.weight = Math.max(bucket.weight, Number((attrs as EdgeAttributes).weight ?? 1));
const edgeType = normalizeEdgeType((attrs as EdgeAttributes).edgeType);
const edgeType = String((attrs as EdgeAttributes).edgeType ?? "related_to");
bucket.typeCounts.set(edgeType, (bucket.typeCounts.get(edgeType) ?? 0) + 1);
groupedEdges.set(key, bucket);
});
@@ -2409,8 +2396,7 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
if (!visibleGroupedEdgeKeys.has(key)) {
return;
}
const dominantEdgeType = [...bundle.typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0]
?? "related_to";
const dominantEdgeType = [...bundle.typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "related_to";
const reverseKey = `${bundle.targetId}${bundle.sourceId}`;
const syntheticEdgeId = `${AGGREGATED_EDGE_PREFIX}${key}`;
const aggregateCount = bundle.rawEdgeIds.length;
@@ -1,7 +1,6 @@
import type { CSSProperties } from "react";
import type {
GraphDiagnosticsSnapshot,
GraphEffectAvailability,
GraphEffectToggle,
} from "../types";
@@ -31,11 +30,6 @@ const EFFECT_ROWS: EffectRowConfig[] = [
label: "Neighborhood Lens",
description: "Local emphasis around the hovered or selected node.",
},
{
key: "edgeLabelsEnabled",
label: "Edge Labels",
description: "Draw the relationship type on graph edges. Off restores label-free edges on dense graphs.",
},
{
key: "legendEnabled",
label: "Semantic Legend",
@@ -43,17 +37,6 @@ const EFFECT_ROWS: EffectRowConfig[] = [
},
];
// Maps the effect toggle keys rendered by this plugin to their corresponding
// availability keys in GraphDiagnosticsSnapshot["effectAvailability"]. Kept
// local because this plugin only renders a subset of all effects.
const EFFECT_AVAILABILITY_KEYS: Partial<Record<GraphEffectToggle, keyof GraphDiagnosticsSnapshot["effectAvailability"]>> = {
pathPulseEnabled: "pathPulse",
pathFlowEnabled: "pathFlow",
lensEnabled: "lens",
edgeLabelsEnabled: "edgeLabels",
legendEnabled: "legend",
};
function renderAvailabilityText(availability: GraphEffectAvailability) {
if (availability.available) {
if (typeof availability.visibleSegments === "number" && typeof availability.segmentCap === "number") {
@@ -156,9 +139,15 @@ export const explorationEffectsPlugin: GraphPlugin = {
description={row.description}
checked={effectsState[row.key]}
availability={
(EFFECT_AVAILABILITY_KEYS[row.key] !== undefined
? availability?.[EFFECT_AVAILABILITY_KEYS[row.key]!]
: undefined) ?? {
availability?.[
row.key === "pathPulseEnabled"
? "pathPulse"
: row.key === "pathFlowEnabled"
? "pathFlow"
: row.key === "lensEnabled"
? "lens"
: "legend"
] ?? {
enabled: effectsState[row.key],
available: false,
reason: "Waiting for graph runtime",
@@ -47,11 +47,6 @@ const SCENE_EFFECT_ROWS: EffectRowConfig[] = [
label: "Contours",
description: "Low-contrast density halos around the strongest visible anchors.",
},
{
key: "edgeLabelsEnabled",
label: "Edge Labels",
description: "Draw the relationship type on graph edges. Off restores label-free edges on dense graphs.",
},
{
key: "legendEnabled",
label: "Regions Summary",
@@ -88,7 +83,6 @@ const AVAILABILITY_KEYS: Record<GraphEffectToggle, keyof GraphDiagnosticsSnapsho
communitiesEnabled: "communities",
centralityEnabled: "centrality",
legendEnabled: "legend",
edgeLabelsEnabled: "edgeLabels",
diagnosticsEnabled: "diagnostics",
};
@@ -1,31 +0,0 @@
import type { GraphLoadSummary } from "./types";
/**
* Predicates for gating GraphWorkspace temporal API requests.
*
* Temporal bounds and snapshot requests must strictly not execute until the
* initial graph load has succeeded (summary !== undefined). An empty graph
* (nodeCount: 0) is still a successful load and must not be rejected.
*/
export function shouldFetchTemporalBounds(
summary: GraphLoadSummary | undefined,
): boolean {
return summary !== undefined;
}
export function shouldFetchTemporalSnapshot({
debouncedTime,
isLoading,
summary,
}: {
debouncedTime: Date | null;
isLoading: boolean;
summary: GraphLoadSummary | undefined;
}): boolean {
return (
summary !== undefined &&
debouncedTime !== null &&
!isLoading
);
}
@@ -103,7 +103,6 @@ export type GraphEffectToggle =
| "communitiesEnabled"
| "centralityEnabled"
| "legendEnabled"
| "edgeLabelsEnabled"
| "diagnosticsEnabled";
export interface GraphEffectsState {
@@ -114,7 +113,6 @@ export interface GraphEffectsState {
semanticRegionsEnabled: boolean;
contoursEnabled: boolean;
pathfindingEnabled: boolean;
edgeLabelsEnabled: boolean;
communitiesEnabled: boolean;
centralityEnabled: boolean;
legendEnabled: boolean;
@@ -188,7 +186,6 @@ export interface GraphDiagnosticsSnapshot {
communities: GraphEffectAvailability;
centrality: GraphEffectAvailability;
legend: GraphEffectAvailability;
edgeLabels: GraphEffectAvailability;
diagnostics: GraphEffectAvailability;
};
}
@@ -1061,198 +1061,3 @@ test("checkGroupedViewAvailability returns available when communities exist", ()
assert.equal(result.reason, null);
});
// ── #1009: edge label data-path regression tests ─────────────────────────────
test("resolveDisplayGraph parallel-bundle preserves edgeType on aggregated edge", () => {
addNode("a");
addNode("b");
batchMergeEdges([
{ id: "e1", source: "a", target: "b", attributes: { edgeType: "causes", weight: 1, properties: {} } },
{ id: "e2", source: "a", target: "b", attributes: { edgeType: "causes", weight: 2, properties: {} } },
]);
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true });
assert.equal(displayGraph.size, 1);
const edgeId = displayGraph.edges()[0];
const attrs = displayGraph.getEdgeAttributes(edgeId) as { edgeType?: string; isAggregated?: boolean };
assert.equal(attrs.isAggregated, true);
// The aggregated representative must carry the relationship text through to
// the edgeReducer's label assignment.
assert.equal(typeof attrs.edgeType, "string");
assert.ok((attrs.edgeType ?? "").length > 0, "aggregated edge must have a non-empty edgeType");
});
test("resolveDisplayGraph parallel-bundle picks dominant edgeType across mixed types", () => {
addNode("a");
addNode("b");
batchMergeEdges([
{ id: "e1", source: "a", target: "b", attributes: { edgeType: "inhibits", weight: 1, properties: {} } },
{ id: "e2", source: "a", target: "b", attributes: { edgeType: "inhibits", weight: 1, properties: {} } },
{ id: "e3", source: "a", target: "b", attributes: { edgeType: "activates", weight: 1, properties: {} } },
]);
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true });
const edgeId = displayGraph.edges()[0];
const attrs = displayGraph.getEdgeAttributes(edgeId) as { edgeType?: string; dominantEdgeType?: string };
// "inhibits" appears twice so it must be the dominant type.
assert.equal(attrs.edgeType, "inhibits");
assert.equal(attrs.dominantEdgeType, "inhibits");
});
test("resolveDisplayGraph grouped view community edges carry non-empty edgeType", () => {
const left = ["g1", "g2", "g3", "g4"];
const right = ["h1", "h2", "h3", "h4"];
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
let edgeIndex = 0;
for (let i = 0; i < left.length; i += 1) {
for (let j = 0; j < left.length; j += 1) {
if (i !== j) {
batchMergeEdges([{
id: `lg-${edgeIndex++}`,
source: left[i],
target: left[j],
attributes: { edgeType: "co_occurs", weight: 3, properties: {} },
}]);
}
}
}
for (let i = 0; i < right.length; i += 1) {
for (let j = 0; j < right.length; j += 1) {
if (i !== j) {
batchMergeEdges([{
id: `rg-${edgeIndex++}`,
source: right[i],
target: right[j],
attributes: { edgeType: "co_occurs", weight: 3, properties: {} },
}]);
}
}
}
batchMergeEdges([{ id: "bridge-g", source: "g1", target: "h1", attributes: { edgeType: "interacts_with", weight: 0.1, properties: {} } }]);
const { graph: displayGraph, state } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
assert.equal(state.groupedViewAvailable, true);
const communityEdges = displayGraph.edges().filter((edgeId) => {
const attrs = displayGraph.getEdgeAttributes(edgeId) as { bundleKind?: string };
return attrs.bundleKind === "community";
});
assert.ok(communityEdges.length > 0, "expected at least one community bundle edge");
for (const edgeId of communityEdges) {
const attrs = displayGraph.getEdgeAttributes(edgeId) as { edgeType?: string };
assert.equal(typeof attrs.edgeType, "string");
assert.ok((attrs.edgeType ?? "").length > 0, `community edge ${edgeId} must have a non-empty edgeType`);
}
});
test("resolveDisplayGraph raw edge preserves exact edgeType string for label rendering", () => {
addNode("src");
addNode("tgt");
batchMergeEdges([{
id: "raw-1",
source: "src",
target: "tgt",
attributes: { edgeType: "works_for", weight: 1, properties: {} },
}]);
// In full view without aggregation the edge passes through unchanged.
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
assert.equal(displayGraph.size, 1);
const edgeId = displayGraph.edges()[0];
const attrs = displayGraph.getEdgeAttributes(edgeId) as { edgeType?: string };
assert.equal(attrs.edgeType, "works_for");
});
test("resolveDisplayGraph does not produce empty-string edgeType on aggregated edges when source has empty type", () => {
addNode("a");
addNode("b");
// Simulate an API response where type is empty string — the aggregation
// path must not propagate a blank label.
batchMergeEdges([
{ id: "e-empty-1", source: "a", target: "b", attributes: { edgeType: "", weight: 1, properties: {} } },
{ id: "e-empty-2", source: "a", target: "b", attributes: { edgeType: "", weight: 1, properties: {} } },
]);
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true });
const edgeId = displayGraph.edges()[0];
const attrs = displayGraph.getEdgeAttributes(edgeId) as {
edgeType?: string;
isAggregated?: boolean;
};
assert.equal(attrs.isAggregated, true);
// The aggregation falls back to "related_to" when all source edgeTypes are
// empty, so the rendered label should never be an empty string.
assert.equal(attrs.edgeType, "related_to");
});
test("resolveEdgeElementStyle hidden class produces hidden:true for suppressed edges", () => {
// Verify the data condition the edgeReducer relies on: hidden-classified
// edges must have hidden:true so that the label assignment sets undefined.
const style = resolveEdgeElementStyle(
GRAPH_THEME,
"overview",
"inactive",
{
edgeType: "causes",
weight: 1,
properties: {},
edgeVariant: "line",
visualPriority: 0.05,
baseSize: 0.3,
},
"source",
"target",
"full",
"inactive-edge",
"hidden",
);
assert.equal(style.hidden, true);
});
// ── #1009 maintainer-blocking regression: single-edge empty edgeType ─────────
test("resolveDisplayGraph single-edge normalizes empty-string edgeType to related_to", () => {
addNode("a");
addNode("b");
// One edge only — exercises the entries.length === 1 path in aggregateDisplayGraph.
batchMergeEdges([{
id: "e-single-empty",
source: "a",
target: "b",
attributes: { edgeType: "", weight: 1, properties: {} },
}]);
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true });
assert.equal(displayGraph.size, 1);
const edgeId = displayGraph.edges()[0];
const attrs = displayGraph.getEdgeAttributes(edgeId) as { edgeType?: string; dominantEdgeType?: string };
assert.equal(attrs.edgeType, "related_to",
"single-edge path must normalize empty edgeType to the canonical fallback");
assert.equal(attrs.dominantEdgeType, "related_to",
"single-edge dominantEdgeType must also be normalized");
});
test("resolveDisplayGraph single-edge preserves a valid non-empty edgeType unchanged", () => {
addNode("a");
addNode("b");
batchMergeEdges([{
id: "e-single-valid",
source: "a",
target: "b",
attributes: { edgeType: "works_for", weight: 1, properties: {} },
}]);
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true });
assert.equal(displayGraph.size, 1);
const edgeId = displayGraph.edges()[0];
const attrs = displayGraph.getEdgeAttributes(edgeId) as { edgeType?: string };
assert.equal(attrs.edgeType, "works_for",
"single-edge path must not alter a valid relationship type");
});
-114
View File
@@ -1,114 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
shouldFetchTemporalBounds,
shouldFetchTemporalSnapshot,
} from "../src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts";
import type { GraphLoadSummary } from "../src/workspaces/GraphWorkspace/types.ts";
const sampleSummary: GraphLoadSummary = {
nodeCount: 42,
edgeCount: 78,
loadTimeMs: 120,
hasCoordinates: true,
layoutSource: "provided",
layoutReady: true,
};
const emptyGraphSummary: GraphLoadSummary = {
nodeCount: 0,
edgeCount: 0,
loadTimeMs: 15,
hasCoordinates: false,
layoutSource: "runtime",
layoutReady: false,
};
// ── shouldFetchTemporalBounds ────────────────────────────────────────────────
test("temporal bounds: false when summary is undefined (initial mount or failed load)", () => {
assert.equal(
shouldFetchTemporalBounds(undefined),
false,
"bounds request must not run before graph load succeeds",
);
});
test("temporal bounds: true when non-empty summary is present", () => {
assert.equal(
shouldFetchTemporalBounds(sampleSummary),
true,
"bounds request should run when successful graph summary exists",
);
});
test("temporal bounds: true when successful summary has nodeCount of 0", () => {
assert.equal(
shouldFetchTemporalBounds(emptyGraphSummary),
true,
"an empty graph is still a successful load and must allow bounds fetching",
);
});
// ── shouldFetchTemporalSnapshot ──────────────────────────────────────────────
test("temporal snapshot: false when summary is undefined even if scrubber time is set and isLoading is false", () => {
assert.equal(
shouldFetchTemporalSnapshot({
debouncedTime: new Date("2024-01-01T00:00:00Z"),
isLoading: false,
summary: undefined,
}),
false,
"snapshot request must not run when graph load failed",
);
});
test("temporal snapshot: false when graph is currently loading", () => {
assert.equal(
shouldFetchTemporalSnapshot({
debouncedTime: new Date("2024-01-01T00:00:00Z"),
isLoading: true,
summary: sampleSummary,
}),
false,
"snapshot request must not run while graph is loading",
);
});
test("temporal snapshot: false when debouncedTime is null", () => {
assert.equal(
shouldFetchTemporalSnapshot({
debouncedTime: null,
isLoading: false,
summary: sampleSummary,
}),
false,
"snapshot request must not run without a scrubber timestamp",
);
});
test("temporal snapshot: true when summary exists, isLoading is false, and time is set", () => {
assert.equal(
shouldFetchTemporalSnapshot({
debouncedTime: new Date("2024-01-01T00:00:00Z"),
isLoading: false,
summary: sampleSummary,
}),
true,
"snapshot request should run after graph load succeeds and time is set",
);
});
test("temporal snapshot: true when successful summary has 0 nodes, isLoading is false, and time is set", () => {
assert.equal(
shouldFetchTemporalSnapshot({
debouncedTime: new Date("2024-01-01T00:00:00Z"),
isLoading: false,
summary: emptyGraphSummary,
}),
true,
"empty successful graph must allow snapshot requests once ready",
);
});
-108
View File
@@ -1,108 +0,0 @@
# Semantica × CrewAI
First-class integration between Semantica and [CrewAI](https://github.com/crewAIInc/crewAI) — give your crews a shared semantic knowledge graph, decision intelligence, and graph-based retrieval.
## Installation
```bash
pip install semantica[crewai]
```
Requires `crewai >= 0.80.0`. If `crewai` is not installed, the integration still imports (classes degrade gracefully), but you can't pass the objects to a `Crew`.
> **⚠️ Security note:** crewai hard-requires `chromadb~=1.1.0`, which is currently affected by the unpatched pre-authentication code-injection advisory **CVE-2026-45829** (no fixed release — even the latest chromadb 1.5.9 is affected). Installing `semantica[crewai]` pulls that dependency into your environment. The `crewai` extra is intentionally **not** part of `semantica[all]` for this reason — only install it where you actually use CrewAI, and follow chromadb for a patched release.
## 1. SemanticaKGTool
A `BaseTool` that lets agents **build and query** a shared `ContextGraph` mid-reasoning:
- `extract_entities` — extract named entities from `text`
- `extract_relations` — extract relationships from `text`
- `add_to_graph` — extract entities/relations from `text` and add them to the shared graph
- `query_graph` — keyword-search the graph using `query`
- `find_related` — find concepts related to `entity` within `hops`
```python
from crewai import Agent, Crew, Task
from semantica.context import ContextGraph
from integrations.crewai import SemanticaKGTool
graph = ContextGraph()
analyst = Agent(
role="Knowledge Analyst",
goal="Build and explore a knowledge graph from documents",
backstory="You map entities and relationships into a shared graph.",
tools=[SemanticaKGTool(graph=graph)],
)
crew = Crew(
agents=[analyst],
tasks=[Task(description="Extract and link key entities from the brief", expected_output="JSON", agent=analyst)],
)
result = crew.kickoff()
```
All actions return JSON, so agents get parseable results.
## 2. SemanticaDecisionTool
A `BaseTool` that wraps `AgentContext` and exposes decision intelligence:
- `record_decision` — record a decision with reasoning and outcome
- `find_precedents` — retrieve past decisions similar to a scenario
- `trace_causal_chain` — trace the causal chain from a decision
- `analyze_impact` — assess downstream influence using graph centrality
- `check_policy` — validate a proposed decision against rule-based policies
```python
from crewai import Agent, Crew, Task
from integrations.crewai import SemanticaDecisionTool
planner = Agent(
role="Decision Planner",
goal="Make grounded, precedented decisions",
backstory="You record decisions and validate them against policy.",
tools=[SemanticaDecisionTool()],
)
crew = Crew(agents=[planner], tasks=[...])
```
When no `AgentContext` is passed, one is created in-memory with `decision_tracking=True`.
## 3. SemanticaKnowledgeSource
A `BaseKnowledgeSource` that serializes the current state of a `ContextGraph` (nodes, edges, metadata) into CrewAI's knowledge storage, giving **every agent in the crew** retrieval access to the graph:
```python
from crewai import Agent, Crew, Task
from semantica.context import ContextGraph
from integrations.crewai import SemanticaKnowledgeSource
graph = ContextGraph()
graph.add_node(node_id="privacy", node_type="policy", content="...")
researcher = Agent(
role="Policy Researcher",
goal="Answer questions from the knowledge base",
backstory="You retrieve from graph knowledge to answer accurately.",
)
crew = Crew(
agents=[researcher],
tasks=[...],
knowledge_sources=[SemanticaKnowledgeSource(graph=graph)],
)
```
> **Embedder required:** storing chunks goes through CrewAI's knowledge pipeline, which needs an embedder. Set `Crew(embedder=...)` (or provide CrewAI's default credentials, e.g. `OPENAI_API_KEY`). Without a working embedder, storage fails, an ERROR is logged, and agents retrieve nothing — the crew still runs with empty knowledge queries.
### Compatibility note
CrewAI's `BaseKnowledgeSource` contract changed between `0.80.x` and current releases (`load_content()``validate_content()`/`aadd()`). `SemanticaKnowledgeSource` implements both the legacy and current methods, so it works across `crewai>=0.80.0`.
### Sharing state & checkpoints
- Each tool/source holds whatever `graph`/`context` you pass it. When omitted, a fresh in-memory object is created and a warning is logged — instances that auto-create their own state do **not** share knowledge, so pass the same object to every agent that must share.
- Live state (`ContextGraph`, `AgentContext`, extractors) is excluded from CrewAI's JSON serialization. After restoring from a checkpoint, re-attach the live graph/context to the restored objects.
-44
View File
@@ -1,44 +0,0 @@
"""
Semantica × CrewAI Integration
==============================
First-class integration between the Semantica semantic intelligence stack and
the `CrewAI <https://github.com/crewAIInc/crewAI>`_ agentic framework.
Public surface
--------------
SemanticaKGTool CrewAI ``BaseTool`` exposing KG construction/query actions
SemanticaDecisionTool CrewAI ``BaseTool`` exposing decision-intelligence actions
SemanticaKnowledgeSource CrewAI ``BaseKnowledgeSource`` giving crews graph knowledge
Quick start
-----------
pip install semantica[crewai]
>>> from integrations.crewai import (
... SemanticaKGTool,
... SemanticaDecisionTool,
... SemanticaKnowledgeSource,
... )
Compatibility
-------------
Requires ``crewai >= 0.80.0``. All three classes degrade gracefully when
``crewai`` is not installed they are still importable and carry the full
Semantica API, but cannot be passed to ``Crew`` / ``Agent`` constructors.
"""
from ._availability import CREWAI_AVAILABLE, CREWAI_IMPORT_ERROR
from .decision_tool import SemanticaDecisionTool
from .kg_tool import SemanticaKGTool
from .knowledge_source import SemanticaKnowledgeSource
__all__ = [
"SemanticaKGTool",
"SemanticaDecisionTool",
"SemanticaKnowledgeSource",
"CREWAI_AVAILABLE",
"CREWAI_IMPORT_ERROR",
]
__version__ = "0.1.0"
-24
View File
@@ -1,24 +0,0 @@
"""
Shared CrewAI availability probe.
Every integration module needs to know whether the real ``crewai`` package is
installed. Probing once here (instead of once per module) guarantees the
exported ``CREWAI_AVAILABLE`` flag means the *whole* integration is ready a
caller gating on it will never see tools using CrewAI while a knowledge source
silently degrades (or vice versa).
"""
from typing import Optional
CREWAI_AVAILABLE = False
CREWAI_IMPORT_ERROR: Optional[str] = None
try:
from crewai.knowledge.source.base_knowledge_source import ( # noqa: F401
BaseKnowledgeSource,
)
from crewai.tools import BaseTool # noqa: F401
CREWAI_AVAILABLE = True
except ImportError as exc:
CREWAI_IMPORT_ERROR = str(exc)
-555
View File
@@ -1,555 +0,0 @@
"""
SemanticaDecisionTool a CrewAI ``BaseTool`` exposing Semantica's decision
intelligence (``AgentContext``) to agents.
Lets agents record decisions with reasoning, retrieve past precedents, trace
causal chains, analyse downstream impact, and validate proposed decisions
against policy rules.
Install
-------
pip install semantica[crewai]
Example
-------
>>> from integrations.crewai import SemanticaDecisionTool
>>> from crewai import Agent, Crew, Task
>>> tool = SemanticaDecisionTool()
>>> crew = Crew(
... agents=[Agent(role="...", goal="...", backstory="...", tools=[tool])],
... tasks=[...],
... )
Tools exposed
-------------
record_decision Record a decision with reasoning and outcome
find_precedents Search past decisions similar to a scenario
trace_causal_chain Trace the causal chain from a decision node
analyze_impact Assess downstream influence of a decision
check_policy Validate a proposed decision against policy rules
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Literal, Optional, Type
from pydantic import BaseModel, Field
from semantica.utils.logging import get_logger
from ._availability import CREWAI_AVAILABLE
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Optional: CrewAI BaseTool base class
# ---------------------------------------------------------------------------
_BaseTool: Any = object
if CREWAI_AVAILABLE:
from crewai.tools import BaseTool as _BaseTool # type: ignore
# ---------------------------------------------------------------------------
# Input schema
# ---------------------------------------------------------------------------
class SemanticaDecisionToolInput(BaseModel):
"""
Input schema for ``SemanticaDecisionTool``.
Exactly one action is dispatched per call; the remaining fields are only
used by the actions that need them.
"""
action: Literal[
"record_decision",
"find_precedents",
"trace_causal_chain",
"analyze_impact",
"check_policy",
] = Field(
...,
description=(
"Which decision-intelligence operation to run. One of: "
"'record_decision', 'find_precedents', 'trace_causal_chain', "
"'analyze_impact', 'check_policy'."
),
)
category: Optional[str] = Field(
None,
description="Domain category, e.g. 'loan_approval'. Used by 'record_decision'.",
)
scenario: Optional[str] = Field(
None,
description=(
"Short description of the situation. Used by 'record_decision' and "
"'find_precedents'."
),
)
reasoning: Optional[str] = Field(
None, description="Why this outcome was chosen. Used by 'record_decision'."
)
outcome: Optional[str] = Field(
None, description="The decision result. Used by 'record_decision'."
)
confidence: float = Field(
0.8,
ge=0.0,
le=1.0,
description="Confidence score in [0, 1]. Used by 'record_decision'.",
)
entities: Optional[str] = Field(
None,
description="Comma-separated entity names. Used by 'record_decision'.",
)
decision_id: Optional[str] = Field(
None,
description=(
"Identifier of a decision. Used by 'trace_causal_chain' and "
"'analyze_impact'."
),
)
depth: int = Field(
3,
ge=1,
le=20,
description="Maximum chain depth. Used by 'trace_causal_chain'.",
)
decision_data: Optional[str] = Field(
None,
description=(
"JSON object describing a proposed decision. Used by 'check_policy'."
),
)
policy_rules: Optional[str] = Field(
None,
description=(
"JSON list of rule strings like 'confidence >= 0.7'. Used by "
"'check_policy'."
),
)
# ---------------------------------------------------------------------------
# SemanticaDecisionTool
# ---------------------------------------------------------------------------
class SemanticaDecisionTool(_BaseTool): # type: ignore[misc]
"""
CrewAI tool that surfaces Semantica's decision intelligence as agent actions.
Parameters
----------
context:
A ``semantica.context.AgentContext`` (or compatible object exposing
``record_decision``, ``find_precedents_advanced``,
``analyze_decision_influence``). A fresh in-memory context is created
when ``None``.
max_precedents:
Default number of precedents returned by ``find_precedents``.
causal_depth:
Default chain depth used by ``trace_causal_chain``.
"""
name: str = "semantica_decision"
description: str = (
"Decision intelligence toolkit. Actions: 'record_decision' (record a "
"decision with category, scenario, reasoning, outcome, confidence), "
"'find_precedents' (search past decisions similar to 'scenario'), "
"'trace_causal_chain' (trace the causal chain from 'decision_id'), "
"'analyze_impact' (assess downstream influence of 'decision_id'), "
"'check_policy' (validate 'decision_data' JSON against 'policy_rules' "
"rules like 'confidence >= 0.7'). Returns JSON."
)
args_schema: Type[BaseModel] = SemanticaDecisionToolInput
context: Any = Field(default=None, exclude=True)
max_precedents: int = 5
causal_depth: int = 3
had_live_state: bool = False
reconstructed_state: bool = Field(default=False, exclude=True)
def __init__(
self,
context: Any = None,
max_precedents: int = 5,
causal_depth: int = 3,
**kwargs: Any,
) -> None:
if CREWAI_AVAILABLE:
super().__init__(
context=context,
max_precedents=max_precedents,
causal_depth=causal_depth,
**kwargs,
)
else:
super().__init__()
self.context = context
self.max_precedents = max_precedents
self.causal_depth = causal_depth
# Degraded mode is a plain class — no model_post_init lifecycle.
self._ensure_defaults()
logger.info("SemanticaDecisionTool initialised (crewai=%s)", CREWAI_AVAILABLE)
def model_post_init(self, __context: Any) -> None:
"""Re-create default state after validation/deserialisation.
``context`` is excluded from JSON serialisation (CrewAI checkpoints
serialise every tool via ``model_dump(mode="json")``), so a tool
restored from a checkpoint has ``None`` state until this runs.
"""
self._ensure_defaults()
super().model_post_init(__context)
def _ensure_defaults(self) -> None:
"""Lazy-import and build a real AgentContext when none is wired."""
if self.context is None:
from semantica.context import AgentContext, ContextGraph
from semantica.vector_store import VectorStore
self.context = AgentContext(
vector_store=VectorStore(backend="faiss"),
decision_tracking=True,
knowledge_graph=ContextGraph(),
)
if self.had_live_state:
self.reconstructed_state = True
logger.warning(
"SemanticaDecisionTool: the live decision context was lost "
"during serialization/checkpoint restore — an EMPTY "
"context was reconstructed; re-attach the original context "
"before continuing"
)
else:
logger.warning(
"SemanticaDecisionTool created a fresh in-memory "
"AgentContext — agents sharing decision state must be "
"wired to the same context"
)
self.had_live_state = True
# ------------------------------------------------------------------
# CrewAI entry points
# ------------------------------------------------------------------
def _run(
self,
action: str,
category: Optional[str] = None,
scenario: Optional[str] = None,
reasoning: Optional[str] = None,
outcome: Optional[str] = None,
confidence: float = 0.8,
entities: Optional[str] = None,
decision_id: Optional[str] = None,
depth: int = 3,
decision_data: Optional[str] = None,
policy_rules: Optional[str] = None,
**kwargs: Any,
) -> str:
valid = {
"record_decision",
"find_precedents",
"trace_causal_chain",
"analyze_impact",
"check_policy",
}
if action not in valid:
return json.dumps(
{
"error": f"Unknown action '{action}'. Valid actions: "
+ ", ".join(sorted(valid))
}
)
if action == "record_decision":
return self._record_decision(
category=category or "general",
scenario=scenario or "decision recorded",
reasoning=reasoning or "agent decision",
outcome=outcome or "recorded",
confidence=confidence,
entities=entities,
)
if action == "find_precedents":
return self._find_precedents(scenario=scenario or "", category=category)
if action == "trace_causal_chain":
return self._trace_causal_chain(decision_id or "", depth=depth)
if action == "analyze_impact":
return self._analyze_impact(decision_id or "")
return self._check_policy(decision_data or "", policy_rules)
async def _arun(self, action: str, **kwargs: Any) -> str:
"""Async variant of ``_run`` for CrewAI's async tool path."""
return self._run(action=action, **kwargs)
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def _record_decision(
self,
category: str,
scenario: str,
reasoning: str,
outcome: str,
confidence: float = 0.8,
entities: Optional[str] = None,
) -> str:
entity_list: Optional[List[str]] = None
if entities:
entity_list = [e.strip() for e in entities.split(",") if e.strip()]
try:
decision_id = self.context.record_decision(
category=category,
scenario=scenario,
reasoning=reasoning,
outcome=outcome,
confidence=float(confidence),
entities=entity_list,
)
result = {"decision_id": str(decision_id), "status": "recorded"}
logger.info("record_decision → %s", decision_id)
except Exception as exc:
result = {"error": str(exc), "status": "failed"}
logger.warning("record_decision failed: %s", exc)
return json.dumps(result)
def _find_precedents(
self,
scenario: str,
category: Optional[str] = None,
limit: Optional[int] = None,
) -> str:
k = limit if limit is not None else self.max_precedents
try:
precedents = self.context.find_precedents_advanced(
scenario=scenario,
category=category,
limit=k,
)
out: List[Dict[str, Any]] = []
for p in (precedents or [])[:k]:
if isinstance(p, dict):
out.append(p)
else:
out.append(
{
"scenario": getattr(p, "scenario", str(p)),
"outcome": getattr(p, "outcome", ""),
"confidence": getattr(p, "confidence", 0.0),
"category": getattr(p, "category", ""),
}
)
logger.info("find_precedents('%s') → %d results", scenario, len(out))
return json.dumps({"precedents": out, "count": len(out)})
except Exception as exc:
logger.warning("find_precedents failed: %s", exc)
return json.dumps({"precedents": [], "count": 0, "error": str(exc)})
def _trace_causal_chain(self, decision_id: str, depth: Optional[int] = None) -> str:
if not decision_id:
return json.dumps(
{
"error": "decision_id is required for trace_causal_chain",
"causal_chain": [],
"decision_id": "",
}
)
max_depth = depth or self.causal_depth
try:
graph = getattr(self.context, "knowledge_graph", None)
if graph is None:
return json.dumps(
{
"error": (
"causal tracing is not available on this knowledge "
"graph (the decision context has no knowledge_graph)"
),
"causal_chain": [],
"decision_id": decision_id,
}
)
trace = getattr(graph, "trace_decision_causality", None)
if trace is None:
return json.dumps(
{
"error": (
"causal tracing is not available on this knowledge graph "
"(graph.trace_decision_causality is not implemented)"
),
"causal_chain": [],
"decision_id": decision_id,
}
)
chain = trace(decision_id, max_depth=max_depth)
return json.dumps({"causal_chain": chain, "decision_id": decision_id})
except Exception as exc:
logger.warning("trace_causal_chain failed: %s", exc)
return json.dumps(
{"error": str(exc), "causal_chain": [], "decision_id": decision_id}
)
def _analyze_impact(self, decision_id: str) -> str:
try:
influence = self.context.analyze_decision_influence(decision_id)
if not isinstance(influence, dict):
influence = {"influence": str(influence)}
influence["decision_id"] = decision_id
return json.dumps(influence)
except Exception as exc:
logger.warning("analyze_impact failed: %s", exc)
return json.dumps({"error": str(exc), "decision_id": decision_id})
def _check_policy(
self,
decision_data: str,
policy_rules: Optional[str] = None,
) -> str:
try:
data = (
json.loads(decision_data)
if isinstance(decision_data, str)
else decision_data
)
except json.JSONDecodeError as exc:
return json.dumps(
{
"compliant": False,
"violations": [f"Invalid decision_data JSON: {exc}"],
"warnings": [],
}
)
if not isinstance(data, dict):
return json.dumps(
{
"compliant": False,
"violations": [
f"decision_data must decode to a JSON object, "
f"got {type(data).__name__}: {data!r}"
],
"warnings": [],
}
)
violations: List[str] = []
warnings: List[str] = []
rules: List[str] = []
if policy_rules:
try:
parsed_rules = json.loads(policy_rules)
except json.JSONDecodeError:
rules = [r.strip() for r in policy_rules.split(",") if r.strip()]
else:
if isinstance(parsed_rules, str):
rules = [parsed_rules]
elif isinstance(parsed_rules, list):
for item in parsed_rules:
if isinstance(item, str):
rules.append(item)
else:
warnings.append(
f"Ignoring non-string policy rule entry: {item!r}"
)
else:
warnings.append(
f"policy_rules must decode to a JSON list of rule strings, "
f"got {type(parsed_rules).__name__}: {parsed_rules!r}"
)
for rule in rules:
try:
if not self._eval_rule(rule, data):
violations.append(f"Rule violated: {rule}")
except Exception as exc:
warnings.append(f"Could not evaluate rule '{rule}': {exc}")
compliant = len(violations) == 0
logger.debug(
"check_policy: compliant=%s, violations=%d", compliant, len(violations)
)
return json.dumps(
{
"compliant": compliant,
"violations": violations,
"warnings": warnings,
}
)
def _eval_rule(self, rule: str, data: Dict[str, Any]) -> bool:
"""Evaluate a simple comparison rule (``field op value``) against data.
This is a small standalone evaluator for the tool's ``check_policy``
action it is intentionally independent of Semantica's policy engine
so agents get a bounded, side-effect-free rule check. Rules are
``<field> <op> <value>`` comparisons only; there is no expression
evaluation (no ``eval``), so untrusted rule strings are safe to pass.
Values are coerced type-aware: ``true``/``false`` (and ``1``/``0``)
become booleans, numeric literals become numbers, and string values
that parse as numbers are compared numerically, so ``score == 0.9``
holds for ``score: "0.90"`` and ``enabled == false`` holds for
``enabled: false``. Field names may contain hyphens, dots and spaces
(e.g. ``risk-score >= 0.9``); they are matched against ``data`` keys
as-is.
"""
m = re.match(r"(.+?)\s*(>=|<=|!=|==|>|<)\s*(.+)$", rule.strip())
if not m:
raise ValueError(f"unrecognised rule format: {rule!r}")
field, op, val_str = m.group(1), m.group(2), m.group(3).strip().strip("\"'")
if field not in data:
raise ValueError(f"rule references undefined field {field!r}")
actual = data[field]
if actual is None:
raise ValueError(f"field {field!r} is null — cannot evaluate rule")
val = self._coerce_value(val_str)
if isinstance(actual, str):
actual = self._coerce_value(actual)
ops = {
">=": lambda a, b: a >= b,
"<=": lambda a, b: a <= b,
"!=": lambda a, b: a != b,
"==": lambda a, b: a == b,
">": lambda a, b: a > b,
"<": lambda a, b: a < b,
}
return ops[op](actual, val)
@staticmethod
def _coerce_value(value: str) -> Any:
"""Parse a rule literal into its most specific Python type."""
text = value.strip()
lowered = text.lower()
if lowered in ("true", "1"):
return True
if lowered in ("false", "0"):
return False
try:
return int(text)
except ValueError:
pass
try:
return float(text)
except ValueError:
pass
return text
# When crewai is absent there is no BaseTool to provide the public
# ``run``/``arun`` entry points, so expose them directly. With crewai
# installed these are left untouched so crewai's own implementations
# (usage tracking, ``result_as_answer``) win.
if not CREWAI_AVAILABLE:
def run(self, *args: Any, **kwargs: Any) -> str:
"""Run the tool synchronously (degraded mode, no crewai)."""
return self._run(*args, **kwargs)
async def arun(self, *args: Any, **kwargs: Any) -> str:
"""Run the tool asynchronously (degraded mode, no crewai)."""
return self._run(*args, **kwargs)
-573
View File
@@ -1,573 +0,0 @@
"""
SemanticaKGTool a CrewAI ``BaseTool`` exposing Semantica's knowledge-graph
pipeline (``NERExtractor``, ``RelationExtractor``, ``ContextGraph``) to agents.
Lets agents build and query a shared ``ContextGraph`` as part of their
reasoning loop.
Install
-------
pip install semantica[crewai]
Example
-------
>>> from integrations.crewai import SemanticaKGTool
>>> from semantica.context import ContextGraph
>>> from crewai import Agent, Crew, Task
>>> graph = ContextGraph()
>>> tool = SemanticaKGTool(graph=graph)
>>> crew = Crew(
... agents=[Agent(role="...", goal="...", backstory="...", tools=[tool])],
... tasks=[...],
... )
Tools exposed
-------------
extract_entities Extract named entities from text
extract_relations Extract relationships between entities
add_to_graph Extract entities/relations from text and add them to the graph
query_graph Query the graph by keyword
find_related Find concepts related to a given entity within ``hops``
"""
from __future__ import annotations
import json
import threading
import weakref
from typing import Any, Dict, List, Literal, Optional, Sequence, Type
from pydantic import BaseModel, Field
from semantica.utils.logging import get_logger
from ._availability import CREWAI_AVAILABLE, CREWAI_IMPORT_ERROR # noqa: F401
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Optional: CrewAI BaseTool base class
# ---------------------------------------------------------------------------
_BaseTool: Any = object
if CREWAI_AVAILABLE:
from crewai.tools import BaseTool as _BaseTool # type: ignore
# One re-entrant lock per graph so concurrent tool invocations sharing a graph
# cannot double-count duplicate adds (check-then-act is not atomic), while
# independent graphs are never serialised against each other. An RLock also
# means an extractor callback that re-enters add_to_graph on the same graph
# cannot deadlock.
_graph_locks_guard = threading.Lock()
_graph_locks: "weakref.WeakKeyDictionary[Any, threading.RLock]" = (
weakref.WeakKeyDictionary()
)
# ---------------------------------------------------------------------------
# Input schema
# ---------------------------------------------------------------------------
class SemanticaKGToolInput(BaseModel):
"""
Input schema for ``SemanticaKGTool``.
Exactly one action is dispatched per call; the remaining fields are only
used by the actions that need them.
"""
action: Literal[
"extract_entities",
"extract_relations",
"add_to_graph",
"query_graph",
"find_related",
] = Field(
...,
description=(
"Which graph operation to run. One of: 'extract_entities', "
"'extract_relations', 'add_to_graph', 'query_graph', 'find_related'."
),
)
text: Optional[str] = Field(
None,
description=(
"Input text. Used by 'extract_entities', 'extract_relations' and "
"'add_to_graph'."
),
)
query: Optional[str] = Field(
None, description="Search query. Used by 'query_graph'."
)
entity: Optional[str] = Field(
None,
description="Root entity name. Used by 'find_related'.",
)
hops: int = Field(
1,
ge=1,
le=10,
description="Maximum relationship hops. Used by 'find_related'.",
)
# ---------------------------------------------------------------------------
# SemanticaKGTool
# ---------------------------------------------------------------------------
class SemanticaKGTool(_BaseTool): # type: ignore[misc]
"""
CrewAI tool that surfaces Semantica's KG pipeline as agent actions.
Parameters
----------
graph:
A ``semantica.context.ContextGraph`` to read/write. A fresh in-memory
graph is used when ``None``.
ner_extractor:
A ``semantica.semantic_extract.NERExtractor`` instance; auto-created
when ``None``.
relation_extractor:
A ``semantica.semantic_extract.RelationExtractor`` instance; auto-
created when ``None``.
"""
name: str = "semantica_knowledge_graph"
description: str = (
"Build and query a semantic knowledge graph. Actions: "
"'extract_entities' (extract named entities from 'text'), "
"'extract_relations' (extract relationships from 'text'), "
"'add_to_graph' (extract entities/relations from 'text' and add them "
"to the shared graph), 'query_graph' (keyword search using 'query'), "
"'find_related' (find concepts related to 'entity' within 'hops' "
"hops). Returns JSON."
)
args_schema: Type[BaseModel] = SemanticaKGToolInput
graph: Any = Field(default=None, exclude=True)
ner_extractor: Any = Field(default=None, exclude=True)
relation_extractor: Any = Field(default=None, exclude=True)
had_live_state: bool = False
reconstructed_state: bool = Field(default=False, exclude=True)
def __init__(
self,
graph: Any = None,
ner_extractor: Any = None,
relation_extractor: Any = None,
**kwargs: Any,
) -> None:
if CREWAI_AVAILABLE:
super().__init__(
graph=graph,
ner_extractor=ner_extractor,
relation_extractor=relation_extractor,
**kwargs,
)
else:
super().__init__()
self.graph = graph
self.ner_extractor = ner_extractor
self.relation_extractor = relation_extractor
# Degraded mode is a plain class — no model_post_init lifecycle.
self._ensure_defaults()
logger.info("SemanticaKGTool initialised (crewai=%s)", CREWAI_AVAILABLE)
def model_post_init(self, __context: Any) -> None:
"""Re-create default state after validation/deserialisation.
``graph``/extractors are excluded from JSON serialisation (CrewAI
checkpoints serialise every tool via ``model_dump(mode="json")``), so a
tool restored from a checkpoint has ``None`` state until this runs.
"""
self._ensure_defaults()
super().model_post_init(__context)
def _ensure_defaults(self) -> None:
"""Lazy-import and build defaults for any missing shared state."""
# Lazy imports keep the module importable without heavy deps
if self.graph is None:
from semantica.context import ContextGraph
self.graph = ContextGraph()
if self.had_live_state:
self.reconstructed_state = True
logger.warning(
"SemanticaKGTool: the live graph was lost during "
"serialization/checkpoint restore — an EMPTY graph was "
"reconstructed; re-attach the original graph before "
"continuing"
)
else:
logger.warning(
"SemanticaKGTool created a fresh in-memory ContextGraph — "
"agents sharing this tool's graph must be wired explicitly"
)
self.had_live_state = True
if self.ner_extractor is None:
from semantica.semantic_extract import NERExtractor
self.ner_extractor = NERExtractor()
if self.relation_extractor is None:
from semantica.semantic_extract import RelationExtractor
self.relation_extractor = RelationExtractor()
# ------------------------------------------------------------------
# CrewAI entry points
# ------------------------------------------------------------------
def _run(
self,
action: str,
text: Optional[str] = None,
query: Optional[str] = None,
entity: Optional[str] = None,
hops: int = 1,
**kwargs: Any,
) -> str:
"""
Dispatch a graph action. Always returns a JSON string so the agent
receives a structured, parseable result.
"""
valid = {
"extract_entities",
"extract_relations",
"add_to_graph",
"query_graph",
"find_related",
}
if action not in valid:
return json.dumps(
{
"error": f"Unknown action '{action}'. Valid actions: "
+ ", ".join(sorted(valid))
}
)
if action == "extract_entities":
return self._extract_entities(text or "")
if action == "extract_relations":
return self._extract_relations(text or "")
if action == "add_to_graph":
return self._add_from_text(text or "")
if action == "query_graph":
return self._query_graph(query or "")
return self._find_related(entity or "", hops=hops)
async def _arun(
self,
action: str,
text: Optional[str] = None,
query: Optional[str] = None,
entity: Optional[str] = None,
hops: int = 1,
**kwargs: Any,
) -> str:
"""
Async variant of ``_run`` for CrewAI's async tool path.
"""
return self._run(
action=action, text=text, query=query, entity=entity, hops=hops, **kwargs
)
# ------------------------------------------------------------------
# Entity/relation field access (handles both Semantica dataclasses and
# third-party shapes like MagicMock/plain dicts in stubs)
# ------------------------------------------------------------------
@staticmethod
def _first_str(obj: Any, attrs: Sequence[str]) -> str:
"""Return the first attribute value that is a non-empty string."""
for attr in attrs:
value = getattr(obj, attr, None)
if isinstance(value, str) and value:
return value
if isinstance(obj, dict):
for key in attrs:
value = obj.get(key)
if isinstance(value, str) and value:
return value
return ""
@classmethod
def _entity_name(cls, e: Any) -> str:
"""Best-effort name for an entity-like object."""
return cls._first_str(e, ("name", "text", "label", "node_id", "id"))
@classmethod
def _entity_type(cls, e: Any) -> str:
"""Best-effort type/label for an entity-like object."""
return cls._first_str(e, ("type", "label")) or "Entity"
@classmethod
def _relation_source(cls, r: Any) -> str:
"""Best-effort source of a relation-like object."""
src = cls._first_str(r, ("source",))
if not src:
src = cls._entity_name(getattr(r, "subject", None))
return src
@classmethod
def _relation_target(cls, r: Any) -> str:
"""Best-effort target of a relation-like object."""
tgt = cls._first_str(r, ("target",))
if not tgt:
tgt = cls._entity_name(getattr(r, "object", None))
return tgt
@classmethod
def _relation_type(cls, r: Any) -> str:
"""Best-effort relation type of a relation-like object."""
rtype = cls._first_str(r, ("type", "relation", "predicate"))
return rtype or "related_to"
@classmethod
def _confidence(cls, e: Any) -> float:
"""Normalise an entity/relation confidence value to a float."""
try:
val = getattr(e, "confidence", None)
if val is None:
return 1.0
return round(float(val), 4)
except (TypeError, ValueError):
return 1.0
@classmethod
def _graph_lock(cls, graph: Any) -> threading.RLock:
"""Return the re-entrant lock guarding a specific graph."""
with _graph_locks_guard:
lock = _graph_locks.get(graph)
if lock is None:
lock = threading.RLock()
_graph_locks[graph] = lock
return lock
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def _extract_entities(self, text: str) -> str:
"""Extract named entities from ``text``."""
try:
raw = self.ner_extractor.extract_entities(text) or []
entities = [
{
"name": self._entity_name(e),
"type": self._entity_type(e),
"confidence": self._confidence(e),
}
for e in raw
if self._entity_name(e)
]
logger.debug("extract_entities → %d entities", len(entities))
return json.dumps({"entities": entities, "count": len(entities)})
except Exception as exc:
logger.warning("extract_entities failed: %s", exc)
return json.dumps({"entities": [], "count": 0, "error": str(exc)})
def _extract_relations(self, text: str) -> str:
"""Extract relationships between entities in ``text``."""
try:
raw = self.relation_extractor.extract_relations(text) or []
relations = [
{
"source": self._relation_source(r),
"relation": self._relation_type(r),
"target": self._relation_target(r),
"confidence": self._confidence(r),
}
for r in raw
]
logger.debug("extract_relations → %d relations", len(relations))
return json.dumps({"relations": relations, "count": len(relations)})
except Exception as exc:
logger.warning("extract_relations failed: %s", exc)
return json.dumps({"relations": [], "count": 0, "error": str(exc)})
def _add_from_text(self, text: str) -> str:
"""
Extract entities and relations from ``text`` and add them to the graph.
Duplicate nodes/edges (same id, or same source/type/target) are
skipped so repeated calls are idempotent. Returns JSON with the
number of nodes/edges added.
"""
nodes_added = 0
edges_added = 0
try:
with self._graph_lock(self.graph):
existing_nodes = {
n.get("id") or n.get("node_id")
for n in (
self.graph.find_nodes() or [] # type: ignore[attr-defined]
)
if n.get("id") or n.get("node_id")
}
existing_edges = {
(e.get("source"), e.get("type") or "related_to", e.get("target"))
for e in (
self.graph.find_edges() or [] # type: ignore[attr-defined]
)
if e.get("source") and e.get("target")
}
raw_entities = self.ner_extractor.extract_entities(text) or []
entities: List[Any] = []
seen: set = set()
for e in raw_entities:
name = self._entity_name(e)
ntype = self._entity_type(e)
if not name or name in seen:
continue
seen.add(name)
entities.append(e)
if name in existing_nodes:
continue
try:
if self.graph.add_node(node_id=name, node_type=ntype):
nodes_added += 1
existing_nodes.add(name)
except Exception as exc:
logger.debug("add_node(%r) failed: %s", name, exc)
raw_relations = (
self.relation_extractor.extract_relations(text, entities=entities)
or []
)
for r in raw_relations:
src = self._relation_source(r)
tgt = self._relation_target(r)
rtype = self._relation_type(r)
if not src or not tgt:
continue
key = (src, rtype, tgt)
if key in existing_edges:
continue
try:
if self.graph.add_edge(
source_id=src, target_id=tgt, edge_type=rtype
):
edges_added += 1
existing_edges.add(key)
except Exception as exc:
logger.debug("add_edge(%r) failed: %s", key, exc)
logger.debug("add_to_graph: +%d nodes, +%d edges", nodes_added, edges_added)
return json.dumps({"nodes_added": nodes_added, "edges_added": edges_added})
except Exception as exc:
logger.warning("add_to_graph failed: %s", exc)
return json.dumps({"nodes_added": 0, "edges_added": 0, "error": str(exc)})
def _query_graph(self, query: str) -> str:
"""Keyword-search graph nodes by id, type and content."""
try:
q = (query or "").strip().lower()
out: List[dict] = []
seen: set = set()
query_method = getattr(self.graph, "query", None)
if query_method is not None:
for match in query_method(query) or []:
node = match.get("node") or {}
nid = node.get("id", "") or node.get("node_id", "")
if not nid or nid in seen:
continue
seen.add(nid)
content = match.get("content") or node.get("content", "")
out.append(
{
"id": nid,
"type": node.get("type", "") or node.get("node_type", ""),
"label": nid,
"content": str(content)[:500],
"score": round(float(match.get("score") or 0.0), 4),
}
)
if q:
for n in self.graph.find_nodes() or []: # type: ignore[attr-defined]
if isinstance(n, dict):
nid = n.get("id", "") or n.get("node_id", "")
ntype = n.get("type", "") or n.get("node_type", "")
content = str(
n.get("content")
or (n.get("properties") or {}).get("content", "")
or ""
)
else:
nid = getattr(n, "id", getattr(n, "label", ""))
ntype = getattr(n, "node_type", "")
content = str(getattr(n, "content", "") or "")
if not nid or nid in seen:
continue
if q in str(nid).lower() or q in str(ntype).lower():
seen.add(nid)
out.append(
{
"id": nid,
"type": ntype,
"label": nid,
"content": content[:500],
"score": 1.0,
}
)
return json.dumps({"results": out, "count": len(out)})
except Exception as exc:
logger.warning("query_graph failed: %s", exc)
return json.dumps({"results": [], "count": 0, "error": str(exc)})
def _find_related(self, entity: str, hops: int = 1) -> str:
"""Find concepts related to ``entity`` within ``hops`` graph hops.
Traversal is undirected an edge counts as related regardless of
direction, so both outgoing and incoming edges are honored.
"""
try:
adjacency: Dict[str, List[str]] = {}
for edge in self.graph.find_edges() or []: # type: ignore[attr-defined]
if isinstance(edge, dict):
src = edge.get("source")
tgt = edge.get("target")
else:
src = getattr(edge, "source", None)
tgt = getattr(edge, "target", None)
if not src or not tgt:
continue
adjacency.setdefault(src, []).append(tgt)
adjacency.setdefault(tgt, []).append(src)
related: List[str] = []
frontier = [entity]
visited = {entity}
for _ in range(max(1, hops)):
next_frontier: List[str] = []
for e in frontier:
for n in adjacency.get(e, []):
if n in visited:
continue
visited.add(n)
next_frontier.append(n)
related.append(n)
frontier = next_frontier
logger.debug("find_related('%s', hops=%d) → %d", entity, hops, len(related))
return json.dumps(
{"entity": entity, "related": related, "count": len(related)}
)
except Exception as exc:
logger.warning("find_related failed: %s", exc)
return json.dumps(
{"entity": entity, "related": [], "count": 0, "error": str(exc)}
)
# When crewai is absent there is no BaseTool to provide the public
# ``run``/``arun`` entry points, so expose them directly. With crewai
# installed these are left untouched so crewai's own implementations
# (usage tracking, ``result_as_answer``) win.
if not CREWAI_AVAILABLE:
def run(self, *args: Any, **kwargs: Any) -> str:
"""Run the tool synchronously (degraded mode, no crewai)."""
return self._run(*args, **kwargs)
async def arun(self, *args: Any, **kwargs: Any) -> str:
"""Run the tool asynchronously (degraded mode, no crewai)."""
return self._run(*args, **kwargs)
-331
View File
@@ -1,331 +0,0 @@
"""
SemanticaKnowledgeSource expose a Semantica ``ContextGraph`` as a CrewAI
knowledge source.
Lets a ``Crew`` load the current state of a knowledge graph (nodes, edges,
metadata) into its knowledge storage, so every agent gets retrieval access to
graph knowledge during the kickoff.
Install
-------
pip install semantica[crewai]
Example
-------
>>> from integrations.crewai import SemanticaKnowledgeSource
>>> from semantica.context import ContextGraph
>>> from crewai import Agent, Crew, Task
>>> graph = ContextGraph()
>>> graph.add_node(node_id="privacy", node_type="policy")
>>> crew = Crew(
... agents=[...],
... tasks=[...],
... knowledge_sources=[SemanticaKnowledgeSource(graph=graph)],
... )
Compatibility
-------------
Works with ``crewai >= 0.80.0``. The ``BaseKnowledgeSource`` contract changed
between versions (``load_content`` ``validate_content``/``aadd``), so this
source implements both legacy and current methods. It degrades gracefully
when ``crewai`` is not installed: the class is still importable and carries the
full Semantica API, but cannot be passed to a ``Crew``.
"""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List, Optional
from pydantic import Field
from semantica.utils.logging import get_logger
from ._availability import CREWAI_AVAILABLE
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Optional: CrewAI BaseKnowledgeSource base class
# ---------------------------------------------------------------------------
_BaseKnowledgeSource: Any = object
if CREWAI_AVAILABLE:
from crewai.knowledge.source.base_knowledge_source import (
BaseKnowledgeSource as _BaseKnowledgeSource, # type: ignore
)
def _chunk_text_manual(text: str, chunk_size: int, chunk_overlap: int) -> List[str]:
"""Fallback plain-text chunker for when CrewAI helpers are unavailable."""
if not text:
return []
if int(chunk_size) <= 0:
return [text]
size = max(1, int(chunk_size))
overlap = max(0, int(chunk_overlap))
if len(text) <= size:
return [text]
step = max(1, size - overlap)
return [text[i : i + size] for i in range(0, len(text), step)]
class SemanticaKnowledgeSource(_BaseKnowledgeSource): # type: ignore[misc]
"""
CrewAI knowledge source backed by a Semantica ``ContextGraph``.
On ``add()`` the graph's nodes and edges are serialised into readable text
and pushed through the standard CrewAI chunking / storage pipeline, making
graph knowledge retrievable by every agent in the crew.
Parameters
----------
graph:
A ``semantica.context.ContextGraph`` to expose. A fresh in-memory
graph is created when ``None``.
name:
Source name. Defaults to ``"semantica_knowledge_graph"``.
chunk_size:
Max characters per chunk (default 4000).
chunk_overlap:
Character overlap between adjacent chunks (default 200).
"""
name: str = "semantica_knowledge_graph"
graph: Any = Field(default=None, exclude=True)
chunk_size: int = 4000
chunk_overlap: int = 200
had_live_state: bool = False
reconstructed_state: bool = Field(default=False, exclude=True)
def __init__(
self,
graph: Any = None,
name: Optional[str] = None,
chunk_size: int = 4000,
chunk_overlap: int = 200,
**kwargs: Any,
) -> None:
if CREWAI_AVAILABLE:
# Do NOT eagerly build a graph here: pydantic calls this ``__init__``
# during ``model_validate`` (checkpoint restore), and the eager
# build would hide that a live graph was lost. ``model_post_init``
# rebuilds defaults and flags ``reconstructed_state`` instead.
super().__init__(
graph=graph,
name=name or "semantica_knowledge_graph",
chunk_size=int(chunk_size),
chunk_overlap=int(chunk_overlap),
**kwargs,
)
else:
if graph is None:
from semantica.context import ContextGraph
graph = ContextGraph()
super().__init__()
self.graph = graph
self.name = name or "semantica_knowledge_graph"
self.chunk_size = int(chunk_size)
self.chunk_overlap = int(chunk_overlap)
logger.info(
"SemanticaKnowledgeSource initialised (crewai=%s, chunk_size=%d)",
CREWAI_AVAILABLE,
self.chunk_size,
)
self.had_live_state = True
def model_post_init(self, __context: Any) -> None:
"""Re-create default state after validation/deserialisation.
``graph`` is excluded from JSON serialisation (CrewAI checkpoints
serialise their models via ``model_dump(mode="json")``), so a source
restored from a checkpoint has ``None`` state until this runs.
"""
if self.graph is None:
from semantica.context import ContextGraph
self.graph = ContextGraph()
if self.had_live_state:
self.reconstructed_state = True
logger.warning(
"SemanticaKnowledgeSource: the live graph was lost during "
"serialization/checkpoint restore — an EMPTY graph was "
"reconstructed; re-attach the original graph before "
"continuing"
)
else:
logger.warning(
"SemanticaKnowledgeSource created a fresh in-memory "
"ContextGraph — sources sharing knowledge must be wired to "
"the same graph explicitly"
)
self.had_live_state = True
super().model_post_init(__context)
# ------------------------------------------------------------------
# Content extraction
# ------------------------------------------------------------------
def load_content(self) -> Dict[str, str]:
"""
Serialise the graph into ``{id: readable_text}`` pairs.
Nodes are rendered with their type/content/metadata, edges with their
source, relation type and target. This satisfies the legacy CrewAI
``BaseKnowledgeSource.load_content`` contract.
"""
content: Dict[str, str] = {}
graph = self.graph
if graph is None:
return content
try:
for node in graph.find_nodes() or []: # type: ignore[attr-defined]
nid = node.get("id") or node.get("node_id") or ""
if not nid:
continue
parts = [
"Entity",
str(nid),
"type: " + str(node.get("type", "entity")),
]
if node.get("content"):
parts.append("content: " + str(node["content"]))
if node.get("metadata"):
try:
import json
parts.append("metadata: " + json.dumps(node["metadata"]))
except Exception:
parts.append("metadata: " + str(node["metadata"]))
content[str(nid)] = " | ".join(parts)
except Exception as exc:
logger.warning(
"SemanticaKnowledgeSource.load_content (nodes) failed: %s", exc
)
try:
for idx, edge in enumerate(
graph.find_edges() or [] # type: ignore[attr-defined]
):
src = edge.get("source")
tgt = edge.get("target")
if not src or not tgt:
continue
rel = edge.get("type") or edge.get("edge_type") or "related_to"
weight = edge.get("weight")
text = f"{src} -[{rel}]-> {tgt}"
if weight is not None:
text += f" (weight: {weight})"
content[f"edge-{idx}"] = text
except Exception as exc:
logger.warning(
"SemanticaKnowledgeSource.load_content (edges) failed: %s", exc
)
return content
def validate_content(self) -> Any:
"""
Validate that a readable graph is attached.
Satisfies the current CrewAI ``BaseKnowledgeSource.validate_content``
contract.
"""
if self.graph is None:
raise ValueError("SemanticaKnowledgeSource requires a ContextGraph.")
return True
# ------------------------------------------------------------------
# Chunking + storage (abstract in both CrewAI generations)
# ------------------------------------------------------------------
def _chunk(self, text: str) -> List[str]:
"""Chunk ``text`` using CrewAI's helper when available, else manual."""
helper = getattr(self, "_chunk_text", None)
if helper is not None:
try:
return list(helper(text) or [])
except Exception as exc:
logger.debug(
"SemanticaKnowledgeSource._chunk_text failed, falling back: %s", exc
)
return _chunk_text_manual(text, self.chunk_size, self.chunk_overlap)
def add(self) -> None:
"""
Process the graph into chunks and store them via CrewAI storage.
Sets both ``chunks`` (current CrewAI) and ``_chunks`` (legacy CrewAI)
so either ``_save_documents`` implementation picks them up. If no
storage has been wired (e.g. not yet attached to a ``Crew``), chunks
are kept in memory.
"""
content = self.load_content()
if not content:
logger.debug("SemanticaKnowledgeSource.add: empty graph — nothing to store")
return
chunks: List[str] = []
for _, text in content.items():
if text:
chunks.extend(self._chunk(text))
self.chunks = chunks
self._chunks = chunks
save = getattr(self, "_save_documents", None)
if save is not None:
if getattr(self, "storage", None) is None:
logger.debug(
"SemanticaKnowledgeSource.add: storage not wired — "
"keeping chunks in memory"
)
else:
try:
save()
logger.info(
"SemanticaKnowledgeSource.add: stored %d chunks", len(chunks)
)
return
except Exception as exc:
logger.error(
"SemanticaKnowledgeSource.add: storage save FAILED (%s) — "
"chunks are only kept in memory and agents will retrieve "
"nothing. Configure the Crew embedder (e.g. an OpenAI "
"embedder with OPENAI_API_KEY, or a local embedder) before "
"running the crew.",
exc,
)
logger.info(
"SemanticaKnowledgeSource.add: %d chunks ready in memory", len(chunks)
)
async def aadd(self) -> None:
"""
Asynchronous variant of ``add()`` (current CrewAI contract).
The graph serialisation is CPU-bound, so it runs in a thread pool to
avoid blocking the event loop.
"""
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self.add)
# ------------------------------------------------------------------
# Inspection helpers
# ------------------------------------------------------------------
def get_content_summary(self) -> Dict[str, Any]:
"""
Summarise what the source exposes (helpful for debugging / testing).
"""
content = self.load_content()
return {
"name": self.name,
"source_count": len(content),
"chunks": len(getattr(self, "chunks", []) or []),
"crewai_available": CREWAI_AVAILABLE,
}
+2 -12
View File
@@ -93,14 +93,7 @@ def _handle_tools_call(req_id: Any, params: dict) -> dict:
result = tool["_handler"](args)
except Exception as exc:
log.exception("Tool %s raised an exception", name)
# The exception's class name (e.g. "ValidationError", "TimeoutError")
# is safe to surface — unlike str(exc), it never carries paths,
# connection strings, or other internal detail — and lets the
# client distinguish failure kinds without a full message.
return _err(
req_id, _INTERNAL_ERROR,
f"Tool '{name}' failed ({type(exc).__name__}). See server logs for details.",
)
return _err(req_id, _INTERNAL_ERROR, str(exc))
# MCP spec: content must be a list of content items
return _ok(req_id, {
@@ -178,10 +171,7 @@ class SemanticaMCPServer:
log.exception("Unhandled error in method %s", method)
if req_id is None:
return None
return _err(
req_id, _INTERNAL_ERROR,
f"Method '{method}' failed ({type(exc).__name__}). See server logs for details.",
)
return _err(req_id, _INTERNAL_ERROR, str(exc))
# ------------------------------------------------------------------
def run(self) -> None:
+3 -11
View File
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "semantica"
version = "0.6.6"
description = "Graph-Native Infrastructure for Context and Accountable AI Systems: context graphs, decision intelligence, full provenance tracking, and explainable reasoning engines — every AI decision traceable, every output auditable."
version = "0.6.5"
description = "Accountability and context layer for AI agents. Context graphs, decision intelligence, full provenance tracking, and explainable reasoning engines — every AI decision traceable, every output auditable."
readme = "README.md"
license = { text = "MIT" }
@@ -201,10 +201,6 @@ gpu = [
# ---- Agentic Framework Integrations ----
agno = ["agno>=1.0.0"]
# crewai core provides BaseTool and BaseKnowledgeSource; crewai-tools is not
# needed (it pulls vulnerable transitive deps like chromadb) and would only
# duplicate the prebuilt tooling users can install separately.
crewai = ["crewai>=0.80.0"]
# ---- File Watching ----
watch = ["watchdog>=6.0.0"]
@@ -246,10 +242,6 @@ explorer-lite = [
]
# Everything (cross-platform — gpu excluded; install semantica[gpu] separately on Linux)
# NOTE: the ``crewai`` extra is intentionally NOT in ``all``: crewai hard-requires
# ``chromadb~=1.1.0``, which carries a pre-authentication code-injection advisory
# (CVE-2026-45829) with no fixed release — including it here would fail the CI
# dependency-audit/security gates. Install it explicitly via ``semantica[crewai]``.
all = [
"semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,explorer]",
"semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,agno]"
@@ -271,7 +263,7 @@ include = ["semantica*", "integrations*"]
[tool.setuptools.package-data]
# Explicit patterns are more reliable than **/* across setuptools versions.
# static/* covers index.html / favicon; static/assets/* covers all JS/CSS chunks.
"semantica" = ["static/*", "static/assets/*", "ontology/vocabulary/*.ttl"]
"semantica" = ["static/*", "static/assets/*"]
[tool.black]
line-length = 88
+4 -4
View File
@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml --python-version 3.11 --extra all --generate-hashes -o requirements-ci.txt
# uv pip compile -p 3.11 --extra all --generate-hashes -o requirements-ci.txt pyproject.toml
accelerate==1.14.0 \
--hash=sha256:41b9c4377a54e0b460a959b0defa1b736e4ca0a2373252d9a539964c2afe3c8d \
--hash=sha256:e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6
@@ -4123,9 +4123,9 @@ pooch==1.9.0 \
--hash=sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed \
--hash=sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b
# via librosa
portalocker==2.7.0 \
--hash=sha256:032e81d534a88ec1736d03f780ba073f047a06c478b06e2937486f334e955c51 \
--hash=sha256:a07c5b4f3985c3cf4798369631fb7011adb498e2a46d8440efc75a8f29a0f983
portalocker==3.2.0 \
--hash=sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac \
--hash=sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968
# via qdrant-client
pre-commit==4.6.2 \
--hash=sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441 \
+1 -1
View File
@@ -10,7 +10,7 @@ Main exports:
- Config: Configuration management
"""
__version__ = "0.6.6"
__version__ = "0.6.5"
__author__ = "Semantica Contributors"
__license__ = "MIT"
+5 -58
View File
@@ -20,7 +20,7 @@ if sys.platform == "win32":
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
from dataclasses import asdict, dataclass, field, is_dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple
import yaml
@@ -3933,68 +3933,15 @@ def backup_restore(cli_ctx: CLIContext, source: str, local_dry: bool) -> None:
try:
if _tf.is_tarfile(str(work_path)):
restore_root = Path.cwd().resolve()
restore_root = Path.cwd()
with _tf.open(str(work_path), "r:*") as tar:
# Dry-run listing was already handled above; extract now
for member in tar.getmembers():
# Strip the leading "semantica-backup/" prefix
member.name = member.name.replace("semantica-backup/", "", 1)
if not member.name:
continue
# Reject members whose resolved path escapes the
# restore root (path traversal / absolute paths),
# regardless of the "semantica-backup/" prefix.
member_path = (restore_root / member.name).resolve()
try:
member_path.relative_to(restore_root)
except ValueError:
raise click.ClickException(
f"Refusing to restore '{member.name}': "
"path escapes the restore directory."
)
# Reject symlink/hardlink members whose target
# escapes the restore root. Checked two ways:
# lexically (linkname itself, so an absolute path or
# a literal ".." segment is rejected outright, with
# no dependence on what else does or doesn't already
# exist on disk) and by resolution (catches any
# remaining traversal the lexical check misses).
if member.issym() or member.islnk():
linkname = member.linkname or ""
linkname_parts = PurePosixPath(
linkname.replace("\\", "/")
).parts
if (
not linkname
or os.path.isabs(linkname)
or PureWindowsPath(linkname).is_absolute()
or ".." in linkname_parts
):
raise click.ClickException(
f"Refusing to restore '{member.name}': "
"link target is absolute or traverses "
"out of the archive."
)
link_target = (
member_path.parent / linkname
).resolve()
try:
link_target.relative_to(restore_root)
except ValueError:
raise click.ClickException(
f"Refusing to restore '{member.name}': "
"link target escapes the restore directory."
)
extract_kwargs: Dict[str, Any] = {"path": str(restore_root)}
if hasattr(_tf, "data_filter"):
# Python >=3.12: also reject device files, and
# further harden the traversal/ownership checks.
extract_kwargs["filter"] = "data"
tar.extract(member, **extract_kwargs)
console.print(f" restored: {member.name}")
if member.name:
tar.extract(member, path=str(restore_root))
console.print(f" restored: {member.name}")
elif src.is_dir():
restore_root = Path.cwd()
for f in src.rglob("*"):
+4 -645
View File
@@ -72,9 +72,9 @@ Example Usage:
... node_embeddings=True)
>>>
>>> # Basic graph operations
>>> graph.add_node("Python", "language", popularity="high")
>>> graph.add_node("Programming", "concept")
>>> graph.add_edge("Python", "Programming", "related_to")
>>> graph.add_node("Python", type="language", properties={"popularity": "high"})
>>> graph.add_node("Programming", type="concept")
>>> graph.add_edge("Python", "Programming", type="related_to")
>>> centrality = graph.get_node_centrality("Python")
>>> similar = graph.find_similar_nodes("Python", similarity_type="content")
>>> analysis = graph.analyze_graph_with_kg()
@@ -88,7 +88,7 @@ Example Usage:
... confidence=0.95,
... entities=["customer_123", "property_456"]
... )
>>> precedents = graph.find_precedents(decision_id, limit=5)
>>> precedents = graph.find_precedents("loan_approval", limit=5)
>>> influence = graph.analyze_decision_influence(decision_id)
>>> insights = graph.get_decision_insights()
>>> causality = graph.trace_decision_causality(decision_id)
@@ -188,26 +188,6 @@ def _normalize_temporal_input(value: Optional[Union[str, int, float, datetime]])
raise ValueError("Temporal values must be datetime, epoch seconds, ISO strings, or None")
def _closing_valid_until(current: Optional[str], at_iso: str) -> str:
"""Return the earlier of an existing end bound and a retraction time.
Retraction closes a validity window and must never widen one: an entity
added with ``valid_until`` already in the past would otherwise be reported
active by ``is_active``/``state_at`` for the span between its original end
and the retraction. An unparseable ``current`` imposes no end bound at all
(see :func:`_parse_iso_dt`), so ``at_iso`` still closes it.
"""
if current is None:
return at_iso
existing = _parse_iso_dt(current)
if existing is None:
return at_iso
requested = _parse_iso_dt(at_iso)
if requested is None or existing <= requested:
return current
return at_iso
def _pick_first(*values: Any) -> Any:
for value in values:
if value is None:
@@ -484,7 +464,6 @@ class ContextGraph:
self.nodes: Dict[str, ContextNode] = {}
self.edges: List[ContextEdge] = []
self._edge_index: Dict[str, ContextEdge] = {}
self._adjacency: Dict[str, List[ContextEdge]] = defaultdict(list)
@@ -496,15 +475,6 @@ class ContextGraph:
self._unresolved_links: Dict[str, Dict[str, str]] = {}
# Retraction closes an entity's validity window but keeps it in the
# graph; a tombstone records that an entity was purged outright,
# without retaining the purged content. Keyed by
# ``(entity_kind, entity_id)`` -- node ids are caller-supplied strings
# and edge ids are UUID strings, so a single id keyspace would let a
# node record mask an edge of the same id, and vice versa.
self._retractions: Dict[Tuple[str, str], Dict[str, Any]] = {}
self._tombstones: Dict[Tuple[str, str], Dict[str, Any]] = {}
self.progress_tracker = get_progress_tracker()
@@ -1150,16 +1120,11 @@ class ContextGraph:
# Clear existing
self.nodes.clear()
self.edges.clear()
self._edge_index.clear()
self._adjacency.clear()
self.node_type_index.clear()
self.edge_type_index.clear()
self._linked_graphs.clear()
self._unresolved_links.clear()
# Deletion metadata belongs to the graph being replaced; keeping it
# would make entities in the loaded graph read as already retracted.
self._retractions.clear()
self._tombstones.clear()
if "graph_id" in data:
self.graph_id = data["graph_id"]
@@ -1553,384 +1518,16 @@ class ContextGraph:
max_edges = n * (n - 1)
return len(self.edges) / max_edges
def retract_node(
self,
node_id: str,
reason: Optional[str] = None,
at: Optional[Union[str, datetime]] = None,
cascade: bool = True,
) -> bool:
"""Retract a node: no longer active, but still visible in history.
Closes the node's validity window rather than deleting it, so
:meth:`state_at` before ``at`` still returns the node and any decision
recorded against it remains explainable. Use :meth:`purge_node` when
the data itself has to be gone.
Args:
node_id: Node to retract.
reason: Why it was retracted, stored on the retraction record.
at: When the retraction takes effect (ISO string or datetime).
Defaults to now, UTC.
cascade: Also retract every edge touching the node. Leaving edges
active around an inactive node means :meth:`find_active_nodes`
drops the node while its relationships still read as current,
so the default keeps the active view self-consistent.
Retraction is expressed through the temporal window, so it is visible
to the activity-aware views -- :meth:`find_active_nodes`,
:meth:`state_at`, ``ContextNode.is_active`` -- and not to membership
checks like :meth:`has_node` or :meth:`stats`, which continue to count
the retained record. That matches how ``valid_until`` already behaved
before retraction existed.
A node whose ``valid_until`` is already earlier than ``at`` keeps that
earlier bound: retraction only ever closes a validity window, never
widens one.
Returns:
True if the node was retracted; False if it does not exist or was
already retracted.
Note:
Emits ``UPDATE_NODE`` to the audit-trail callback, since retraction
changes the validity window rather than removing the record.
"""
at_iso = _normalize_temporal_input(at) or datetime.now(timezone.utc).isoformat()
with self._lock:
node = self.nodes.get(node_id)
if node is None:
self.logger.warning("Cannot retract unknown node: %r", node_id)
return False
if ("node", node_id) in self._retractions:
return False
node.valid_until = _closing_valid_until(node.valid_until, at_iso)
record = {
"entity_id": node_id,
"entity_kind": "node",
"retracted_at": at_iso,
"reason": reason,
}
self._retractions[("node", node_id)] = record
node_payload = {**node.to_dict(), "retraction": dict(record)}
cascaded: List[Tuple[str, Dict[str, Any]]] = []
if cascade:
# Snapshotted once, before the loop: edge_id is content-derived
# and not guaranteed unique (#922), so two distinct edge objects
# can share one id. Checking the live _retractions dict inside
# the loop would let the first duplicate's record block the
# second from ever being closed, leaving it active indefinitely
# while its retraction record claimed otherwise.
already_retracted_edge_ids = {
key[1] for key in self._retractions if key[0] == "edge"
}
for edge in self._incident_edges(node_id):
if edge.edge_id in already_retracted_edge_ids:
continue
edge.valid_until = _closing_valid_until(edge.valid_until, at_iso)
edge_record = {
"entity_id": edge.edge_id,
"entity_kind": "edge",
"retracted_at": at_iso,
"reason": reason,
"cascaded_from": node_id,
}
self._retractions[("edge", edge.edge_id)] = edge_record
# Payloads are snapshotted here, not read back after the
# lock is released: a concurrent clear() would otherwise
# wipe the record out from under the emission below.
cascaded.append(
(
edge.edge_id,
{**edge.to_dict(), "retraction": dict(edge_record)},
)
)
self._emit_mutation("UPDATE_NODE", node_id, node_payload)
for edge_id, edge_payload in cascaded:
self._emit_mutation("UPDATE_EDGE", edge_id, edge_payload)
self.logger.info(
"Retracted node %r at %s (cascaded %d edge(s))",
node_id,
at_iso,
len(cascaded),
)
return True
def retract_edge(
self,
edge_id: str,
reason: Optional[str] = None,
at: Optional[Union[str, datetime]] = None,
) -> bool:
"""Retract a single edge, leaving its endpoints untouched.
An edge whose ``valid_until`` is already earlier than ``at`` keeps that
earlier bound; retraction never widens a validity window.
Args:
edge_id: Edge to retract.
reason: Why it was retracted.
at: When the retraction takes effect. Defaults to now, UTC.
Returns:
True if the edge was retracted; False if it does not exist or was
already retracted.
Note:
``edge_id`` is content-derived and not guaranteed unique (#922):
two distinct edge objects can share one id. Every edge matching
``edge_id`` is closed under a single retraction record, so a
duplicate can never be left silently active while the record
claims it was retracted.
"""
at_iso = _normalize_temporal_input(at) or datetime.now(timezone.utc).isoformat()
with self._lock:
edges = [e for e in self.edges if e.edge_id == edge_id]
if not edges:
self.logger.warning("Cannot retract unknown edge: %r", edge_id)
return False
if ("edge", edge_id) in self._retractions:
return False
record = {
"entity_id": edge_id,
"entity_kind": "edge",
"retracted_at": at_iso,
"reason": reason,
}
self._retractions[("edge", edge_id)] = record
for edge in edges:
edge.valid_until = _closing_valid_until(edge.valid_until, at_iso)
payload = {**edges[0].to_dict(), "retraction": dict(record)}
self._emit_mutation("UPDATE_EDGE", edge_id, payload)
self.logger.info(
"Retracted edge %r at %s (%d underlying record(s))",
edge_id,
at_iso,
len(edges),
)
return True
def purge_node(
self,
node_id: str,
reason: Optional[str] = None,
at: Optional[Union[str, datetime]] = None,
cascade: bool = True,
) -> bool:
"""Permanently remove a node; history no longer contains it.
Unlike :meth:`retract_node` this is destructive: the node disappears
from :meth:`state_at` as well as from the active view. Only a tombstone
remains, recording that a purge happened and why -- deliberately
without the purged content, since retaining it would defeat the point.
Scope is this graph only. Copies held elsewhere (``AgentMemory``, a
bound vector store, an exported file) are not reached, so this is one
step of an erasure workflow, not the whole of it.
Args:
node_id: Node to purge.
reason: Why it was purged, e.g. an erasure-request reference.
at: When the purge takes effect, recorded as the tombstone's
``purged_at`` (ISO string or datetime). Defaults to now, UTC.
cascade: Also purge every edge touching the node, and the marker
node of any cross-graph link it exits through. Defaults to True
because leaving edges pointing at a removed node produces
dangling endpoints.
Cross-graph links registered by :meth:`link_graph` out of this node are
deregistered either way -- a link whose source no longer exists would
still resolve through :meth:`navigate_to` and still be serialized by
:meth:`save_to_file`.
Returns:
True if the node was purged; False if it does not exist.
Note:
Emits ``REMOVE_NODE``/``REMOVE_EDGE`` to the audit-trail callback.
"""
purged_at = (
_normalize_temporal_input(at) or datetime.now(timezone.utc).isoformat()
)
with self._lock:
if node_id not in self.nodes:
self.logger.warning("Cannot purge unknown node: %r", node_id)
return False
# The link marker node is scaffolding reachable only from the node
# being purged, so it goes with the cascade rather than surviving as
# an orphan. Resolve the markers before deregistering the links they
# are derived from.
targets = [node_id]
if cascade:
targets.extend(self._cross_graph_marker_nodes(node_id))
for link_id in self._cross_graph_links_for(node_id):
self._linked_graphs.pop(link_id, None)
self._unresolved_links.pop(link_id, None)
# Tombstones are snapshotted into locals before the lock is
# released; reading them back afterwards would race a clear().
purged_edges: List[Tuple[str, Dict[str, Any]]] = []
purged_nodes: List[Tuple[str, Dict[str, Any]]] = []
for target in targets:
cascaded_from = None if target == node_id else node_id
if cascade:
for edge in self._incident_edges(target):
self._drop_edge_from_indexes(edge)
edge_record = {
"entity_id": edge.edge_id,
"entity_kind": "edge",
"purged_at": purged_at,
"reason": reason,
"cascaded_from": node_id,
}
self._tombstones[("edge", edge.edge_id)] = edge_record
self._retractions.pop(("edge", edge.edge_id), None)
purged_edges.append((edge.edge_id, dict(edge_record)))
self._drop_node_from_indexes(target)
node_record = {
"entity_id": target,
"entity_kind": "node",
"purged_at": purged_at,
"reason": reason,
}
if cascaded_from is not None:
node_record["cascaded_from"] = cascaded_from
self._tombstones[("node", target)] = node_record
self._retractions.pop(("node", target), None)
purged_nodes.append((target, dict(node_record)))
for edge_id, payload in purged_edges:
self._emit_mutation("REMOVE_EDGE", edge_id, payload)
for purged_id, payload in purged_nodes:
self._emit_mutation("REMOVE_NODE", purged_id, payload)
self.logger.info(
"Purged node %r (cascaded %d edge(s), %d node(s))",
node_id,
len(purged_edges),
len(purged_nodes) - 1,
)
return True
def purge_edge(
self,
edge_id: str,
reason: Optional[str] = None,
at: Optional[Union[str, datetime]] = None,
) -> bool:
"""Permanently remove a single edge, leaving its endpoints in place.
If the edge is the bridge of a cross-graph link, the link is also
deregistered -- :meth:`navigate_to` should not keep resolving a link
whose bridge is gone. The marker node itself is an endpoint and is left
in place; purge it directly, or purge the link's source node, to remove
it too.
Args:
edge_id: Edge to purge.
reason: Why it was purged.
at: When the purge takes effect, recorded as the tombstone's
``purged_at``. Defaults to now, UTC.
Returns:
True if the edge was purged; False if it does not exist.
Note:
``edge_id`` is content-derived and not guaranteed unique (#922):
two distinct edge objects can share one id. Every edge matching
``edge_id`` is dropped under a single tombstone, so a duplicate
can never be left live in the graph while the tombstone claims
the edge is gone.
"""
purged_at = (
_normalize_temporal_input(at) or datetime.now(timezone.utc).isoformat()
)
with self._lock:
edges = [e for e in self.edges if e.edge_id == edge_id]
if not edges:
self.logger.warning("Cannot purge unknown edge: %r", edge_id)
return False
for edge in edges:
self._drop_edge_from_indexes(edge)
link_id = (edge.metadata or {}).get("link_id")
if (edge.metadata or {}).get("cross_graph") and link_id:
self._linked_graphs.pop(link_id, None)
self._unresolved_links.pop(link_id, None)
self._retractions.pop(("edge", edge_id), None)
record = {
"entity_id": edge_id,
"entity_kind": "edge",
"purged_at": purged_at,
"reason": reason,
}
self._tombstones[("edge", edge_id)] = record
payload = dict(record)
self._emit_mutation("REMOVE_EDGE", edge_id, payload)
self.logger.info(
"Purged edge %r (%d underlying record(s))", edge_id, len(edges)
)
return True
def get_retraction(
self, entity_id: str, entity_kind: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""Return the retraction record for a node or edge, or None.
Args:
entity_id: Node id or edge id.
entity_kind: ``"node"`` or ``"edge"``. Records are keyed by kind as
well as id, so pass this when a node id and an edge id could
collide; without it a node record is preferred over an edge one.
"""
with self._lock:
return self._find_removal_record(self._retractions, entity_id, entity_kind)
def get_tombstone(
self, entity_id: str, entity_kind: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""Return the purge tombstone for a node or edge, or None.
The tombstone records that a purge happened, when, and why. It never
contains the purged content.
Args:
entity_id: Node id or edge id.
entity_kind: ``"node"`` or ``"edge"``; disambiguates a node id that
collides with an edge id, as for :meth:`get_retraction`.
"""
with self._lock:
return self._find_removal_record(self._tombstones, entity_id, entity_kind)
def list_retractions(self) -> List[Dict[str, Any]]:
"""Return every retraction record."""
with self._lock:
return [dict(record) for record in self._retractions.values()]
def list_tombstones(self) -> List[Dict[str, Any]]:
"""Return every purge tombstone."""
with self._lock:
return [dict(record) for record in self._tombstones.values()]
def clear(self) -> None:
"""Fully reset the graph state and indexes."""
with self._lock:
self.nodes.clear()
self.edges.clear()
self._edge_index.clear()
self._adjacency.clear()
self.node_type_index.clear()
self.edge_type_index.clear()
self._linked_graphs.clear()
self._unresolved_links.clear()
self._retractions.clear()
self._tombstones.clear()
self.logger.debug("Graph state fully cleared.")
# --- Internal Helpers ---
@@ -1998,11 +1595,6 @@ class ContextGraph:
self.logger.warning("Skipping internal edge with invalid endpoints: %r", edge)
return False
with self._lock:
# Edge identity is content-derived, so an existing edge_id means this
# exact edge is already stored; re-adding it is a no-op (issue #922).
if edge.edge_id in self._edge_index:
return False
# Ensure nodes exist
if edge.source_id not in self.nodes:
self._add_internal_node(
@@ -2013,7 +1605,6 @@ class ContextGraph:
ContextNode(edge.target_id, "entity", edge.target_id)
)
self._edge_index[edge.edge_id] = edge
self.edges.append(edge)
self.edge_type_index[edge.edge_type].append(edge)
self._adjacency[edge.source_id].append(edge)
@@ -2029,147 +1620,6 @@ class ContextGraph:
)
return True
def _emit_mutation(
self, operation: str, entity_id: str, payload: Dict[str, Any]
) -> None:
"""Fire the audit-trail callback, mirroring the add paths.
Kept in one place so retraction and purge record themselves the same
way ``_add_internal_node``/``_add_internal_edge`` already do, including
the ``_suspend_mutation_callback`` guard used during restores.
"""
if not getattr(self, "mutation_callback", None):
return
if getattr(self, "_suspend_mutation_callback", False):
return
try:
self.mutation_callback(operation, entity_id, payload)
except Exception as e:
self.logger.warning(
f"Audit trail callback failed for {operation} {entity_id}: {e}"
)
def _incident_edges(self, node_id: str) -> List[ContextEdge]:
"""Every edge touching ``node_id``, in either direction.
``_adjacency`` is keyed by source only, so incoming edges have to come
from a scan of ``self.edges``; relying on ``_adjacency`` alone would
silently leave inbound edges pointing at a removed node.
"""
return [
edge
for edge in self.edges
if edge.source_id == node_id or edge.target_id == node_id
]
@staticmethod
def _find_removal_record(
store: Dict[Tuple[str, str], Dict[str, Any]],
entity_id: str,
entity_kind: Optional[str],
) -> Optional[Dict[str, Any]]:
"""Look a retraction/tombstone up by id, optionally narrowed by kind.
The caller must hold ``self._lock``. Records are keyed by
``(entity_kind, entity_id)``; with no kind given, both keyspaces are
tried so callers that know an id is unambiguous can pass it alone.
"""
if entity_kind is not None:
if entity_kind not in ("node", "edge"):
raise ValueError(
f"entity_kind must be 'node', 'edge' or None, got {entity_kind!r}"
)
kinds: Tuple[str, ...] = (entity_kind,)
else:
kinds = ("node", "edge")
for kind in kinds:
record = store.get((kind, entity_id))
if record is not None:
return dict(record)
return None
def _cross_graph_links_for(self, node_id: str) -> List[str]:
"""Link ids that ``node_id`` participates in, as exit point or marker.
The caller must hold ``self._lock``. :meth:`link_graph` registers a link
in three places -- ``_linked_graphs``, a marker node and the bridge edge
-- so removing only the node would leave :meth:`navigate_to` resolving a
link whose source is gone.
"""
link_ids = [
link_id
for link_id, (_, source_node_id, _) in self._linked_graphs.items()
if source_node_id == node_id
]
link_ids.extend(
link_id
for link_id, meta in self._unresolved_links.items()
if meta.get("source_node_id") == node_id
)
node = self.nodes.get(node_id)
metadata = getattr(node, "metadata", None) or {}
if metadata.get("cross_graph") and metadata.get("link_id"):
link_ids.append(metadata["link_id"])
return list(dict.fromkeys(link_ids))
def _cross_graph_marker_nodes(self, node_id: str) -> List[str]:
"""Marker nodes of the cross-graph links ``node_id`` exits through.
The caller must hold ``self._lock``.
"""
return [
marker_id
for marker_id in (
f"__cross_graph_{link_id}"
for link_id in self._cross_graph_links_for(node_id)
)
if marker_id != node_id and marker_id in self.nodes
]
def _drop_node_from_indexes(self, node_id: str) -> None:
"""Remove one node from ``nodes``, ``node_type_index`` and ``_adjacency``.
The caller must hold ``self._lock``. Incident edges are not touched --
see :meth:`_drop_edge_from_indexes`.
"""
node = self.nodes.pop(node_id, None)
if node is None:
return
bucket = self.node_type_index.get(node.node_type)
if bucket is not None:
bucket.discard(node_id)
if not bucket:
del self.node_type_index[node.node_type]
self._adjacency.pop(node_id, None)
def _drop_edge_from_indexes(self, edge: ContextEdge) -> None:
"""Remove one edge from every structure that references it.
The caller must hold ``self._lock``. ``edges``, ``edge_type_index`` and
``_adjacency`` must be updated together or the indexes drift out of
step with the edge list.
"""
try:
self.edges.remove(edge)
except ValueError:
pass
bucket = self.edge_type_index.get(edge.edge_type)
if bucket is not None:
try:
bucket.remove(edge)
except ValueError:
pass
if not bucket:
del self.edge_type_index[edge.edge_type]
adjacent = self._adjacency.get(edge.source_id)
if adjacent is not None:
try:
adjacent.remove(edge)
except ValueError:
pass
if not adjacent:
del self._adjacency[edge.source_id]
# --- Builder Methods (Legacy/Utility) ---
def build_from_conversations(
@@ -2449,97 +1899,6 @@ class ContextGraph:
},
}
def to_kg_dict(self, entities_only: bool = False) -> Dict[str, Any]:
"""Export graph in the canonical knowledge-graph shape.
This is the official adapter that converts the ContextGraph's internal
``{"nodes", "edges"}`` / ``source`` representation into the
``{"entities", "relationships"}`` / ``source_id`` shape expected by
downstream consumers such as
:class:`~semantica.export.rdf_exporter.RDFExporter` and
:meth:`~semantica.kg.temporal_query.TemporalGraphQuery.query_time_range`.
Users no longer need to hand-map field names between APIs.
Args:
entities_only: If True, only nodes whose ``node_type`` is
``"entity"`` are exported as entities. When False (default),
every node is exported. Relationships whose endpoints are not
in the exported entity set are dropped to avoid dangling
references in downstream consumers.
Returns:
dict: A knowledge-graph dictionary with:
- ``entities``: list of ``{"id", "text", "type", "properties",
"metadata"}`` (plus ``valid_from`` / ``valid_until`` when set)
- ``relationships``: list of ``{"source_id", "target_id",
"type", "weight", "id", "familyId"}`` (plus ``metadata`` and
``valid_from`` / ``valid_until`` when set)
- ``statistics``: ``{"entity_count", "relationship_count"}``
"""
with self._lock:
entities_out = []
for n in self.nodes.values():
if entities_only and n.node_type != "entity":
continue
# Normalize the entity id to ``str`` so it matches ContextEdge,
# which coerces its endpoints to ``str`` in ``__post_init__``.
# Without this, non-string node ids (e.g. numeric ids loaded via
# ``from_dict``) would fail the ``valid_ids`` membership check
# below and silently drop otherwise-valid relationships.
entity_id = str(n.node_id)
entity: Dict[str, Any] = {
"id": entity_id,
"text": n.content,
"type": n.node_type,
# ``properties`` / ``metadata`` may be ``None`` when a node
# was loaded from JSON containing an explicit ``null``;
# guard with ``or {}`` so ``dict(...)`` never raises.
"properties": dict(n.properties or {}),
"metadata": dict(n.metadata or {}),
}
if n.valid_from is not None:
entity["valid_from"] = n.valid_from
if n.valid_until is not None:
entity["valid_until"] = n.valid_until
entities_out.append(entity)
# When only entity nodes are exported, drop relationships whose
# endpoints were filtered out so downstream consumers never see a
# source_id/target_id that is absent from ``entities``.
valid_ids = {e["id"] for e in entities_out} if entities_only else None
relationships_out = []
for e in self.edges:
if valid_ids is not None and (
e.source_id not in valid_ids or e.target_id not in valid_ids
):
continue
rel: Dict[str, Any] = {
"id": e.edge_id,
"familyId": e.family_id or e.edge_id,
"source_id": e.source_id,
"target_id": e.target_id,
"type": e.edge_type,
"weight": e.weight,
}
if e.metadata:
rel["metadata"] = dict(e.metadata)
if e.valid_from is not None:
rel["valid_from"] = e.valid_from
if e.valid_until is not None:
rel["valid_until"] = e.valid_until
relationships_out.append(rel)
return {
"entities": entities_out,
"relationships": relationships_out,
"statistics": {
"entity_count": len(entities_out),
"relationship_count": len(relationships_out),
},
}
def from_dict(self, graph_dict: Dict[str, Any]) -> None:
"""Load graph from dictionary format."""
# Clear existing graph
+1 -2
View File
@@ -45,7 +45,6 @@ License: MIT
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Union
from ..utils.entity_ids import get_entity_id
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
@@ -505,7 +504,7 @@ class EntityMerger:
# Record source entities
provenance["merged_from"] = [
{
"id": get_entity_id(e),
"id": self._get_entity_value(e, "id"),
"name": self._get_entity_value(e, "name"),
"source": self._get_entity_value(e, "metadata", {}).get("source") if hasattr(e, "metadata") or isinstance(e, dict) else None,
}
+2 -9
View File
@@ -45,7 +45,6 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from ..utils.entity_ids import get_entity_id
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
@@ -318,20 +317,14 @@ class MergeStrategyManager:
message=f"Building merged entity... ({current_step}/{total_steps}, remaining: {remaining_steps} steps)"
)
# Build merged entity
merged_from = []
for entity in entities:
entity_id = get_entity_id(entity)
if entity_id is not None:
merged_from.append(entity_id)
merged_entity = {
"id": get_entity_id(base_entity),
"id": base_entity.get("id"),
"name": self._merge_top_level_field("name", entities, base_entity),
"type": self._merge_top_level_field("type", entities, base_entity),
"properties": merged_properties,
"relationships": merged_relationships,
"metadata": self._merge_metadata(entities, base_entity),
"merged_from": merged_from,
"merged_from": [e.get("id") for e in entities if e.get("id")],
"merge_strategy": strategy.value,
}
+15 -19
View File
@@ -176,30 +176,26 @@ async def extract_entities(
session: GraphSession = Depends(get_session),
):
try:
from ...semantic_extract import NamedEntityRecognizer, RelationExtractor
from ...semantic_extract.methods import extract_entities as _extract_entities
from ...semantic_extract.methods import extract_relations as _extract_relations
entities = await asyncio.to_thread(_extract_entities, body.text)
relations = await asyncio.to_thread(_extract_relations, body.text)
ent_list = entities if isinstance(entities, list) else getattr(entities, "entities", [])
rel_list = relations if isinstance(relations, list) else getattr(relations, "relations", [])
return EnrichExtractResponse(
entities=[_safe_dict(entity) for entity in ent_list],
relations=[_safe_dict(relation) for relation in rel_list],
)
except ImportError:
raise HTTPException(
status_code=503,
detail="semantic_extract module not available. Ensure spacy and transformers are installed.",
)
recognizer = NamedEntityRecognizer(confidence_threshold=0.7)
extractor = RelationExtractor(confidence_threshold=0.6)
entities = await asyncio.to_thread(recognizer.extract_entities, body.text)
ent_list = entities if isinstance(entities, list) else getattr(entities, "entities", [])
relations = await asyncio.to_thread(
extractor.extract_relations, body.text, ent_list
)
rel_list = relations if isinstance(relations, list) else getattr(relations, "relations", [])
return EnrichExtractResponse(
entities=[_safe_dict(entity) for entity in ent_list],
relations=[_safe_dict(relation) for relation in rel_list],
)
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Extraction failed: {exc}")
@router.post("/api/enrich/links", response_model=LinkPredictionResponse)
+3 -36
View File
@@ -2,10 +2,10 @@
Shared Pydantic schemas for the Semantica Knowledge Explorer API.
"""
from datetime import datetime, timezone
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional, Tuple
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field
class ErrorResponse(BaseModel):
@@ -144,37 +144,6 @@ class DecisionResponse(BaseModel):
timestamp: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
@field_validator("timestamp", mode="before")
@classmethod
def _normalize_timestamp(cls, value: Any) -> Optional[str]:
"""Accept the epoch floats ContextGraph.record_decision() writes.
Decision nodes store ``timestamp`` as ``datetime.now().timestamp()``, a
float, so passing the stored value through unconverted fails validation
and turns every decision route into a 500. Normalize to ISO-8601 here so
the wire format stays a single string type whatever the producer wrote.
"""
if value is None or isinstance(value, str):
return value
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, (int, float)) and not isinstance(value, bool):
import math
if not math.isfinite(value):
raise ValueError(
f"timestamp must be a finite number, got {value!r}"
)
try:
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
except (OverflowError, OSError) as exc:
raise ValueError(
f"timestamp {value!r} is out of the representable epoch range"
) from exc
raise ValueError(
f"timestamp must be None, a string, a datetime, or a numeric epoch; "
f"got {type(value).__name__!r}"
)
class CausalChainResponse(BaseModel):
decision_id: str
@@ -205,9 +174,7 @@ class TemporalPatternResponse(BaseModel):
class EnrichExtractRequest(BaseModel):
# 10 000 characters is sufficient for a substantial document paragraph while
# preventing unbounded spaCy NLP processing on arbitrarily large payloads.
text: str = Field(..., max_length=10_000)
text: str
class EnrichExtractResponse(BaseModel):
+11 -13
View File
@@ -28,7 +28,7 @@ import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from ..utils.helpers import _require_mapping, ensure_directory, normalize_graph_payload
from ..utils.helpers import ensure_directory
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
@@ -204,19 +204,17 @@ class ArangoAQLExporter:
self._generate_collection_creation(vertex_collection, edge_collection)
)
# A non-mapping payload cannot reach normalize_graph_payload(): it
# raises ValidationError for that case, which would leave this
# exporter alone in raising a different exception type than the YAML
# and Neo4j exporters raise for the identical mistake.
_require_mapping(
knowledge_graph, ("entities", "relationships", "nodes", "edges")
)
# Extract entities and relationships
entities = knowledge_graph.get("entities", [])
relationships = knowledge_graph.get("relationships", [])
nodes = knowledge_graph.get("nodes", entities)
edges = knowledge_graph.get("edges", relationships)
# Accept either vocabulary; resolution is centralized so every
# exporter agrees on what a given payload means.
normalized = normalize_graph_payload(knowledge_graph)
entities = normalized["entities"]
relationships = normalized["relationships"]
# Use nodes/edges if entities/relationships are empty
if not entities and nodes:
entities = nodes
if not relationships and edges:
relationships = edges
# Generate vertex INSERT statements
vertex_statements = self._generate_vertex_inserts(entities, vertex_collection)
+3 -4
View File
@@ -14,10 +14,9 @@ License: MIT
"""
from typing import Any, Optional
from datetime import datetime
import uuid
from ..utils.helpers import utc_now_iso
class ExporterWithProvenance:
"""Base exporter with provenance tracking."""
@@ -46,9 +45,9 @@ class ExporterWithProvenance:
def export(self, data: Any, destination: str, **kwargs):
"""Export data with provenance tracking."""
activity_started_at_time = utc_now_iso()
activity_started_at_time = datetime.utcnow().isoformat()
result = self._exporter.export(data, destination, **kwargs)
activity_ended_at_time = utc_now_iso()
activity_ended_at_time = datetime.utcnow().isoformat()
if self.provenance and self._prov_manager:
self._prov_manager.track_entity(
-68
View File
@@ -297,57 +297,6 @@ export_yaml(semantic_network, "network.yaml", method="semantic_network")
export_yaml(schema, "schema.yaml", method="schema")
```
### Accepted Input
Both YAML exporters read their payload by key, so the input must be a mapping;
anything else raises `ProcessingError`. A bare list is rejected rather than
wrapped, since these formats distinguish entities from relationships from
triplets and guessing which one a list holds would mislabel the records.
Each exporter then reads a fixed set of keys, and raises `ValidationError` on a
non-empty mapping that supplies none of them — such a payload would otherwise
serialize to a valid file with every collection empty. Naming a recognized key
is not enough on its own: `{"entities": [], "data": [...]}` also raises, since
nothing resolves while the records sit under a key the exporter never reads.
| Method | Recognized keys |
| :--- | :--- |
| `"semantic_network"` | `entities` (alias `nodes`), `relationships` (alias `edges`), `triplets` |
| `"schema"` | `classes`, `properties`, `namespaces`, `uri`, `title`, `description`, `version` |
`metadata` is carried through on both, but does not by itself make a payload
recognized — an `export_json` envelope (`{"data": [...], "count": N,
"metadata": {...}}`) carries one and is rejected.
```python
# ContextGraph.to_dict() exports directly via the nodes/edges aliases
export_yaml(context_graph.to_dict(), "graph.yaml")
# A bare list has no unambiguous meaning here
export_yaml(records, "out.yaml") # ProcessingError
# An export_json payload is refused rather than written out empty
export_yaml({"data": records}, "out.yaml") # ValidationError
# ...and so is one that names a recognized key but leaves it empty
export_yaml({"entities": [], "data": records}, "out.yaml") # ValidationError
```
The value under a recognized key must be a collection of records — a list or
tuple of mappings or objects. A string, a bare mapping, or a scalar raises
`ValidationError` naming the key, rather than being iterated into
character-sized "records" or surfacing as a `TypeError` from inside the
exporter. `None` is read as an absent collection, the same as `[]`.
```python
export_yaml({"entities": "abc"}, "out.yaml") # ValidationError
export_yaml({"entities": 42}, "out.yaml") # ValidationError
export_yaml({"nodes": {"id": "n1"}}, "out.yaml") # ValidationError — wrap it in a list
```
An empty mapping is still accepted: an empty graph is a legitimate export and
has no records to lose.
## OWL Export
### OWL/XML Format
@@ -530,23 +479,6 @@ Pass `validate=True` to run a post-export integrity check before returning:
export_neo4j_csv(kg, "neo4j_import/", validate=True)
```
#### Accepted Input
Mapping payloads are read on the same terms as the YAML exporters (see [Accepted
Input](#accepted-input) above): `entities`/`relationships`, with `nodes`/`edges`
accepted as aliases. A non-empty mapping that supplies neither — or that supplies
a malformed collection value — raises `ValidationError` rather than writing
header-only CSVs indistinguishable from a genuinely exported empty graph. The
payload is normalized before any file is opened, so a rejected export writes
nothing.
Graph *objects* are unaffected: they are still read off `nodes`/`entities` and
`edges`/`relationships` attributes.
```python
export_neo4j_csv({"data": [{"id": "e1"}]}, "neo4j_import/") # ValidationError
```
#### Importing into Neo4j
Once the CSV files are generated, they can be imported into a new Neo4j database using the `neo4j-admin database import` command:
+25 -34
View File
@@ -24,14 +24,14 @@ License: MIT
"""
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.helpers import ensure_directory, utc_now_iso, write_json_file
from ..utils.helpers import ensure_directory, write_json_file
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from .rdf_exporter import SEMANTICA_NS, mint_entity_iri, mint_relationship_iri
class JSONExporter:
@@ -265,12 +265,11 @@ class JSONExporter:
json_data = {
"@context": {
"@vocab": "https://semantica.dev/vocab/",
"semantica": SEMANTICA_NS,
"entities": {"@id": "semantica:entities", "@container": "@list"},
},
"entities": entities,
"metadata": {
"exported_at": utc_now_iso(),
"exported_at": datetime.now().isoformat(),
"entity_count": len(entities),
**options.get("metadata", {}),
},
@@ -295,7 +294,6 @@ class JSONExporter:
json_data = {
"@context": {
"@vocab": "https://semantica.dev/vocab/",
"semantica": SEMANTICA_NS,
"relationships": {
"@id": "semantica:relationships",
"@container": "@list",
@@ -303,7 +301,7 @@ class JSONExporter:
},
"relationships": relationships,
"metadata": {
"exported_at": utc_now_iso(),
"exported_at": datetime.now().isoformat(),
"relationship_count": len(relationships),
**options.get("metadata", {}),
},
@@ -341,7 +339,7 @@ class JSONExporter:
if include_metadata:
if "metadata" not in result:
result["metadata"] = {}
result["metadata"]["exported_at"] = utc_now_iso()
result["metadata"]["exported_at"] = datetime.now().isoformat()
if include_provenance:
result["metadata"]["format"] = "json"
@@ -351,7 +349,7 @@ class JSONExporter:
"data": data,
"count": len(data),
"metadata": {
"exported_at": utc_now_iso(),
"exported_at": datetime.now().isoformat(),
"format": "json" if include_provenance else None,
**options.get("metadata", {}),
},
@@ -360,7 +358,7 @@ class JSONExporter:
# Single value
return {
"value": data,
"metadata": {"exported_at": utc_now_iso()}
"metadata": {"exported_at": datetime.now().isoformat()}
if include_metadata
else {},
}
@@ -412,9 +410,9 @@ class JSONExporter:
# Add metadata and provenance if requested
if include_metadata:
jsonld["@id"] = f"https://semantica.dev/data/{utc_now_iso()}"
jsonld["@id"] = f"https://semantica.dev/data/{datetime.now().isoformat()}"
if include_provenance:
jsonld["semantica:exportedAt"] = utc_now_iso()
jsonld["semantica:exportedAt"] = datetime.now().isoformat()
jsonld["semantica:format"] = "json-ld"
return jsonld
@@ -446,7 +444,7 @@ class JSONExporter:
"nodes": kg.get("nodes", []),
"edges": kg.get("edges", []),
"metadata": {
"exported_at": utc_now_iso(),
"exported_at": datetime.now().isoformat(),
**kg.get("metadata", {}),
**options.get("metadata", {}),
},
@@ -483,7 +481,7 @@ class JSONExporter:
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
},
"@id": f"https://semantica.dev/graph/{utc_now_iso()}",
"@id": f"https://semantica.dev/graph/{datetime.now().isoformat()}",
"@type": "semantica:KnowledgeGraph",
}
@@ -497,15 +495,14 @@ class JSONExporter:
relationships = kg.get("relationships", [])
if relationships:
jsonld["semantica:relationships"] = [
self._relationship_to_jsonld(r, index)
for index, r in enumerate(relationships)
self._relationship_to_jsonld(r) for r in relationships
]
self.logger.debug(
f"Converted {len(relationships)} relationship(s) to JSON-LD"
)
# Add metadata
jsonld["semantica:exportedAt"] = utc_now_iso()
jsonld["semantica:exportedAt"] = datetime.now().isoformat()
if "metadata" in kg:
jsonld["semantica:metadata"] = kg["metadata"]
@@ -529,13 +526,11 @@ class JSONExporter:
Returns:
Dictionary in JSON-LD format representing the entity
"""
# Generate @id if not provided. Minted exactly as the RDF serializers
# mint it (#1101), so the JSON-LD and Turtle exports of one knowledge
# graph name the same entity with the same IRI. Interpolating the raw
# text into f"semantica:entity/{text}" produced an invalid IRI for any
# text containing a space, and a JSON-LD parser dropped the whole node.
entity_text = entity.get("text") or entity.get("label", "unknown")
entity_id = entity.get("id") or mint_entity_iri(entity_text)
# Generate @id if not provided
entity_id = entity.get("id")
if not entity_id:
entity_text = entity.get("text") or entity.get("label", "unknown")
entity_id = f"semantica:entity/{entity_text}"
jsonld = {
"@id": entity_id,
@@ -550,9 +545,7 @@ class JSONExporter:
return jsonld
def _relationship_to_jsonld(
self, rel: Dict[str, Any], index: int = 0
) -> Dict[str, Any]:
def _relationship_to_jsonld(self, rel: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert relationship to JSON-LD format.
@@ -567,18 +560,16 @@ class JSONExporter:
- type: Relationship type (optional)
- confidence: Confidence score (optional)
- metadata: Metadata dictionary (optional)
index: Position of the relationship in the exported list, used when
minting an IRI for a relationship that arrived without an id
Returns:
Dictionary in JSON-LD format representing the relationship
"""
# Generate @id if not provided, from the same mint the RDF serializers
# use, including the list index that separates two relationships
# sharing a pair of endpoints (#1101).
source_id = rel.get("source_id") or rel.get("source", "")
target_id = rel.get("target_id") or rel.get("target", "")
rel_id = rel.get("id") or mint_relationship_iri(index, source_id, target_id)
# Generate @id if not provided
rel_id = rel.get("id")
if not rel_id:
source_id = rel.get("source_id") or rel.get("source", "")
target_id = rel.get("target_id") or rel.get("target", "")
rel_id = f"semantica:rel/{source_id}_{target_id}"
jsonld = {
"@id": rel_id,
+12 -28
View File
@@ -24,7 +24,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.helpers import _require_mapping, ensure_directory, normalize_graph_payload
from ..utils.helpers import ensure_directory
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
@@ -154,26 +154,15 @@ class LPGExporter:
"""
queries = []
# A non-mapping payload cannot reach normalize_graph_payload(): it
# raises ValidationError for that case, which would leave this
# exporter alone in raising a different exception type than the YAML
# and Neo4j exporters raise for the identical mistake.
_require_mapping(
knowledge_graph, ("entities", "relationships", "nodes", "edges")
)
# Accept either vocabulary. Reading 'nodes' with 'entities' as the
# default dropped every entity when 'nodes' was present but empty --
# the shape JSONExporter emits -- so resolution is centralized.
normalized = normalize_graph_payload(knowledge_graph)
nodes = normalized["entities"]
edges = normalized["relationships"]
# Generate indexes if requested. Fed the normalized entities so index
# generation sees the same records as node generation; reading
# 'entities' directly here skipped indexes for nodes/edges payloads.
# Generate indexes if requested
if self.include_indexes:
queries.extend(self._generate_indexes(nodes))
queries.extend(self._generate_indexes(knowledge_graph))
# Extract entities and relationships
entities = knowledge_graph.get("entities", [])
relationships = knowledge_graph.get("relationships", [])
nodes = knowledge_graph.get("nodes", entities)
edges = knowledge_graph.get("edges", relationships)
# Generate node creation queries
node_queries = self._generate_node_queries(nodes)
@@ -185,18 +174,13 @@ class LPGExporter:
return queries
def _generate_indexes(self, entities: List[Dict[str, Any]]) -> List[str]:
"""Generate Cypher index and constraint creation queries.
Args:
entities: Entity records, already resolved from whichever
vocabulary the caller supplied.
"""
def _generate_indexes(self, knowledge_graph: Dict[str, Any]) -> List[str]:
"""Generate Cypher index and constraint creation queries."""
indexes = []
# Get unique entity types for labels
entity_types = set()
for entity in entities:
for entity in knowledge_graph.get("entities", []):
entity_type = entity.get("type") or entity.get("entity_type")
if entity_type:
entity_types.add(entity_type)
+2 -20
View File
@@ -494,7 +494,7 @@ def export_graph(
def export_yaml(
data: Dict[str, Any],
data: Union[Dict[str, Any], List[Dict[str, Any]]],
file_path: Union[str, Path],
method: str = "semantic_network",
**kwargs,
@@ -504,32 +504,14 @@ def export_yaml(
This is a user-friendly wrapper that exports data to YAML format.
Unlike :func:`export_json` and :func:`export_csv`, which treat a list as
opaque records, both YAML methods are keyed formats: they distinguish
entities from relationships from triplets (and classes from properties
for ``method="schema"``). A bare list is therefore rejected rather than
guessed at, since inferring which collection it represents would silently
mislabel the records.
Args:
data: Data to export, as a mapping. For ``method="semantic_network"``,
keyed by 'entities'/'relationships'/'triplets'; for
``method="schema"``, by 'classes'/'properties'.
data: Data to export (semantic network, entities, relationships)
file_path: Output YAML file path
method: Export method (default: "semantic_network")
- "semantic_network": Semantic network YAML export
- "schema": Schema YAML export
**kwargs: Additional options passed to YAML exporters
Raises:
ProcessingError: if ``data`` is not a mapping, or if ``method`` is not
a known YAML export method.
ValidationError: if ``data`` is a mapping whose keys the selected
exporter does not read -- an ``export_json`` envelope
(``{"data": [...], "count": N, "metadata": {...}}``) is the
common case. Such a payload used to be written out as a valid
YAML file with every collection empty.
Examples:
>>> from semantica.export.methods import export_yaml
>>> export_yaml(semantic_network, "network.yaml", method="semantic_network")
+4 -28
View File
@@ -32,13 +32,12 @@ from __future__ import annotations
import csv
import hashlib
import json
from collections.abc import Mapping
from dataclasses import asdict, is_dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Union
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.helpers import ensure_directory, normalize_graph_payload
from ..utils.helpers import ensure_directory
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
@@ -174,19 +173,6 @@ class Neo4jCSVExporter:
Returns:
Mapping with ``"nodes"`` and ``"relationships"`` output paths.
Raises:
ValidationError: if a mapping payload carries no recognized graph
key, resolves to nothing while an unread key still holds
records, or holds something other than records under one --
see
:func:`~semantica.utils.helpers.normalize_graph_payload`.
Each would otherwise be written out as header-only CSVs
indistinguishable from a genuinely empty graph. The payload is
normalized before any file is opened, so a rejected export
writes nothing.
ProcessingError: if a non-mapping payload exposes none of the
graph attributes.
"""
output_dir = Path(output_dir)
ensure_directory(output_dir)
@@ -508,19 +494,9 @@ class Neo4jCSVExporter:
return prepared
def _normalize_graph(self, graph: Any) -> Dict[str, List[Dict[str, Any]]]:
if isinstance(graph, Mapping):
# Mapping payloads go through the shared resolver on its default
# terms, so this backend cannot drift from the others: an
# unrecognized mapping raises here rather than writing header-only
# CSVs that read as a successful export of an empty graph. Checked
# against Mapping rather than dict, so a non-dict Mapping (a
# MappingProxyType, a ChainMap) takes this path too, instead of
# falling through to the attribute branch below and being rejected
# as an unrecognized object -- the LPG, Arango, and YAML exporters
# already accept such payloads via the same resolver.
resolved = normalize_graph_payload(graph)
nodes = resolved["entities"]
relationships = resolved["relationships"]
if isinstance(graph, dict):
nodes = graph.get("nodes") or graph.get("entities") or []
relationships = graph.get("edges") or graph.get("relationships") or []
else:
nodes = getattr(graph, "nodes", None)
if nodes is None:
+29 -64
View File
@@ -33,42 +33,11 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Union
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.helpers import ensure_directory, hash_data
from ..utils.helpers import ensure_directory
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
SEMANTICA_NS = "https://semantica.dev/ns#"
#: Written when an entity carries no type of its own. A full IRI rather than the
#: prefixed form, because the Turtle serializer writes it inside angle brackets,
#: where `semantica:Entity` would be read as an IRI in the scheme `semantica`
#: rather than as the prefix expansion (issue #1101).
DEFAULT_ENTITY_TYPE = f"{SEMANTICA_NS}Entity"
#: Written when a relationship carries no type of its own. Same reasoning.
DEFAULT_RELATION_TYPE = f"{SEMANTICA_NS}related_to"
def mint_entity_iri(text: str) -> str:
"""Mint a stable IRI for an entity that arrived without an id.
Python's builtin ``hash()`` is randomised per process (PYTHONHASHSEED), so
minting from it gave the same entity a different IRI on every run: exports
could not be diffed, deduplicated against an earlier load, or joined to a
provenance record written by an earlier process. SHA-256 is stable across
runs and machines, which is what an identifier has to be.
"""
digest = hash_data(str(text))[:16]
return f"{SEMANTICA_NS}entity_{digest}"
def mint_relationship_iri(index: int, source: Any, target: Any) -> str:
"""Mint a stable IRI for a relationship that arrived without an id."""
digest = hash_data(f"{source}\x00{target}")[:16]
return f"{SEMANTICA_NS}rel_{index}_{digest}"
class NamespaceManager:
"""
RDF namespace management engine.
@@ -391,9 +360,9 @@ class RDFSerializer:
entity_id = entity.get("id")
if not entity_id:
entity_text = entity.get("text", "")
entity_id = mint_entity_iri(entity_text)
entity_id = f"semantica:entity_{hash(entity_text)}"
entity_type = entity.get("type", DEFAULT_ENTITY_TYPE)
entity_type = entity.get("type", "semantica:Entity")
text = entity.get("text") or entity.get("label", "")
confidence = entity.get("confidence", 1.0)
@@ -407,7 +376,7 @@ class RDFSerializer:
for idx, rel in enumerate(relationships):
source_id = rel.get("source_id") or rel.get("source")
target_id = rel.get("target_id") or rel.get("target")
rel_type = rel.get("type", DEFAULT_RELATION_TYPE)
rel_type = rel.get("type", "semantica:related_to")
lines.append(f"<{source_id}> <{rel_type}> <{target_id}> .")
@@ -444,14 +413,10 @@ class RDFSerializer:
if time_axis in ("transaction", "both"):
axes.append(("tx", rel.get("recorded_at"), rel.get("superseded_at")))
# Resolve endpoints the same way serialize_to_turtle does: both
# representations are accepted upstream, and minting from source_id
# alone hashes empty strings for every relationship that uses source,
# so unrelated relationships at the same index would collide on a
# deterministic IRI.
source_id = rel.get("source_id") or rel.get("source") or ""
target_id = rel.get("target_id") or rel.get("target") or ""
rel_base_id = rel.get("id") or mint_relationship_iri(idx, source_id, target_id)
rel_base_id = (
rel.get("id")
or f"semantica:rel_{idx}_{hash(str(rel.get('source_id', '')) + str(rel.get('target_id', '')))}"
)
lines = [""] # blank separator
for axis_name, from_val, until_val in axes:
@@ -523,9 +488,9 @@ class RDFSerializer:
entity_id = entity.get("id")
if not entity_id:
entity_text = entity.get("text", "")
entity_id = mint_entity_iri(entity_text)
entity_id = f"semantica:entity_{hash(entity_text)}"
entity_type = entity.get("type", DEFAULT_ENTITY_TYPE)
entity_type = entity.get("type", "semantica:Entity")
text = entity.get("text") or entity.get("label", "")
confidence = entity.get("confidence", 1.0)
@@ -600,13 +565,11 @@ class RDFSerializer:
# Convert entities to JSON-LD
entities = rdf_data.get("entities", [])
for entity in entities:
# Generate @id if not provided. Minted the same way the Turtle and
# N-Triples paths mint it (#1101), so one knowledge graph carries
# the same node identity whichever serializer wrote it. The former
# f"semantica:entity/{text}" interpolated the raw text into an IRI:
# any entity whose text contained a space produced an invalid IRI
# and was dropped in full by a JSON-LD parser, silently.
entity_id = entity.get("id") or mint_entity_iri(entity.get("text", ""))
# Generate @id if not provided
entity_id = entity.get("id")
if not entity_id:
entity_text = entity.get("text", "")
entity_id = f"semantica:entity/{entity_text}"
jsonld["@graph"].append(
{
@@ -619,22 +582,24 @@ class RDFSerializer:
# Convert relationships to JSON-LD
relationships = rdf_data.get("relationships", [])
for index, rel in enumerate(relationships):
# Endpoints are resolved both ways, as serialize_to_turtle resolves
# them: a relationship carrying source/target rather than
# source_id/target_id used to hash into f"semantica:rel/_", so every
# such relationship in an export collapsed onto one node and their
# types and endpoints merged.
source = rel.get("source_id") or rel.get("source", "")
target = rel.get("target_id") or rel.get("target", "")
rel_id = rel.get("id") or mint_relationship_iri(index, source, target)
for rel in relationships:
# Generate @id if not provided
rel_id = rel.get("id")
if not rel_id:
source_id = rel.get("source_id", "")
target_id = rel.get("target_id", "")
rel_id = f"semantica:rel/{source_id}_{target_id}"
jsonld["@graph"].append(
{
"@id": rel_id,
"@type": "semantica:Relationship",
"semantica:source": {"@id": source},
"semantica:target": {"@id": target},
"semantica:source": {
"@id": rel.get("source_id") or rel.get("source")
},
"semantica:target": {
"@id": rel.get("target_id") or rel.get("target")
},
"semantica:type": rel.get("type", "related_to"),
}
)
@@ -679,7 +644,7 @@ class RDFSerializer:
entity_id = entity.get("id")
if not entity_id:
entity_text = entity.get("text", "")
entity_id = mint_entity_iri(entity_text)
entity_id = f"semantica:entity_{hash(entity_text)}"
subject = expand_uri(entity_id)
+11 -20
View File
@@ -23,13 +23,13 @@ Author: Semantica Contributors
License: MIT
"""
import html
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.helpers import ensure_directory, utc_now_iso
from ..utils.helpers import ensure_directory
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
@@ -251,7 +251,7 @@ class ReportGenerator:
# Build report data with summary
report_data = {
"title": "Quality Assurance Report",
"generated_at": utc_now_iso(),
"generated_at": datetime.now().isoformat(),
"metrics": quality_metrics,
"summary": self._generate_quality_summary(quality_metrics),
}
@@ -277,7 +277,7 @@ class ReportGenerator:
"""
report_data = {
"title": "Analysis Report",
"generated_at": utc_now_iso(),
"generated_at": datetime.now().isoformat(),
"analysis": analysis_results,
"summary": self._generate_analysis_summary(analysis_results),
}
@@ -303,7 +303,7 @@ class ReportGenerator:
"""
report_data = {
"title": "Framework Metrics Report",
"generated_at": utc_now_iso(),
"generated_at": datetime.now().isoformat(),
"metrics": metrics,
"summary": self._generate_metrics_summary(metrics),
}
@@ -362,7 +362,7 @@ class ReportGenerator:
' <meta name="viewport" content="width=device-width, initial-scale=1.0">'
)
title = data.get("title", "Report")
lines.append(f" <title>{html.escape(str(title))}</title>")
lines.append(f" <title>{title}</title>")
lines.append(" <style>")
lines.append(" body { font-family: Arial, sans-serif; margin: 20px; }")
lines.append(" h1 { color: #333; }")
@@ -380,14 +380,11 @@ class ReportGenerator:
# Title
title = data.get("title", "Report")
lines.append(f" <h1>{html.escape(str(title))}</h1>")
lines.append(f" <h1>{title}</h1>")
# Generated at
if "generated_at" in data:
lines.append(
f' <p><strong>Generated:</strong> '
f'{html.escape(str(data["generated_at"]))}</p>'
)
lines.append(f' <p><strong>Generated:</strong> {data["generated_at"]}</p>')
# Summary
if "summary" in data:
@@ -396,13 +393,10 @@ class ReportGenerator:
if isinstance(summary, dict):
lines.append(" <ul>")
for key, value in summary.items():
lines.append(
f" <li><strong>{html.escape(str(key))}:</strong> "
f"{html.escape(str(value))}</li>"
)
lines.append(f" <li><strong>{key}:</strong> {value}</li>")
lines.append(" </ul>")
else:
lines.append(f" <p>{html.escape(str(summary))}</p>")
lines.append(f" <p>{summary}</p>")
# Metrics
if "metrics" in data:
@@ -489,10 +483,7 @@ class ReportGenerator:
else:
value_str = str(value)
lines.append(
f" <tr><td>{html.escape(str(key))}</td>"
f"<td>{html.escape(value_str)}</td></tr>"
)
lines.append(f" <tr><td>{key}</td><td>{value_str}</td></tr>")
lines.append(" </table>")
+23 -184
View File
@@ -21,83 +21,15 @@ Author: Semantica Contributors
License: MIT
"""
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from ..utils.exceptions import ValidationError
from ..utils.helpers import (
_require_mapping,
_require_nothing_dropped,
_require_recognized_keys,
ensure_directory,
normalize_graph_payload,
utc_now_iso,
)
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.helpers import ensure_directory
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
# Keys YAMLSchemaExporter.export_ontology_schema reads. Graph payloads use the
# recognized set owned by normalize_graph_payload() instead; schemas are a
# separate vocabulary with no aliasing, so the set lives here.
_SCHEMA_KEYS = (
"classes",
"properties",
"namespaces",
"uri",
"title",
"description",
"version",
)
def _require_usable_schema(ontology: Mapping) -> None:
"""Reject a schema mapping this exporter cannot read.
Two ways an ontology mapping produces an empty file: it shares no key with
the recognized set at all, or it names a recognized key that is empty
while the real records sit under a key this exporter does not read
(``{"classes": [], "nodes": [...]}``). Both are refused, using the same
checks the graph payloads go through, so the two vocabularies cannot drift
apart in what they consider a silent-empty export.
An empty mapping is allowed through: it carries nothing that could be
lost, and an empty export is a legitimate result.
Note the deliberate split in exception types, which the codebase already
makes: a wrong *type* cannot be exported at all and raises
ProcessingError, matching ``Neo4jCSVExporter._normalize_graph``; a mapping
whose *contents* are unusable raises ValidationError, matching
``normalize_graph_payload``.
Args:
ontology: Mapping already checked by :func:`_require_mapping`.
Raises:
ValidationError: if the mapping shares no key with ``_SCHEMA_KEYS``,
or resolves to nothing while an unread key still holds records.
"""
_require_recognized_keys(ontology, _SCHEMA_KEYS, what="Ontology schema")
# Only non-empty list/tuple values from recognized schema keys count as
# evidence that records survived export. Scalar metadata fields such as
# 'uri', 'title', 'description', and 'version' are truthy strings, but
# their presence does not mean the caller's record collections were
# exported -- passing them as ``resolved`` would let any scalar value
# short-circuit the dropped-records check and silently discard a list
# under an unread key alongside e.g. {"version": "1.0", "nodes": [...]}.
resolved = [
v
for key in _SCHEMA_KEYS
for v in (ontology.get(key),)
if isinstance(v, (list, tuple)) and v
]
_require_nothing_dropped(
ontology,
_SCHEMA_KEYS,
resolved,
what="Ontology schema",
)
class SemanticNetworkYAMLExporter:
"""
@@ -158,39 +90,15 @@ class SemanticNetworkYAMLExporter:
Args:
semantic_network: Semantic network dictionary containing:
- entities: List of entity dictionaries (alias: 'nodes')
- entities: List of entity dictionaries
- relationships: List of relationship dictionaries
(alias: 'edges')
- triplets: List of triplet dictionaries (optional)
- metadata: Metadata dictionary (optional)
Key resolution is delegated to
:func:`~semantica.utils.helpers.normalize_graph_payload`, so
``ContextGraph.to_dict()`` output ('nodes'/'edges') exports
directly.
**options: Additional export options (unused)
Returns:
String containing YAML representation of semantic network
Raises:
ProcessingError: if ``semantic_network`` is not a mapping. A bare
list of records cannot be exported here because this format
distinguishes entities, relationships, and triplets, and
guessing which one a list represents would silently mislabel
it.
ValidationError: if the mapping carries both spellings of a
collection with different contents; if it is non-empty and
shares no key with the recognized set; or if it resolves to
nothing while an unread key still holds records
(``{"entities": [], "data": [...]}``). Each previously
serialized to a file with every collection empty while the log
reported success. An empty mapping is still accepted -- it has
no records to lose. Note that 'metadata' alone is not a
recognized key: an ``export_json`` envelope carries one, and
accepting it would readmit the silent-empty export it is the
most likely source of.
Example:
>>> network = {
... "entities": [...],
@@ -199,8 +107,6 @@ class SemanticNetworkYAMLExporter:
... }
>>> yaml_str = exporter.export_semantic_network(network)
"""
_require_mapping(semantic_network, ("entities", "relationships", "triplets"))
# Track YAML export
tracking_id = self.progress_tracker.start_tracking(
file=None,
@@ -213,14 +119,15 @@ class SemanticNetworkYAMLExporter:
self.progress_tracker.update_tracking(
tracking_id, message="Preparing YAML data..."
)
records = normalize_graph_payload(semantic_network)
yaml_data = {
"metadata": {
"exported_at": utc_now_iso(),
"exported_at": datetime.now().isoformat(),
"version": "1.0",
**semantic_network.get("metadata", {}),
},
**records,
"entities": semantic_network.get("entities", []),
"relationships": semantic_network.get("relationships", []),
"triplets": semantic_network.get("triplets", []),
}
self.progress_tracker.update_tracking(
@@ -233,7 +140,7 @@ class SemanticNetworkYAMLExporter:
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message="Serialized semantic network to YAML",
message="Exported semantic network to YAML",
)
return result
@@ -253,46 +160,16 @@ class SemanticNetworkYAMLExporter:
data: Data to export
file_path: Output file path
**options: Additional options
Raises:
ProcessingError: if ``data`` is not a mapping.
ValidationError: on the mappings :meth:`export_semantic_network`
rejects. Serialization runs before the output directory is
created, so a rejected export leaves nothing behind.
OSError: if the file cannot be written. The write is tracked
separately from serialization, so no progress entry reports a
completed export until the bytes are on disk.
"""
file_path = Path(file_path)
ensure_directory(file_path.parent)
yaml_content = self.export_semantic_network(data, **options)
# Serialization reports its own completion, but it says nothing about
# the file: without this second span, a failing write would leave the
# tracker showing a completed export and no output.
tracking_id = self.progress_tracker.start_tracking(
file=str(file_path),
module="export",
submodule="SemanticNetworkYAMLExporter",
message=f"Writing YAML to {file_path}",
)
with open(file_path, "w", encoding="utf-8") as f:
f.write(yaml_content)
try:
ensure_directory(file_path.parent)
with open(file_path, "w", encoding="utf-8") as f:
f.write(yaml_content)
self.logger.info(f"Exported YAML to: {file_path}")
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message=f"Exported YAML to: {file_path}",
)
except Exception as e:
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message=str(e)
)
raise
self.logger.info(f"Exported YAML to: {file_path}")
def export_entities(
self, entities: List[Dict[str, Any]], include_metadata: bool = True, **options
@@ -309,7 +186,7 @@ class SemanticNetworkYAMLExporter:
if include_metadata:
yaml_data["metadata"] = {
"exported_at": utc_now_iso(),
"exported_at": datetime.now().isoformat(),
"entity_count": len(entities),
}
@@ -333,7 +210,7 @@ class SemanticNetworkYAMLExporter:
if include_properties:
yaml_data["metadata"] = {
"exported_at": utc_now_iso(),
"exported_at": datetime.now().isoformat(),
"relationship_count": len(relationships),
}
@@ -370,7 +247,7 @@ class SemanticNetworkYAMLExporter:
}
yaml_data["metadata"] = {
"exported_at": utc_now_iso(),
"exported_at": datetime.now().isoformat(),
"triplet_count": len(triplets),
}
@@ -386,34 +263,18 @@ class SemanticNetworkYAMLExporter:
Structure for definition generation
Include extraction metadata
Return pipeline-ready YAML
Args:
extracted_data: Semantic network mapping, read through
:func:`~semantica.utils.helpers.normalize_graph_payload` on
the same terms as :meth:`export_semantic_network`.
pipeline_stage: Stage number recorded in the output.
**options: Additional export options (unused)
Returns:
Pipeline-ready YAML string.
Raises:
ProcessingError: if ``extracted_data`` is not a mapping.
ValidationError: on the same mappings as
:meth:`export_semantic_network` -- this method built its
nested semantic network from the same defaulted lookups and
so had the same silent-empty failure.
"""
_require_mapping(extracted_data, ("entities", "relationships", "triplets"))
semantic_network = normalize_graph_payload(extracted_data)
yaml_data = {
"pipeline_stage": pipeline_stage,
"metadata": {
"extracted_at": utc_now_iso(),
"extracted_at": datetime.now().isoformat(),
**extracted_data.get("metadata", {}),
},
"semantic_network": semantic_network,
"semantic_network": {
"entities": extracted_data.get("entities", []),
"relationships": extracted_data.get("relationships", []),
"triplets": extracted_data.get("triplets", []),
},
}
return self.yaml.dump(yaml_data, default_flow_style=False, sort_keys=False)
@@ -447,29 +308,7 @@ class YAMLSchemaExporter:
Include hierarchies and constraints
Structure for easy editing
Return YAML schema
Args:
ontology: Ontology mapping keyed by any of 'classes',
'properties', 'namespaces', 'uri', 'title', 'description',
'version'.
**options: Additional export options (unused)
Returns:
YAML schema string.
Raises:
ProcessingError: if ``ontology`` is not a mapping.
ValidationError: if ``ontology`` is a non-empty mapping sharing
no key with the recognized set, or resolves to nothing while
an unread key still holds records
(``{"classes": [], "nodes": [...]}``) -- each previously
produced a file with empty 'classes', 'properties' and
'namespaces' and no indication anything was dropped. An empty
mapping is still accepted.
"""
_require_mapping(ontology, ("classes", "properties"))
_require_usable_schema(ontology)
yaml_data = {
"ontology": {
"uri": ontology.get("uri", ""),
-11
View File
@@ -66,12 +66,6 @@ except (ImportError, OSError):
# Helpers
# ---------------------------------------------------------------------------
# create_index's index_type reaches a raw SQL keyword position (`USING
# {index_type}`) that can't be bound as a query parameter; only the
# documented, PostgreSQL-recognized types are allowed through.
_ALLOWED_INDEX_TYPES = frozenset({"btree", "gin", "hash", "gist", "brin"})
def _sanitize_label(label: str) -> str:
"""
Sanitize a Cypher label to prevent injection.
@@ -1220,11 +1214,6 @@ class ApacheAgeStore:
safe_label = _sanitize_label(label)
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", property_name):
raise ValidationError(f"Invalid property name: '{property_name}'")
if index_type not in _ALLOWED_INDEX_TYPES:
raise ValidationError(
f"Invalid index_type: {index_type!r}. "
f"Allowed: {sorted(_ALLOWED_INDEX_TYPES)}"
)
index_name = options.get(
"index_name", f"idx_{self.graph_name}_{safe_label}_{property_name}"
-19
View File
@@ -503,16 +503,6 @@ class Neo4jStore:
Returns:
List of matching nodes
"""
# LIMIT can't be bound as a query parameter in a way Neo4j accepts
# here, so it's interpolated directly; validate explicitly rather
# than trust the `limit: int` type hint, which Python doesn't
# enforce at runtime. Done outside the try/except below so a bad
# limit raises ValidationError, not a generic ProcessingError.
try:
limit = int(limit)
except (TypeError, ValueError) as exc:
raise ValidationError(f"Invalid limit: {limit!r}") from exc
try:
# Build query
if labels:
@@ -708,15 +698,6 @@ class Neo4jStore:
Returns:
List of matching relationships
"""
# See get_nodes: LIMIT is interpolated directly, so validate
# explicitly rather than trust the unenforced `limit: int` hint,
# outside the try/except below so a bad limit raises
# ValidationError, not a generic ProcessingError.
try:
limit = int(limit)
except (TypeError, ValueError) as exc:
raise ValidationError(f"Invalid limit: {limit!r}") from exc
try:
type_filter = f":{sanitize_identifier(rel_type, 'relationship type')}" if rel_type else ""
+5 -115
View File
@@ -29,7 +29,6 @@ License: MIT
"""
import json
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -39,96 +38,6 @@ from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Fragments that turn a filter/order clause into a second statement, a
# data-exfiltration UNION, a time-based blind-injection oracle, or schema
# enumeration, rather than a boolean/ordering expression.
_SQL_FRAGMENT_BLOCKLIST_RE = re.compile(
r";|--|/\*|\*/|\bunion\b|\binsert\b|\bupdate\b|\bdelete\b|\bdrop\b|"
r"\balter\b|\bcreate\b|\bexec\b|\bexecute\b|\bgrant\b|\brevoke\b|"
r"\battach\b|\bpragma\b|\bxp_\w+|\bsp_\w+|\binto\s+outfile\b|\bload_file\b|"
r"\bsleep\s*\(|\bbenchmark\s*\(|\bpg_sleep\s*\(|\bwaitfor\b|"
r"\bdbms_\w+|\butl_\w+|\binformation_schema\b|\bpg_catalog\b",
re.IGNORECASE,
)
# SQL single-quoted string literals ('' is the standard escaped-quote) and
# double-quoted identifiers ("" likewise) — matched only when properly
# closed, so a malformed/unterminated quote sequence is left alone and
# still hits the blocklist above rather than being treated as "inside a
# literal" and skipped.
_SQL_STRING_LITERAL_RE = re.compile(r"'(?:[^']|'')*'")
_SQL_QUOTED_IDENTIFIER_RE = re.compile(r'"(?:[^"]|"")*"')
def _mask_sql_literals(fragment: str) -> str:
"""Blank the contents of quoted literals so they can't trip the blocklist.
A legitimate value or quoted identifier that happens to contain a
blocked word or character as *data* e.g. ``status = 'union'`` or
``"my--column" = 1`` is not SQL syntax and shouldn't be rejected as
if it were. Only the quoted span's interior is replaced (with `?`,
keeping the surrounding quotes and the fragment's length/positions
intact for the error message); text outside any properly closed quote
is passed through unchanged and still fully scrutinized.
"""
fragment = _SQL_STRING_LITERAL_RE.sub(
lambda m: "'" + "?" * (len(m.group(0)) - 2) + "'", fragment
)
fragment = _SQL_QUOTED_IDENTIFIER_RE.sub(
lambda m: '"' + "?" * (len(m.group(0)) - 2) + '"', fragment
)
return fragment
def _validate_sql_identifier(name: str, kind: str) -> str:
"""Validate a table/schema name used as a raw SQL identifier.
``export_table_data`` interpolates *name* directly into the query text
(SQLAlchemy has no bind-parameter syntax for identifiers), so anything
outside a plain alphanumeric/underscore identifier is a potential
breakout of the surrounding ``"..."`` quoting.
"""
if not isinstance(name, str) or not _IDENTIFIER_RE.match(name):
raise ValidationError(
f"Invalid {kind}: {name!r}. Must start with a letter or "
"underscore and contain only alphanumeric characters and "
"underscores."
)
return name
def _validate_sql_fragment(fragment: str, kind: str) -> str:
"""Reject WHERE/ORDER BY fragments that smuggle a second statement.
These clauses can't be bound as query parameters (they're arbitrary
boolean/ordering expressions, not values), so this blocks the concrete
injection primitives (statement separators, comments, UNION, DML/DDL
keywords, time-based blind oracles, schema enumeration) rather than
parameterizing.
This is a blocklist, not a grammar: it cannot exhaustively prove
*fragment* is safe, only reject known-dangerous constructs, so a
boolean-blind subquery expressed with none of the blocked keywords
(e.g. ``id = (SELECT 1 FROM t WHERE ...)``) still passes. ``where``/
``order_by`` are a raw-SQL-fragment API by design (see
``export_table_data``'s docstring); treat them as trusted/operator
input, not something to expose directly to untrusted end users.
"""
if not isinstance(fragment, str):
raise ValidationError(f"Invalid {kind}: must be a string")
# Check the blocklist against literal-masked text so a blocked word
# appearing only as quoted data (not as SQL syntax) doesn't false-
# positive; the original, unmodified fragment is still what's returned
# and used in the query.
if _SQL_FRAGMENT_BLOCKLIST_RE.search(_mask_sql_literals(fragment)):
raise ValidationError(
f"Invalid {kind}: {fragment!r} contains disallowed SQL "
"keywords or statement-boundary characters"
)
return fragment
@dataclass
class TableData:
@@ -344,13 +253,8 @@ class DataExporter:
schema: Schema name (for databases with schema support, optional)
limit: Maximum number of rows to export (optional)
offset: Row offset for pagination (optional)
where: WHERE clause for filtering (optional, e.g., "age > 18").
Raw SQL, checked against a keyword/character blocklist (see
``_validate_sql_fragment``) but not fully sanitized treat
as trusted/operator input, never pass untrusted end-user
text here directly.
order_by: ORDER BY clause for sorting (optional, e.g., "name ASC").
Same trust requirement as ``where``.
where: WHERE clause for filtering (optional, e.g., "age > 18")
order_by: ORDER BY clause for sorting (optional, e.g., "name ASC")
**options: Additional export options (unused)
Returns:
@@ -365,16 +269,7 @@ class DataExporter:
ProcessingError: If table export fails
"""
try:
from sqlalchemy import inspect, text
_validate_sql_identifier(table_name, "table_name")
if schema:
_validate_sql_identifier(schema, "schema")
if where:
_validate_sql_fragment(where, "where")
if order_by:
_validate_sql_fragment(order_by, "order_by")
from sqlalchemy import inspect
inspector = inspect(connection)
# Get column information
@@ -449,8 +344,6 @@ class DataExporter:
schema=schema,
)
except ValidationError:
raise
except Exception as e:
self.logger.error(f"Failed to export table {table_name}: {e}")
raise ProcessingError(f"Failed to export table: {e}") from e
@@ -867,11 +760,8 @@ class DBIngestor:
schema: Schema name (for databases with schema support, optional)
limit: Maximum number of rows to export (optional)
offset: Row offset for pagination (optional)
where: WHERE clause for filtering (optional, e.g., "status = 'active'").
Raw SQL passed through to ``export_table_data`` same trust
requirement documented there: not for untrusted end-user text.
order_by: ORDER BY clause for sorting (optional, e.g., "created_at DESC").
Same trust requirement as ``where``.
where: WHERE clause for filtering (optional, e.g., "status = 'active'")
order_by: ORDER BY clause for sorting (optional, e.g., "created_at DESC")
transform: Whether to apply data transformations (default: False)
**filters: Additional filtering options (merged with above parameters)
+23 -26
View File
@@ -41,7 +41,6 @@ from typing import Any, Dict, List, Optional, Union
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from .ssrf import request_with_ssrf_guard
@dataclass
@@ -342,38 +341,36 @@ class MCPClient:
raise
def _send_request_http(self, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Send request via HTTP, with redirect-safe credential handling.
Uses ``request_with_ssrf_guard`` so that:
* ``Authorization`` / ``Proxy-Authorization`` headers are **not**
forwarded to a different origin if the MCP server issues a redirect
(issue #947).
* The redirect chain is bounded (default 10 hops).
``allow_private_ips=True`` is set because MCP servers are explicitly
configured by the operator and frequently run on localhost or an
internal network the same trust model as ``allow_private_ips`` opt-in
in the other ingestors. That trust covers only ``self.url`` itself:
``allow_private_ips_on_redirect=False`` keeps redirect targets held to
the normal public-address check, so a compromised or malicious MCP
server cannot use a redirect to route the client into private/
internal address space (e.g. cloud metadata) that the operator never
configured. Scheme validation (http/https only) and the
auth-stripping logic remain active regardless of these flags.
"""
"""Send request via HTTP."""
try:
response = request_with_ssrf_guard(
"POST",
import httpx
response = httpx.post(
self.url,
headers=self.headers,
json=request,
headers=self.headers,
timeout=self.config.get("timeout", 30.0),
allow_private_ips=True,
allow_private_ips_on_redirect=False,
)
response.raise_for_status()
return response.json()
except (ImportError, OSError):
# Fallback to requests if httpx not available
try:
import requests
response = requests.post(
self.url,
json=request,
headers=self.headers,
timeout=self.config.get("timeout", 30.0),
)
response.raise_for_status()
return response.json()
except (ImportError, OSError):
raise ProcessingError(
"HTTP transport requires 'httpx' or 'requests' package. "
"Install with: pip install httpx or pip install requests"
)
except Exception as e:
self.logger.error(f"Failed to send HTTP request: {e}")
raise
+6 -32
View File
@@ -45,7 +45,6 @@ except ModuleNotFoundError: # pragma: no cover - fallback for minimal installs
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from .api_ingestor import APIData, RESTIngestor
from .ssrf import request_with_ssrf_guard
AUTH_HEADER_NAMES = {
"authorization",
@@ -360,31 +359,18 @@ class PublicAPIIngestor(RESTIngestor):
request_options = options.copy()
timeout = request_options.pop("timeout", self.config.get("timeout", 30))
rate_limit_delay = request_options.pop("rate_limit_delay", None)
# session and allow_private_ips are always supplied explicitly below;
# drop any caller-provided copies so request_with_ssrf_guard() does
# not receive duplicate keyword arguments.
request_options.pop("session", None)
request_options.pop("allow_private_ips", None)
request_headers = self._merged_headers(headers)
try:
self._wait_if_needed(rate_limit_delay=rate_limit_delay)
# Route through the SSRF guard so that:
# * redirects to private/loopback IPs are blocked, and
# * Authorization / Proxy-Authorization are stripped on
# cross-origin redirects (issue #947).
response = request_with_ssrf_guard(
method,
endpoint,
session=self.session,
response = self.session.request(
method=method,
url=endpoint,
headers=request_headers,
params=params,
timeout=timeout,
allow_private_ips=self.allow_private_ips,
**request_options,
)
except (ValidationError, ProcessingError):
raise
except requests.exceptions.RequestException as exc:
self.logger.error(f"Failed to detect public API {endpoint}: {exc}")
raise ProcessingError(f"Failed to detect public API: {exc}") from exc
@@ -454,30 +440,18 @@ class PublicAPIIngestor(RESTIngestor):
request_options = options.copy()
timeout = request_options.pop("timeout", self.config.get("timeout", 30))
# session and allow_private_ips are always supplied explicitly below;
# drop any caller-provided copies so request_with_ssrf_guard() does
# not receive duplicate keyword arguments.
request_options.pop("session", None)
request_options.pop("allow_private_ips", None)
request_headers = self._merged_headers(headers)
try:
self._wait_if_needed(rate_limit_delay=rate_limit_delay)
# Route through the SSRF guard so that:
# * redirects to private/loopback IPs are blocked, and
# * Authorization / Proxy-Authorization are stripped on
# cross-origin redirects even when validate_no_auth=False
# (issue #947).
response = request_with_ssrf_guard(
method,
endpoint,
session=self.session,
response = self.session.request(
method=method,
url=endpoint,
headers=request_headers,
params=params,
data=data,
json=json_data,
timeout=timeout,
allow_private_ips=self.allow_private_ips,
**request_options,
)
+52 -462
View File
@@ -11,7 +11,7 @@ import concurrent.futures
import ipaddress
import socket
import threading
from typing import Any, Iterable, List, Optional
from typing import Any, Iterable, Optional
from urllib.parse import urljoin, urlparse
import requests
@@ -138,7 +138,6 @@ def _get_dns_executor() -> concurrent.futures.ThreadPoolExecutor:
BLOCKED_NETWORKS = (
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("100.64.0.0/10"), # CGNAT (RFC 6598) — routable inside carrier/cloud NAT
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud metadata
ipaddress.ip_network("172.16.0.0/12"),
@@ -263,225 +262,12 @@ def validate_url_for_request(
)
def _resolve_pinned_ips(
url: str, *, allow_private_ips: bool
) -> Optional[List[str]]:
"""Validate *url* and return every resolved IP for connection pinning.
``validate_url_for_request`` and the subsequent connection used to
resolve the same hostname independently, which reopens a DNS-rebinding
TOCTOU window: a low-TTL or rebinding DNS answer can differ between the
validation lookup and the connect-time lookup, so a hostname that
validated as public can still connect to a private/internal address.
This performs the one resolution that is actually used for both the
accept/reject decision *and* the connection (see
``_make_pinned_adapter``), closing that window the same way
``explorer/routes/ontology.py``'s ``_validate_fetch_url`` /
``_make_pinned_session`` pair already does.
Returns ``None`` when ``allow_private_ips`` is True (the caller
explicitly trusts this host, e.g. an operator-configured internal
endpoint that may rely on live DNS/service discovery pinning is
skipped so it keeps resolving normally) or when the URL has no host.
Otherwise returns the deduplicated, resolution-ordered list of
validated IP addresses.
"""
validate_url_for_request(url, allow_private_ips=allow_private_ips)
if allow_private_ips:
return None
host = urlparse(url).hostname
if not host:
return None
try:
literal_ip = ipaddress.ip_address(host)
except ValueError:
literal_ip = None
if literal_ip is not None:
return [str(literal_ip)]
executor = _get_dns_executor()
owned_executor = False
try:
try:
future = executor.submit(socket.getaddrinfo, host, None)
except RuntimeError:
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
owned_executor = True
future = executor.submit(socket.getaddrinfo, host, None)
resolved: Iterable = future.result(timeout=_DNS_RESOLVE_TIMEOUT_SECONDS)
except (socket.gaierror, concurrent.futures.TimeoutError, OSError) as exc:
raise ValidationError(
f"URL host '{host}' could not be resolved safely "
"(DNS error or timeout); request blocked"
) from exc
finally:
if owned_executor:
_shutdown_executor(executor)
pinned_ips: List[str] = []
for info in resolved:
addr = ipaddress.ip_address(info[4][0])
if _ip_is_blocked(addr):
raise ValidationError(
f"URL host '{host}' resolves to a blocked (private/loopback/"
"link-local) address"
)
addr_str = str(addr)
if addr_str not in pinned_ips:
pinned_ips.append(addr_str)
if not pinned_ips:
raise ValidationError(
f"URL host '{host}' could not be resolved to a usable address"
)
return pinned_ips
def _make_pinned_adapter(pinned_ips: List[str], hostname: str) -> "requests.adapters.HTTPAdapter":
"""Build an HTTPAdapter that connects only to *pinned_ips*.
Falls back across every pinned address in order (a hostname can have
multiple A/AAAA records) while presenting *hostname* as the TLS SNI /
certificate identity and outgoing Host header, so DNS resolution is
bypassed entirely for the actual connection mirroring
``explorer/routes/ontology.py``'s ``_make_pinned_session``.
"""
import urllib3.util.connection as _u3_connection
from urllib3.exceptions import NewConnectionError
class _MultiIPConnectionMixin:
def _new_conn(self):
last_exc: Optional[BaseException] = None
for ip in pinned_ips:
try:
return _u3_connection.create_connection(
(ip, self.port),
self.timeout,
source_address=self.source_address,
socket_options=self.socket_options,
)
except OSError as exc:
last_exc = exc
continue
raise NewConnectionError(
self,
f"Failed to establish a connection to any of {pinned_ips}: {last_exc}",
)
class _PinnedIPHTTPAdapter(requests.adapters.HTTPAdapter):
def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
# A proxy performs its own DNS resolution outside this
# process's control, which would silently reopen the exact
# rebinding race pinning exists to close. Fail closed instead.
if requests.utils.select_proxy(request.url, proxies):
raise ValidationError(
"Proxied requests are not supported through the "
"SSRF-guarded request path (a proxy would resolve the "
"host itself and bypass IP pinning)."
)
host_params, pool_kwargs = self.build_connection_pool_key_attributes(
request, verify, cert
)
if host_params.get("scheme") == "https":
pool_kwargs.setdefault("assert_hostname", hostname)
pool_kwargs.setdefault("server_hostname", hostname)
host_params["host"] = pinned_ips[0]
pool = self.poolmanager.connection_from_host(
**host_params, pool_kwargs=pool_kwargs
)
base_connection_cls = pool.ConnectionCls
if not issubclass(base_connection_cls, _MultiIPConnectionMixin):
pool.ConnectionCls = type(
"_PinnedConnection",
(_MultiIPConnectionMixin, base_connection_cls),
{},
)
return pool
return _PinnedIPHTTPAdapter()
def _apply_connection_pin(
active_session: "requests.Session",
url: str,
pinned_ips: Optional[List[str]],
orig_http_adapter: "requests.adapters.HTTPAdapter",
orig_https_adapter: "requests.adapters.HTTPAdapter",
had_host_header: bool,
orig_host_header: Optional[str],
) -> None:
"""Mount (or remove) IP pinning on *active_session* for the next hop."""
# Session.mount() silently drops whatever adapter it replaces without
# closing it. Across a multi-hop redirect chain, each hop gets its own
# fresh pinned adapter (a new pool), so failing to close the one from
# the previous hop would leak its pooled connection.
_current = active_session.adapters.get("http://")
if getattr(_current, "_semantica_pinned", False):
_current.close()
parsed = urlparse(url)
if pinned_ips:
port = parsed.port
default_port = _DEFAULT_PORTS.get(parsed.scheme, 80)
host_header = (
parsed.hostname
if port in (None, default_port)
else f"{parsed.hostname}:{port}"
)
adapter = _make_pinned_adapter(pinned_ips, parsed.hostname or "")
adapter._semantica_pinned = True
active_session.mount("http://", adapter)
active_session.mount("https://", adapter)
active_session.headers["Host"] = host_header
else:
active_session.mount("http://", orig_http_adapter)
active_session.mount("https://", orig_https_adapter)
# Restore the session's own pre-call Host header state rather than
# unconditionally clearing it — a caller-supplied session may carry
# a legitimate Host override (e.g. a private/internal endpoint
# fronted by a name that differs from the connection host), which
# a hop that happens not to need pinning must not silently drop.
if had_host_header:
active_session.headers["Host"] = orig_host_header
else:
active_session.headers.pop("Host", None)
_SESSION_LOCK_ATTR = "_semantica_ssrf_lock"
_session_lock_registry_lock = threading.Lock()
def _get_session_lock(session: "requests.Session") -> threading.Lock:
"""Return a lock private to *session*, creating one on first use.
request_with_ssrf_guard mutates a caller-supplied session's adapters
and Host header for the duration of one guarded call (including every
redirect hop). Without serializing on the session itself, two guarded
calls sharing the same session from different threads could interleave
their mount()/restore cycles one call's request could go out pinned
to (or carrying the Host header for) a completely different call's
target host. Double-checked locking so concurrent first-use doesn't
attach two different locks to the same session.
"""
lock = getattr(session, _SESSION_LOCK_ATTR, None)
if lock is not None:
return lock
with _session_lock_registry_lock:
lock = getattr(session, _SESSION_LOCK_ATTR, None)
if lock is None:
lock = threading.Lock()
setattr(session, _SESSION_LOCK_ATTR, lock)
return lock
def request_with_ssrf_guard(
method: str,
url: str,
*,
session: Optional[requests.Session] = None,
allow_private_ips: bool = False,
allow_private_ips_on_redirect: Optional[bool] = None,
max_redirects: int = _DEFAULT_MAX_REDIRECTS,
**kwargs: Any,
) -> requests.Response:
@@ -491,263 +277,67 @@ def request_with_ssrf_guard(
public URL to bounce into private/loopback/link-local space. This helper
disables automatic redirects and re-validates each ``Location`` target
before issuing the next hop.
``allow_private_ips`` trusts the caller's own *url* (e.g. an
operator-configured internal endpoint). That trust follows a redirect
only when the redirect target's host matches the original host (e.g. a
same-host path redirect on a private/localhost server); a redirect to a
*different* host is validated with ``allow_private_ips_on_redirect``
instead, which defaults to ``allow_private_ips`` for backward
compatibility but can be pinned to ``False`` by callers that want to
trust only the original host and never extend private-IP eligibility to
any other host a redirect chain might reach otherwise a private-IP-
eligible endpoint could be tricked into redirecting into arbitrary
internal address space (e.g. cloud metadata) the caller never
configured.
Authorization / credential-header handling (issue #947)
--------------------------------------------------------
Credentials are stripped from **all** sources that ``requests`` can use to
attach an ``Authorization`` header whenever a redirect changes origin:
1. ``kwargs["headers"]`` per-request header dict (already handled).
2. ``session.headers`` session-level headers that ``requests`` merges
automatically; cleared for the hop and restored via ``finally``.
3. ``kwargs["auth"]`` per-request auth tuple/callable; removed from the
local ``kwargs`` copy when stripping is required. This copy never
escapes to the caller, so there is nothing to restore.
4. ``session.auth`` session-level auth handler that ``requests`` merges
via ``merge_setting(auth, self.auth)`` inside ``prepare_request``;
cleared for the hop and restored via ``finally``.
5. ``session.trust_env`` when ``True``, ``requests`` reads ``~/.netrc``
for the *redirect target* host and calls ``prepare_auth()`` with those
credentials even after sources 3 and 4 are cleared; disabled for
cross-origin hops and restored via ``finally``.
Leaving any one of these intact allows ``requests`` to re-attach
credentials on the hop to the foreign origin, defeating the header-level
strip.
Session state that was removed is unconditionally restored in a ``finally``
block so the session is left in its original state after this call returns,
regardless of how it exits (normal return, exception, redirect cap). A
caller-supplied session is also serialized on internally (see
``_get_session_lock``): two guarded calls sharing the same session from
different threads block on each other for the call's duration rather than
interleaving their mutations, so concurrent use of a shared session is
safe, if not concurrent.
Once credentials have been stripped for a cross-origin hop they are NOT
re-added for subsequent hops in the same chain, even if a later hop
happens to point back to the original host. This prevents credential
resurrection via crafted multi-hop redirect chains.
"""
kwargs = dict(kwargs)
kwargs.pop("allow_redirects", None)
redirect_allow_private_ips = (
allow_private_ips
if allow_private_ips_on_redirect is None
else allow_private_ips_on_redirect
)
_original_host = (urlparse(url).hostname or "").lower()
validate_url_for_request(url, allow_private_ips=allow_private_ips)
current_pinned_ips = _resolve_pinned_ips(url, allow_private_ips=allow_private_ips)
_owns_session = session is None
active_session = session if session is not None else requests.Session()
requester = active_session.request
requester = session.request if session is not None else requests.request
current_url = url
current_method = method.upper()
redirects_followed = 0
# A caller-supplied session is mutated (adapters + Host header, and
# potentially auth/trust_env below) for the duration of this call,
# including every redirect hop; serialize on the session itself so a
# second guarded call sharing it from another thread can't interleave
# its own mount()/restore cycle into the middle of this one. An owned
# session is private to this call, so no lock is needed. Released in
# the outermost `finally` below, alongside the state it protects.
_session_lock = None if _owns_session else _get_session_lock(active_session)
if _session_lock is not None:
_session_lock.acquire()
while True:
response = requester(
current_method,
current_url,
allow_redirects=False,
**kwargs,
)
# Snapshot the session's pre-existing adapters/Host header so pinning
# (mounted per-hop below) can be fully undone when this call returns —
# required for a caller-supplied session, which outlives this call.
_orig_http_adapter = (
active_session.adapters.get("http://") or requests.adapters.HTTPAdapter()
)
_orig_https_adapter = (
active_session.adapters.get("https://") or requests.adapters.HTTPAdapter()
)
_had_host_header = "Host" in active_session.headers
_orig_host_header = active_session.headers.get("Host")
# -- issue #947: snapshot every session-level credential source so we can
# restore them unconditionally when this call exits.
_SENSITIVE = ("Authorization", "Proxy-Authorization")
_session_auth_backup: dict = {}
_session_auth_handler_backup: Any = None # session.auth backup
_session_trust_env_backup: bool = True # session.trust_env backup
if session is not None:
for _h in _SENSITIVE:
# requests stores session headers in a case-insensitive dict;
# .get() matches regardless of the casing used at insertion time.
_val = session.headers.get(_h)
if _val is not None:
_session_auth_backup[_h] = _val
# Snapshot session.auth (HTTPBasicAuth, tuple, callable, or None).
_session_auth_handler_backup = session.auth
# Snapshot session.trust_env (controls .netrc / env proxy lookup).
_session_trust_env_backup = session.trust_env
# Track whether credentials have been stripped for this redirect chain.
# Once stripped they must not reappear on any subsequent hop.
_auth_stripped = False
try:
while True:
_apply_connection_pin(
active_session,
current_url,
current_pinned_ips,
_orig_http_adapter,
_orig_https_adapter,
_had_host_header,
_orig_host_header,
)
response = requester(
current_method,
current_url,
allow_redirects=False,
**kwargs,
)
if response.status_code not in _REDIRECT_STATUS_CODES:
return response
if redirects_followed >= max_redirects:
response.close()
raise ValidationError(
f"Exceeded maximum redirects ({max_redirects}) while "
f"fetching '{url}'"
)
location = response.headers.get("Location")
if not location or not str(location).strip():
response.close()
raise ValidationError(
f"Redirect from '{current_url}' is missing a Location header"
)
next_url = urljoin(current_url, str(location).strip())
next_host = (urlparse(next_url).hostname or "").lower()
# A redirect back to the original host inherits the caller's
# trust in that host (e.g. a same-host path redirect on a
# private/localhost MCP server). A redirect to a *different*
# host must not inherit that trust, even if the original host
# was private/internal — otherwise a compromised or malicious
# endpoint could redirect into arbitrary private address space
# (e.g. cloud metadata) the caller never configured.
hop_allow_private_ips = (
allow_private_ips
if next_host and next_host == _original_host
else redirect_allow_private_ips
)
current_pinned_ips = _resolve_pinned_ips(
next_url, allow_private_ips=hop_allow_private_ips
)
# Do not leak sensitive headers or auth handlers to a different
# origin on redirects. All four credential sources are cleared:
# • kwargs["headers"] — per-request header dict
# • session.headers — session-level header dict
# • kwargs["auth"] — per-request auth tuple/callable
# • session.auth — session-level auth handler
#
# Once stripped (_auth_stripped=True), credentials stay absent for
# the remainder of the chain — even if a later hop targets the
# original host — to prevent credential resurrection.
if _auth_stripped or _should_strip_auth(current_url, next_url):
_auth_stripped = True
# 1. Strip from per-request kwargs headers.
kwargs = dict(kwargs)
headers = dict(kwargs.get("headers") or {})
for sensitive in _SENSITIVE:
headers.pop(sensitive, None)
# Also remove any case variant the caller may have used
# (e.g. "authorization" or "AUTHORIZATION").
for key in list(headers):
if key.lower() == sensitive.lower():
del headers[key]
kwargs["headers"] = headers
# 2. Strip per-request auth kwarg so requests cannot call
# prepare_auth() with the caller's credential on this hop.
kwargs.pop("auth", None)
# 3. Strip session-level headers so requests cannot re-inject
# them when merging session + per-request headers for this hop.
if session is not None:
for sensitive in _SENSITIVE:
# CaseInsensitiveDict.pop(key, None) handles any casing.
session.headers.pop(sensitive, None)
# 4. Clear session.auth so prepare_request's merge_setting()
# cannot fall back to the session-level auth handler and
# reattach credentials on the foreign-origin hop.
session.auth = None
# 5. Disable .netrc / environment-proxy credential lookup so
# requests cannot inject credentials from ~/.netrc for the
# redirect target host on this hop.
session.trust_env = False
# Match requests' historical method rewriting for 301/302/303.
if (
response.status_code in _STRIP_BODY_ON_REDIRECT
and current_method not in {"GET", "HEAD"}
):
current_method = "GET"
for key in ("data", "json", "files"):
kwargs.pop(key, None)
# Params apply to the original request URL only; Location is authoritative.
kwargs.pop("params", None)
if response.status_code not in _REDIRECT_STATUS_CODES:
return response
if redirects_followed >= max_redirects:
response.close()
current_url = next_url
redirects_followed += 1
raise ValidationError(
f"Exceeded maximum redirects ({max_redirects}) while "
f"fetching '{url}'"
)
finally:
if _owns_session:
# No caller holds a reference to this session; just release it.
active_session.close()
else:
# Unconditionally restore every session credential source and
# pinning artifact we touched, so the session is in its
# original state after this call returns or raises.
if _session_auth_backup:
for _h, _v in _session_auth_backup.items():
active_session.headers[_h] = _v
# Restore session.auth to whatever it was before this call.
active_session.auth = _session_auth_handler_backup
# Restore session.trust_env (.netrc / env-proxy lookup flag).
active_session.trust_env = _session_trust_env_backup
# Undo any IP-pinning adapter/Host header mounted for a hop,
# closing the last pinned adapter so its pooled connection
# isn't leaked (see _apply_connection_pin).
_current = active_session.adapters.get("http://")
if getattr(_current, "_semantica_pinned", False):
_current.close()
active_session.mount("http://", _orig_http_adapter)
active_session.mount("https://", _orig_https_adapter)
if _had_host_header:
active_session.headers["Host"] = _orig_host_header
else:
active_session.headers.pop("Host", None)
if _session_lock is not None:
_session_lock.release()
location = response.headers.get("Location")
if not location or not str(location).strip():
response.close()
raise ValidationError(
f"Redirect from '{current_url}' is missing a Location header"
)
next_url = urljoin(current_url, str(location).strip())
validate_url_for_request(next_url, allow_private_ips=allow_private_ips)
# Do not leak sensitive headers to a different origin on redirects:
# reuse the caller's headers only while host, port, and scheme keep
# the credential safe, mirroring requests' should_strip_auth.
if _should_strip_auth(current_url, next_url):
kwargs = dict(kwargs)
headers = dict(kwargs.get("headers") or {})
for sensitive in ("Authorization", "Proxy-Authorization"):
headers.pop(sensitive, None)
kwargs["headers"] = headers
# Match requests' historical method rewriting for 301/302/303.
if (
response.status_code in _STRIP_BODY_ON_REDIRECT
and current_method not in {"GET", "HEAD"}
):
current_method = "GET"
for key in ("data", "json", "files"):
kwargs.pop(key, None)
# Params apply to the original request URL only; Location is authoritative.
kwargs.pop("params", None)
response.close()
current_url = next_url
redirects_followed += 1
-206
View File
@@ -1,206 +0,0 @@
"""Internal graph view helpers shared by KG analytics modules."""
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
@dataclass
class GraphView:
"""Normalized node and edge view used by graph analytics."""
nodes: List[Any]
edges: List[Tuple[Any, Any]]
def build_graph_view(graph: Any) -> GraphView:
"""Build a graph view without dropping explicitly declared nodes.
Graph analytics accepts graph dictionaries, ContextGraph-like objects, and
NetworkX graphs. Nodes declared without an incident edge remain in the
returned view so callers can choose how to handle isolated nodes.
"""
nodes: List[Any] = []
edges: List[Tuple[Any, Any]] = []
seen_nodes: Set[Any] = set()
seen_edges: Set[Tuple[Any, Any]] = set()
def add_node(value: Any) -> Optional[Any]:
node_id = _node_id(value)
if node_id is None or node_id == "":
return None
if node_id not in seen_nodes:
seen_nodes.add(node_id)
nodes.append(node_id)
return node_id
for node in _extract_nodes(graph):
add_node(node)
for raw_edge in _extract_edges(graph):
edge = _edge_endpoints(raw_edge)
if edge is None:
continue
source, target = edge
source = add_node(source)
target = add_node(target)
if source is None or target is None:
continue
if (source, target) not in seen_edges:
seen_edges.add((source, target))
edges.append((source, target))
return GraphView(nodes=nodes, edges=edges)
def build_adjacency(graph: Any, directed: bool = False) -> Dict[Any, List[Any]]:
"""Build an adjacency list while preserving isolated graph nodes."""
view = build_graph_view(graph)
adjacency: Dict[Any, List[Any]] = {node: [] for node in view.nodes}
for source, target in view.edges:
if target not in adjacency[source]:
adjacency[source].append(target)
if not directed and source not in adjacency[target]:
adjacency[target].append(source)
return adjacency
def _extract_nodes(graph: Any) -> Iterable[Any]:
if isinstance(graph, dict):
raw_nodes: List[Any] = []
for key in ("entities", "nodes"):
values = graph.get(key, [])
if isinstance(values, dict):
raw_nodes.extend(values.keys())
elif values:
raw_nodes.extend(values)
return raw_nodes
raw_nodes = getattr(graph, "nodes", None)
if callable(raw_nodes):
return raw_nodes()
if isinstance(raw_nodes, dict):
return raw_nodes.keys()
if raw_nodes is not None:
return raw_nodes
get_nodes = getattr(graph, "get_nodes", None)
if callable(get_nodes):
return get_nodes()
return []
def _extract_edges(graph: Any) -> Iterable[Any]:
if isinstance(graph, dict):
raw_edges: List[Any] = []
for key in ("relationships", "edges"):
values = graph.get(key, [])
if values:
raw_edges.extend(values)
return raw_edges
raw_edges: List[Any] = []
relationships = getattr(graph, "relationships", None)
if relationships is not None:
raw_edges.extend(relationships)
edges = getattr(graph, "edges", None)
if callable(edges):
raw_edges.extend(edges())
elif edges is not None:
raw_edges.extend(edges)
if raw_edges:
return raw_edges
get_relationships = getattr(graph, "get_relationships", None)
if callable(get_relationships):
return get_relationships()
return []
def _edge_endpoints(edge: Any) -> Optional[Tuple[Any, Any]]:
if isinstance(edge, (tuple, list)) and len(edge) >= 2:
return edge[0], edge[1]
if isinstance(edge, dict):
source = _first_value(
edge,
"source",
"source_id",
"subject",
"start",
"start_id",
"from",
"src",
"START_ID",
":START_ID",
)
target = _first_value(
edge,
"target",
"target_id",
"object",
"end",
"end_id",
"to",
"dst",
"END_ID",
":END_ID",
)
else:
source = _first_attribute(
edge,
"source_id",
"source",
"subject",
"start",
"start_id",
"from_id",
)
target = _first_attribute(
edge,
"target_id",
"target",
"object",
"end",
"end_id",
"to_id",
)
if source is None or target is None:
return None
return source, target
def _node_id(value: Any) -> Any:
if isinstance(value, dict):
value = _first_value(
value, "id", "node_id", "entity_id", "key", "name", "text"
)
elif not isinstance(value, (str, int, float, bool, bytes, tuple)):
value = _first_attribute(
value, "node_id", "id", "entity_id", "key", "name", "text"
)
if value is None:
return None
try:
hash(value)
except TypeError:
return str(value)
return value
def _first_value(mapping: Dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in mapping and mapping[key] not in (None, ""):
return mapping[key]
return None
def _first_attribute(value: Any, *names: str) -> Any:
for name in names:
attribute = getattr(value, name, None)
if attribute not in (None, ""):
return attribute
return None
+66 -6
View File
@@ -43,7 +43,7 @@ Author: Semantica Contributors
License: MIT
"""
from collections import deque
from collections import defaultdict, deque
from typing import Any, Dict, List, Optional
import numpy as np
@@ -51,7 +51,6 @@ from scipy import sparse
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from ._graph_view import build_adjacency, build_graph_view
class CentralityCalculator:
@@ -519,15 +518,76 @@ class CentralityCalculator:
def _build_adjacency(self, graph) -> Dict[str, List[str]]:
"""Build adjacency list from graph."""
return build_adjacency(graph)
adjacency = defaultdict(list)
# Extract relationships
relationships = []
if hasattr(graph, "relationships"):
relationships = graph.relationships
elif hasattr(graph, "get_relationships"):
relationships = graph.get_relationships()
elif isinstance(graph, dict):
relationships = graph.get("relationships", graph.get("edges", []))
elif hasattr(graph, "edges") and not callable(graph.edges):
# ContextGraph-style: edges is a list of dataclass objects with source_id/target_id
for edge in (graph.edges or []):
if isinstance(edge, dict):
src = edge.get("source") or edge.get("source_id")
tgt = edge.get("target") or edge.get("target_id")
else:
src = getattr(edge, "source_id", None) or getattr(edge, "source", None)
tgt = getattr(edge, "target_id", None) or getattr(edge, "target", None)
if src and tgt:
src, tgt = str(src), str(tgt)
if tgt not in adjacency[src]:
adjacency[src].append(tgt)
if src not in adjacency[tgt]:
adjacency[tgt].append(src)
return dict(adjacency)
# Build adjacency
for rel in relationships:
# Handle tuple/list edges (e.g., from NetworkX)
if isinstance(rel, (tuple, list)) and len(rel) >= 2:
source, target = str(rel[0]), str(rel[1])
if source and target:
if target not in adjacency[source]:
adjacency[source].append(target)
if source not in adjacency[target]:
adjacency[target].append(source)
continue
source = rel.get("source") or rel.get("subject")
target = rel.get("target") or rel.get("object")
# Extract IDs if objects are passed
if source and not isinstance(source, (str, int, float)):
if isinstance(source, dict):
source = source.get("id") or source.get("entity_id") or source.get("text") or str(source)
else:
source = getattr(source, "id", getattr(source, "text", str(source)))
if target and not isinstance(target, (str, int, float)):
if isinstance(target, dict):
target = target.get("id") or target.get("entity_id") or target.get("text") or str(target)
else:
target = getattr(target, "id", getattr(target, "text", str(target)))
if source and target:
if target not in adjacency[source]:
adjacency[source].append(target)
if source not in adjacency[target]:
adjacency[target].append(source)
return dict(adjacency)
def _to_networkx(self, graph):
"""Convert graph to NetworkX format."""
view = build_graph_view(graph)
adjacency = self._build_adjacency(graph)
nx_graph = self.nx.Graph()
nx_graph.add_nodes_from(view.nodes)
nx_graph.add_edges_from(view.edges)
for source, targets in adjacency.items():
for target in targets:
nx_graph.add_edge(source, target)
return nx_graph
+80 -47
View File
@@ -49,16 +49,6 @@ from typing import Any, Dict, List, Optional
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from ._graph_view import build_adjacency, build_graph_view
def _is_hashable(value: Any) -> bool:
"""Return whether a community identifier can be used in a set."""
try:
hash(value)
except TypeError:
return False
return True
class CommunityDetector:
@@ -167,18 +157,17 @@ class CommunityDetector:
nx_graph = self._to_networkx(graph)
# An empty graph has no communities. A graph with nodes but
# no edges still has singleton communities.
# Check if graph is empty or has no edges
num_nodes = nx_graph.number_of_nodes()
num_edges = nx_graph.number_of_edges()
self.logger.debug(f"Graph stats: nodes={num_nodes}, edges={num_edges}")
if num_nodes == 0:
self.logger.warning("Graph is empty, returning 0 communities")
if num_nodes == 0 or num_edges == 0:
self.logger.warning("Graph is empty or has no edges, returning 0 communities")
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message="Detected 0 communities (empty graph)",
message="Detected 0 communities (empty graph/no edges)",
)
return {
"communities": [],
@@ -361,7 +350,17 @@ class CommunityDetector:
adjacency = self._build_adjacency(graph)
node_communities = self._to_node_assignments(communities)
# Extract community structure
if isinstance(communities, dict):
node_communities = communities
elif isinstance(communities, dict) and "node_assignments" in communities:
node_communities = communities["node_assignments"]
else:
# Convert list of communities to node assignments
node_communities = {}
for i, community in enumerate(communities):
for node in community:
node_communities[node] = i
# Calculate metrics
num_communities = len(set(node_communities.values()))
@@ -409,7 +408,16 @@ class CommunityDetector:
metrics = self.calculate_community_metrics(graph, communities)
node_communities = self._to_node_assignments(communities)
# Extract node assignments
if isinstance(communities, dict) and "node_assignments" in communities:
node_communities = communities["node_assignments"]
elif isinstance(communities, dict):
node_communities = communities
else:
node_communities = {}
for i, community in enumerate(communities):
for node in community:
node_communities[node] = i
# Analyze connectivity between communities
adjacency = self._build_adjacency(graph)
@@ -432,32 +440,6 @@ class CommunityDetector:
"edge_ratio": intra_community_edges / (inter_community_edges + 1),
}
@staticmethod
def _to_node_assignments(communities: Any) -> Dict[Any, Any]:
"""Normalize community results to a node-to-community mapping."""
if isinstance(communities, dict):
assignments = communities.get("node_assignments")
if isinstance(assignments, dict):
return assignments
detected_communities = communities.get("communities")
if isinstance(detected_communities, (list, tuple)):
communities = detected_communities
elif "communities" in communities:
raise ValueError("Community results must contain a list of communities")
elif not all(_is_hashable(value) for value in communities.values()):
raise ValueError(
"Community assignments must map nodes to hashable community IDs"
)
else:
return communities
node_assignments: Dict[Any, Any] = {}
for community_id, community in enumerate(communities or []):
for node in community:
node_assignments[node] = community_id
return node_assignments
def detect_communities(
self, graph: Any, algorithm: str = "louvain", method: str = None, **options
) -> Dict[str, Any]:
@@ -496,7 +478,57 @@ class CommunityDetector:
def _build_adjacency(self, graph) -> Dict[str, List[str]]:
"""Build adjacency list from graph."""
return build_adjacency(graph)
from collections import defaultdict
adjacency = defaultdict(list)
# Extract relationships
relationships = []
raw_edges = [] # flat (u, v) tuples
if hasattr(graph, "relationships"):
relationships = graph.relationships
elif hasattr(graph, "get_relationships"):
relationships = graph.get_relationships()
elif isinstance(graph, dict):
relationships = graph.get("relationships", [])
# Also handle 'edges' key (list of tuples or dicts)
for edge in graph.get("edges", []):
if isinstance(edge, (list, tuple)) and len(edge) >= 2:
raw_edges.append((str(edge[0]), str(edge[1])))
elif isinstance(edge, dict):
relationships.append(edge)
# Add raw (u, v) edges
for u, v in raw_edges:
if u and v:
adjacency[u].append(v)
adjacency[v].append(u)
# Build adjacency
for rel in relationships:
source = rel.get("source") or rel.get("subject")
target = rel.get("target") or rel.get("object")
# Extract IDs if objects are passed
if source and not isinstance(source, (str, int, float)):
if isinstance(source, dict):
source = source.get("id") or source.get("entity_id") or source.get("text") or str(source)
else:
source = getattr(source, "id", getattr(source, "text", str(source)))
if target and not isinstance(target, (str, int, float)):
if isinstance(target, dict):
target = target.get("id") or target.get("entity_id") or target.get("text") or str(target)
else:
target = getattr(target, "id", getattr(target, "text", str(target)))
if source and target:
if target not in adjacency[source]:
adjacency[source].append(target)
if source not in adjacency[target]:
adjacency[target].append(source)
return dict(adjacency)
def _to_networkx(self, graph):
"""Convert graph to NetworkX format."""
@@ -504,11 +536,12 @@ class CommunityDetector:
if hasattr(graph, 'nodes') and hasattr(graph, 'edges') and hasattr(graph, 'number_of_nodes'):
return graph
view = build_graph_view(graph)
adjacency = self._build_adjacency(graph)
nx_graph = self.nx.Graph()
nx_graph.add_nodes_from(view.nodes)
nx_graph.add_edges_from(view.edges)
for source, targets in adjacency.items():
for target in targets:
nx_graph.add_edge(source, target)
return nx_graph
+46 -3
View File
@@ -48,12 +48,11 @@ Author: Semantica Contributors
License: MIT
"""
from collections import deque
from collections import defaultdict, deque
from typing import Any, Dict, List, Optional, Set, Tuple
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from ._graph_view import build_adjacency
class ConnectivityAnalyzer:
@@ -386,7 +385,51 @@ class ConnectivityAnalyzer:
def _build_adjacency(self, graph) -> Dict[str, List[str]]:
"""Build adjacency list from graph."""
return build_adjacency(graph)
adjacency = defaultdict(list)
# Extract relationships
relationships = []
if hasattr(graph, "relationships"):
relationships = graph.relationships
elif hasattr(graph, "get_relationships"):
relationships = graph.get_relationships()
elif isinstance(graph, dict):
relationships = graph.get("relationships", graph.get("edges", []))
# Build adjacency
for rel in relationships:
# Handle tuple/list edges (e.g., from NetworkX)
if isinstance(rel, (tuple, list)) and len(rel) >= 2:
source, target = str(rel[0]), str(rel[1])
if source and target:
if target not in adjacency[source]:
adjacency[source].append(target)
if source not in adjacency[target]:
adjacency[target].append(source)
continue
source = rel.get("source") or rel.get("subject")
target = rel.get("target") or rel.get("object")
# Extract IDs if objects are passed
if source and not isinstance(source, (str, int, float)):
if isinstance(source, dict):
source = source.get("id") or source.get("entity_id") or source.get("text") or str(source)
else:
source = getattr(source, "id", getattr(source, "text", str(source)))
if target and not isinstance(target, (str, int, float)):
if isinstance(target, dict):
target = target.get("id") or target.get("entity_id") or target.get("text") or str(target)
else:
target = getattr(target, "id", getattr(target, "text", str(target)))
if source and target:
if target not in adjacency[source]:
adjacency[source].append(target)
if source not in adjacency[target]:
adjacency[target].append(source)
return dict(adjacency)
def _bfs_shortest_path(
self, adjacency: Dict[str, List[str]], source: str, target: str
+31 -76
View File
@@ -22,9 +22,8 @@ License: MIT
from typing import Any, Dict, List, Optional
from ..deduplication.duplicate_detector import DuplicateDetector, DuplicateGroup
from ..deduplication.duplicate_detector import DuplicateDetector
from ..deduplication.entity_merger import EntityMerger
from ..utils.entity_ids import get_entity_id
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
@@ -139,7 +138,9 @@ class EntityResolver:
self.logger.debug(
f"Detecting duplicate groups with threshold {self.similarity_threshold}"
)
duplicate_groups = self._detect_duplicate_groups(entities)
duplicate_groups = self.duplicate_detector.detect_duplicate_groups(
entities, threshold=self.similarity_threshold
)
self.logger.debug(f"Found {len(duplicate_groups)} duplicate group(s)")
@@ -149,7 +150,6 @@ class EntityResolver:
# Step 2: Merge duplicates in each group
merged_entities = []
processed_entity_ids = set() # Track which entities have been merged
processed_entity_objects = set()
for group in duplicate_groups:
# Skip groups with less than 2 entities (not duplicates)
@@ -157,16 +157,9 @@ class EntityResolver:
continue
# Merge the duplicate group into a single canonical entity
if self.resolution_strategy == "exact":
merge_operations = [
self.entity_merger.merge_entity_group(
group.entities, **self.config
)
]
else:
merge_operations = self.entity_merger.merge_duplicates(
group.entities, **self.config
)
merge_operations = self.entity_merger.merge_duplicates(
group.entities, **self.config
)
# Process each merge operation
for operation in merge_operations:
@@ -175,26 +168,30 @@ class EntityResolver:
# Mark all source entities as processed
for source_entity in operation.source_entities:
entity_id = self._get_entity_id(source_entity)
if entity_id is None:
processed_entity_objects.add(id(source_entity))
continue
try:
entity_id = (
source_entity.get("id")
if isinstance(source_entity, dict)
else getattr(source_entity, "id", None)
) or (
source_entity.get("entity_id")
if isinstance(source_entity, dict)
else getattr(source_entity, "entity_id", None)
)
if entity_id:
processed_entity_ids.add(entity_id)
except TypeError:
processed_entity_objects.add(id(source_entity))
# Step 3: Add non-duplicate entities (entities not in any duplicate group)
for entity in entities:
entity_id = self._get_entity_id(entity)
if entity_id is None:
is_unprocessed = id(entity) not in processed_entity_objects
else:
try:
is_unprocessed = entity_id not in processed_entity_ids
except TypeError:
is_unprocessed = id(entity) not in processed_entity_objects
if is_unprocessed:
entity_id = (
entity.get("id")
if isinstance(entity, dict)
else getattr(entity, "id", None)
) or (
entity.get("entity_id")
if isinstance(entity, dict)
else getattr(entity, "entity_id", None)
)
if entity_id and entity_id not in processed_entity_ids:
# This entity was not merged, add it as-is
merged_entities.append(entity)
@@ -216,48 +213,6 @@ class EntityResolver:
)
raise
def _detect_duplicate_groups(
self, entities: List[Dict[str, Any]]
) -> List[DuplicateGroup]:
"""Detect duplicate groups according to the configured strategy."""
if self.resolution_strategy != "exact":
return self.duplicate_detector.detect_duplicate_groups(
entities, threshold=self.similarity_threshold
)
groups = {}
for entity in entities:
name = self._get_entity_name(entity)
normalized = str(name).strip() if name is not None else ""
if normalized:
groups.setdefault(normalized.casefold(), []).append(entity)
return [
DuplicateGroup(entities=group, confidence=1.0)
for group in groups.values()
if len(group) > 1
]
@staticmethod
def _get_entity_id(entity: Any) -> Any:
"""Return an entity ID while supporting dictionary and object inputs."""
return get_entity_id(entity)
@staticmethod
def _get_entity_name(entity: Any) -> Optional[str]:
"""Return an entity name, falling back to text-based entity input."""
if isinstance(entity, dict):
name = entity.get("name")
return (
name if name is not None and str(name).strip() else entity.get("text")
)
name = getattr(entity, "name", None)
return (
name
if name is not None and str(name).strip()
else getattr(entity, "text", None)
)
def merge_duplicates(self, entities: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Merge duplicate entities.
@@ -278,13 +233,13 @@ class EntityResolver:
processed_ids = set()
for op in merge_operations:
for source_entity in op.source_entities:
entity_id = get_entity_id(source_entity)
if entity_id is not None:
entity_id = source_entity.get("id") or source_entity.get("entity_id")
if entity_id:
processed_ids.add(entity_id)
for entity in entities:
entity_id = get_entity_id(entity)
if entity_id is not None and entity_id not in processed_ids:
entity_id = entity.get("id") or entity.get("entity_id")
if entity_id and entity_id not in processed_ids:
merged_entities.append(entity)
self.logger.info(f"Merged to {len(merged_entities)} entities")
+5 -19
View File
@@ -185,25 +185,8 @@ class GraphValidator:
# 3. Relationship Validation
for i, rel in enumerate(relationships):
# Endpoints may use either the legacy ``source``/``target`` keys or
# the canonical ``source_id``/``target_id`` keys emitted by
# ``ContextGraph.to_kg_dict()``. Accept either variant so both
# representations validate consistently.
src = rel.get("source")
if src is None:
src = rel.get("source_id")
tgt = rel.get("target")
if tgt is None:
tgt = rel.get("target_id")
# Check required fields: ``type`` plus a resolvable source/target.
missing = set()
if "type" not in rel:
missing.add("type")
if src is None:
missing.add("source")
if tgt is None:
missing.add("target")
# Check required fields
missing = self.required_rel_fields - set(rel.keys())
if missing:
issues.append(ValidationIssue(
code="MISSING_FIELD",
@@ -213,6 +196,9 @@ class GraphValidator:
details={"index": i}
))
continue
src = rel.get("source")
tgt = rel.get("target")
# Check Dangling Edges
def is_valid_id(node_id):
+129 -162
View File
@@ -127,100 +127,66 @@ class PathFinder:
try:
self.logger.info(f"Finding Dijkstra shortest path from {source} to {target}")
path = self._dijkstra_shortest_path(
graph,
source,
target,
weight_attribute,
default_weight,
directed,
)
# Validate nodes exist
if not self._node_exists(graph, source):
raise ValueError(f"Source node {source} not found")
if not self._node_exists(graph, target):
raise ValueError(f"Target node {target} not found")
traversal_graph = graph if directed else self._make_undirected_view(graph)
# Dijkstra's algorithm
distances = {source: 0.0}
previous = {}
priority_queue = [(0.0, source)]
visited = set()
while priority_queue:
current_distance, current_node = heapq.heappop(priority_queue)
if current_node in visited:
continue
visited.add(current_node)
if current_node == target:
break
# Explore neighbors
for neighbor, edge_data in self._get_neighbors(traversal_graph, current_node):
if neighbor in visited:
continue
# Get edge weight
weight = self._get_edge_weight(edge_data, weight_attribute, default_weight)
distance = current_distance + weight
if neighbor not in distances or distance < distances[neighbor]:
distances[neighbor] = distance
previous[neighbor] = current_node
heapq.heappush(priority_queue, (distance, neighbor))
# Reconstruct path
if target not in previous and source != target:
return [] # No path found
path = []
current = target
while current is not None:
path.append(current)
current = previous.get(current)
path.reverse()
self.logger.info(f"Found path of length {len(path)}")
return path
except ValueError:
# Re-raise ValueError for invalid nodes
raise
except Exception as e:
self.logger.error(f"Dijkstra path finding failed: {str(e)}")
raise RuntimeError(f"Path finding failed: {str(e)}")
def _dijkstra_shortest_path(
self,
graph: Any,
source: str,
target: str,
weight_attribute: str = "weight",
default_weight: float = 1.0,
directed: bool = True,
excluded_nodes: Optional[Set[str]] = None,
excluded_edges: Optional[Set[Tuple[str, str]]] = None,
) -> List[str]:
"""Find a shortest path without mutating the graph.
``excluded_nodes`` and ``excluded_edges`` are used internally by
Yen's algorithm to model its temporary graph modifications.
"""
excluded_nodes = excluded_nodes or set()
excluded_edges = excluded_edges or set()
# Validate nodes exist before applying the temporary exclusions.
if not self._node_exists(graph, source):
raise ValueError(f"Source node {source} not found")
if not self._node_exists(graph, target):
raise ValueError(f"Target node {target} not found")
if source in excluded_nodes or target in excluded_nodes:
return []
traversal_graph = graph if directed else self._make_undirected_view(graph)
# Dijkstra's algorithm
distances = {source: 0.0}
previous = {}
priority_queue = [(0.0, source)]
visited = set()
while priority_queue:
current_distance, current_node = heapq.heappop(priority_queue)
if current_node in visited or current_node in excluded_nodes:
continue
visited.add(current_node)
if current_node == target:
break
# Explore neighbors
for neighbor, edge_data in self._get_neighbors(traversal_graph, current_node):
if neighbor in visited or neighbor in excluded_nodes:
continue
if self._edge_is_excluded(
traversal_graph, current_node, neighbor, excluded_edges
):
continue
# Get edge weight
weight = self._get_edge_weight(edge_data, weight_attribute, default_weight)
distance = current_distance + weight
if neighbor not in distances or distance < distances[neighbor]:
distances[neighbor] = distance
previous[neighbor] = current_node
heapq.heappush(priority_queue, (distance, neighbor))
# Reconstruct path
if target not in previous and source != target:
return [] # No path found
path = []
current = target
while current is not None:
path.append(current)
current = previous.get(current)
path.reverse()
return path
def a_star_search(
self,
@@ -527,89 +493,69 @@ class PathFinder:
raise ValueError("k must be positive")
# Find first shortest path
first_path = self.dijkstra_shortest_path(
graph, source, target, weight_attribute, default_weight
)
first_path = self.dijkstra_shortest_path(graph, source, target, weight_attribute, default_weight)
if not first_path:
return []
paths = [first_path]
candidates = []
candidate_paths = {tuple(first_path)}
candidate_order = 0
while len(paths) < k:
previous_path = paths[-1]
# Generate candidate paths from every spur node in the last path.
for j in range(len(previous_path) - 1):
spur_node = previous_path[j]
root_path = previous_path[:j + 1]
# Block the next edge of every accepted path sharing this root.
excluded_edges = set()
for i in range(1, k):
# Generate candidate paths
for j in range(len(paths[-1]) - 1):
spur_node = paths[-1][j]
root_path = paths[-1][:j + 1]
# Temporarily remove edges
removed_edges = []
for path in paths:
if len(path) > j + 1 and path[:j + 1] == root_path:
excluded_edges.add((path[j], path[j + 1]))
# Block root nodes so the combined path remains loopless.
excluded_nodes = set(root_path[:-1])
spur_path = self._dijkstra_shortest_path(
graph,
spur_node,
target,
weight_attribute,
default_weight,
excluded_nodes=excluded_nodes,
excluded_edges=excluded_edges,
)
if not spur_path:
continue
candidate_path = root_path[:-1] + spur_path
if len(candidate_path) != len(set(candidate_path)):
continue
candidate_key = tuple(candidate_path)
if candidate_key in candidate_paths:
continue
try:
length = self.path_length(
graph, candidate_path, weight_attribute, default_weight
)
except ValueError:
continue
candidate_paths.add(candidate_key)
heapq.heappush(candidates, (length, candidate_order, candidate_path))
candidate_order += 1
if not candidates:
break
_, _, next_path = heapq.heappop(candidates)
paths.append(next_path)
if len(path) > j and path[:j + 1] == root_path:
if j + 1 < len(path):
edge_data = self._get_edge_data(graph, path[j], path[j + 1])
if edge_data is not None:
removed_edges.append((path[j], path[j + 1], edge_data))
self._remove_edge(graph, path[j], path[j + 1])
# Temporarily remove nodes (except spur node and nodes that don't exist)
removed_nodes = []
for node in root_path[:-1]:
if node != spur_node and node != source and self._node_exists(graph, node):
removed_nodes.append(node)
self._remove_node(graph, node)
# Find spur path
spur_path = self.dijkstra_shortest_path(graph, spur_node, target, weight_attribute, default_weight)
# Restore graph
for node in removed_nodes:
self._restore_node(graph, node)
for u, v, data in removed_edges:
self._restore_edge(graph, u, v, data)
# Combine root and spur paths
if spur_path:
candidate_path = root_path[:-1] + spur_path
if candidate_path not in candidates and candidate_path not in paths:
candidates.append(candidate_path)
# Calculate path lengths and sort
candidates_with_lengths = []
for path in candidates:
try:
length = self.path_length(graph, path, weight_attribute, default_weight)
candidates_with_lengths.append((path, length))
except ValueError:
# Skip invalid paths
continue
candidates_with_lengths.sort(key=lambda x: x[1])
# Add shortest unique paths
for path, length in candidates_with_lengths:
if len(paths) < k and path not in paths:
paths.append(path)
return paths
def _edge_is_excluded(
self,
graph: Any,
source: str,
target: str,
excluded_edges: Set[Tuple[str, str]],
) -> bool:
"""Check whether an edge is excluded for the current traversal."""
if (source, target) in excluded_edges:
return True
is_directed = getattr(graph, "is_directed", None)
if callable(is_directed) and not is_directed():
return (target, source) in excluded_edges
return False
def _node_exists(self, graph: Any, node: str) -> bool:
"""Check if node exists in graph."""
@@ -668,6 +614,27 @@ class PathFinder:
return edge_data.get(weight_attribute, default_weight)
return default_weight
def _remove_edge(self, graph: Any, u: str, v: str) -> None:
"""Remove edge from graph."""
if hasattr(graph, 'remove_edge'):
graph.remove_edge(u, v)
def _restore_edge(self, graph: Any, u: str, v: str, data: Any) -> None:
"""Restore edge to graph."""
if hasattr(graph, 'add_edge'):
graph.add_edge(u, v, **data)
def _remove_node(self, graph: Any, node: str) -> None:
"""Remove node from graph."""
if hasattr(graph, 'remove_node'):
graph.remove_node(node)
def _restore_node(self, graph: Any, node: str) -> None:
"""Restore node to graph (implementation depends on graph type)."""
# This is a simplified implementation
# In practice, you'd need to restore the node and its connections
pass
def _reconstruct_all_paths(
self,
previous: Dict[str, List[str]],
+1 -8
View File
@@ -535,8 +535,7 @@ class TemporalGraphQuery:
relationships = [
rel
for rel in relationships
if (rel.get("source") or rel.get("source_id")) == entity
or (rel.get("target") or rel.get("target_id")) == entity
if rel.get("source") == entity or rel.get("target") == entity
]
if relationship:
@@ -643,14 +642,8 @@ class TemporalGraphQuery:
parsed_end_time = self._parse_time(end_time) if end_time else None
for rel in relationships:
# Accept both the legacy ``source``/``target`` keys and the
# canonical ``source_id``/``target_id`` keys from ``to_kg_dict()``.
s = rel.get("source")
if s is None:
s = rel.get("source_id")
t = rel.get("target")
if t is None:
t = rel.get("target_id")
# Check temporal validity
if start_time or end_time:
+5 -12
View File
@@ -562,11 +562,6 @@ class CurrencyNormalizer:
"SEK",
"NOK",
"DKK",
"RUB",
"KRW",
"ILS",
"NGN",
"PKR",
]
self.logger.debug("Currency normalizer initialized")
@@ -611,15 +606,13 @@ class CurrencyNormalizer:
# Check for currency code
if not currency_code:
for code in self.currency_codes:
match = re.search(
rf"(?<![A-Z]){re.escape(code)}(?![A-Z])",
currency_input.upper(),
)
if match:
if code in currency_input.upper():
currency_code = code
amount_str = (
currency_input[: match.start()] + currency_input[match.end() :]
).strip()
currency_input.replace(code, "")
.replace(code.lower(), "")
.strip()
)
amount_str = amount_str.replace(",", "").replace(" ", "")
try:
amount = float(amount_str)
+4 -44
View File
@@ -33,12 +33,6 @@ class SHACLViolation:
value: Optional[str] = None
shape: Optional[str] = None
explanation: Optional[str] = None
# Real constraint parameters extracted from the source shape (sh:sourceShape),
# used to render accurate plain-English explanations.
min_count: Optional[int] = None
max_count: Optional[int] = None
datatype: Optional[str] = None
class_: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
@@ -50,10 +44,6 @@ class SHACLViolation:
"value": self.value,
"shape": self.shape,
"explanation": self.explanation,
"min_count": self.min_count,
"max_count": self.max_count,
"datatype": self.datatype,
"class_": self.class_,
}
@@ -128,10 +118,10 @@ class SHACLValidationReport:
focus_node=v.focus_node,
path=v.result_path or "",
value=v.value or "",
min_count=v.min_count if v.min_count is not None else "?",
max_count=v.max_count if v.max_count is not None else "?",
datatype=v.datatype or "the expected datatype",
class_=v.class_ or "the required class",
min_count=1,
max_count=1,
datatype=v.message or "",
class_=v.message or "",
)
def to_dict(self) -> Dict[str, Any]:
@@ -218,32 +208,6 @@ def _run_pyshacl(
shape_node = results_graph.value(result, SH.sourceShape)
shape = str(shape_node) if shape_node is not None else None
# Look up the real constraint parameters from the source shape so that
# explain_violations can render accurate values instead of placeholders.
# Note: sh:qualifiedMinCount / sh:qualifiedMaxCount are not handled here;
# such violations fall back to the "?" placeholder in explain_violations.
min_count: Optional[int] = None
max_count: Optional[int] = None
datatype: Optional[str] = None
class_: Optional[str] = None
if shape_node is not None:
min_node = shacl_g.value(shape_node, SH.minCount)
if min_node is not None:
try:
min_count = int(str(min_node))
except (TypeError, ValueError):
min_count = None
max_node = shacl_g.value(shape_node, SH.maxCount)
if max_node is not None:
try:
max_count = int(str(max_node))
except (TypeError, ValueError):
max_count = None
dt_node = shacl_g.value(shape_node, SH.datatype)
datatype = str(dt_node) if dt_node is not None else None
cls_node = shacl_g.value(shape_node, SH["class"])
class_ = str(cls_node) if cls_node is not None else None
v = SHACLViolation(
focus_node=focus,
result_path=path,
@@ -252,10 +216,6 @@ def _run_pyshacl(
message=msg,
value=val,
shape=shape,
min_count=min_count,
max_count=max_count,
datatype=datatype,
class_=class_,
)
if sev_str == "Violation":
violations.append(v)
-35
View File
@@ -1,35 +0,0 @@
"""The vocabulary Semantica's exporters emit terms from.
Every RDF export mints terms in ``https://semantica.dev/ns#``: ``sem:text``,
``sem:confidence``, the default ``sem:Entity`` type, and the rest. Until this
file existed, nothing declared what those terms meant, so a consumer receiving
an export could not tell ``sem:text`` from a typo of it, and no closed-world
check could be run against them at all (issue #1107).
The document ships inside the package so it can be loaded without a network
round trip, and is the same file intended to be served at the namespace IRI.
>>> from semantica.ontology.vocabulary import vocabulary_turtle
>>> ttl = vocabulary_turtle()
"""
from __future__ import annotations
from pathlib import Path
VOCABULARY_FILENAME = "semantica-ns.ttl"
#: The namespace the vocabulary declares terms in.
NAMESPACE = "https://semantica.dev/ns#"
__all__ = ["NAMESPACE", "VOCABULARY_FILENAME", "vocabulary_path", "vocabulary_turtle"]
def vocabulary_path() -> Path:
"""Filesystem path to the vocabulary document."""
return Path(__file__).parent / VOCABULARY_FILENAME
def vocabulary_turtle() -> str:
"""The vocabulary document as Turtle."""
return vocabulary_path().read_text(encoding="utf-8")
@@ -1,157 +0,0 @@
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix dct: <http://purl.org/dc/terms/> .
@prefix prov: <http://www.w3.org/ns/prov#> .
@prefix time: <http://www.w3.org/2006/time#> .
@prefix sem: <https://semantica.dev/ns#> .
<https://semantica.dev/ns> a owl:Ontology ;
rdfs:label "Semantica vocabulary" ;
rdfs:comment """Declares the terms the Semantica exporters emit in
https://semantica.dev/ns#. Drafted from the emitting call sites in
semantica 0.6.5: export/rdf_exporter.py, export/json_exporter.py and
provenance/manager.py. Every term below appears in output the package
produces today; no term has been invented for completeness.""" ;
owl:versionInfo "0.1.0-draft" ;
dct:created "2026-08-19"^^xsd:date .
# ── Classes ──────────────────────────────────────────────────────────────────
sem:Entity a owl:Class ;
rdfs:label "Entity" ;
rdfs:comment """The default type given to an extracted entity when the
source carries no type of its own. Emitted by serialize_to_turtle as the
fallback for entity.get("type").""" ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:Relationship a owl:Class ;
rdfs:label "Relationship" ;
rdfs:comment """A reified relationship, as emitted in the JSON-LD export
where a relationship carries sem:type, sem:source and sem:target rather than
being written as a single triple.""" ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:KnowledgeGraph a owl:Class ;
rdfs:label "Knowledge Graph" ;
rdfs:comment """The document-level type of a JSON-LD export: the @type of
the top-level node carrying sem:entities, sem:relationships and
sem:exportedAt. Emitted by _convert_kg_to_jsonld in export/json_exporter.py.""" ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
# ── Properties on an entity ──────────────────────────────────────────────────
sem:text a owl:DatatypeProperty ;
rdfs:label "text" ;
rdfs:comment """The surface text of an extracted entity. Carries the same
intent as rdfs:label; declared separately because the exporters emit it under
this IRI.""" ;
rdfs:domain sem:Entity ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:confidence a owl:DatatypeProperty ;
rdfs:label "confidence" ;
rdfs:comment """Extractor confidence in the assertion, on the unit interval.
Emitted for both entities and relationships, so the domain is left open rather
than tied to sem:Entity.
No rdfs:range is declared, deliberately. The Turtle serializer writes the value
bare, which the Turtle grammar reads as xsd:decimal, while the N-Triples
serializer types it xsd:float explicitly, and those two datatypes are disjoint.
Declaring either one would make the vocabulary contradict one of the exporters.
Issue #1100 tracks the disagreement; a range belongs here once the serializers
agree on one.""" ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:metadata a owl:AnnotationProperty ;
rdfs:label "metadata" ;
rdfs:comment """Free-form metadata carried through from extraction. An
annotation property because its value is an arbitrary structure rather than a
modelled one.""" ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
# ── Relationship terms (JSON-LD export) ──────────────────────────────────────
sem:related_to a owl:ObjectProperty ;
rdfs:label "related to" ;
rdfs:comment """The default predicate for a relationship whose type the
extractor did not determine. Deliberately unspecific: it asserts that two
entities are connected and nothing about how.""" ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:source a owl:ObjectProperty ;
rdfs:label "source" ;
rdfs:comment "The subject entity of a reified relationship." ;
rdfs:domain sem:Relationship ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:target a owl:ObjectProperty ;
rdfs:label "target" ;
rdfs:comment "The object entity of a reified relationship." ;
rdfs:domain sem:Relationship ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:type a owl:DatatypeProperty ;
rdfs:label "type" ;
rdfs:comment """The relationship type as a label, as emitted in the JSON-LD
export. Distinct from rdf:type, which relates a node to a class rather than to
a string.""" ;
rdfs:domain sem:Relationship ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
# ── Document-level terms (JSON-LD export) ────────────────────────────────────
sem:entities a owl:ObjectProperty ;
rdfs:label "entities" ;
rdfs:comment "Ordered list of entities in an exported graph document." ;
rdfs:range sem:Entity ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:relationships a owl:ObjectProperty ;
rdfs:label "relationships" ;
rdfs:comment "Ordered list of relationships in an exported graph document." ;
rdfs:range sem:Relationship ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:exportedAt a owl:DatatypeProperty ;
rdfs:label "exported at" ;
rdfs:comment """When the export was written, as an ISO 8601 timestamp with
an explicit UTC offset. The range was xsd:dateTime while the exporters stamped
with a naive datetime.now(); with the offset present (#1114) the value is a
determinate instant, comparable against a timestamp written anywhere else, so
the range is the stricter xsd:dateTimeStamp, which requires the offset.""" ;
rdfs:range xsd:dateTimeStamp ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:format a owl:DatatypeProperty ;
rdfs:label "format" ;
rdfs:comment """The serialization format label written on a JSON-LD
document (currently always the literal "json-ld"). Emitted by
JSONExporter.export_to_jsonld in export/json_exporter.py.""" ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
# ── Temporal term (OWL-Time export) ──────────────────────────────────────────
sem:openEndedInterval a owl:DatatypeProperty ;
rdfs:label "open ended interval" ;
rdfs:comment """True when an interval has no known end. OWL-Time has no
standard predicate for this, which is the reason the exporter mints one: an
interval with no time:hasEnd is ambiguous between "ongoing" and "end not
recorded", and this term resolves that in favour of the first.""" ;
rdfs:domain time:Interval ;
rdfs:range xsd:boolean ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
# ── Provenance roles ─────────────────────────────────────────────────────────
sem:role_generator a prov:Role ;
rdfs:label "generator" ;
rdfs:comment """The default role in a prov:qualifiedAssociation, used when
an agent generated an entity rather than approving or reviewing it. Typed as
prov:Role so that prov:hadRole has a declared value rather than an undeclared
IRI.""" ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
-1
View File
@@ -37,7 +37,6 @@ from openpyxl import load_workbook
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
@dataclass
+1 -9
View File
@@ -243,14 +243,6 @@ class MediaParser:
try:
import subprocess
# ffprobe's own argument parser doesn't reliably honor a bare
# "--" end-of-options marker, so a filename starting with "-"
# could otherwise be parsed as an option; neutralize that by
# forcing a relative-path prefix ffprobe can't mistake for a flag.
ffprobe_path = str(file_path)
if ffprobe_path.startswith("-"):
ffprobe_path = f"./{ffprobe_path}"
result = subprocess.run(
[
"ffprobe",
@@ -260,7 +252,7 @@ class MediaParser:
"json",
"-show_format",
"-show_streams",
ffprobe_path,
str(file_path),
],
capture_output=True,
text=True,
+2 -3
View File
@@ -61,10 +61,9 @@ License: MIT
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from datetime import datetime
import uuid
from ..utils.helpers import utc_now_iso
@dataclass
class BridgeAxiom:
@@ -281,7 +280,7 @@ class TranslationChain:
"type": layer_type,
"value": value,
"source": source,
"timestamp": utc_now_iso(),
"timestamp": datetime.utcnow().isoformat(),
**kwargs
}
self.layers.append(layer)
+15 -50
View File
@@ -26,7 +26,7 @@ License: MIT
from typing import Optional, List, Dict, Any, Union
from collections.abc import Mapping
from datetime import datetime, timezone
from datetime import datetime
from contextlib import contextmanager
import copy
import inspect
@@ -36,13 +36,8 @@ import threading
from .schemas import ProvenanceEntry, SourceReference, AgentRecord, ActivityRecord
from .storage import ProvenanceStorage, InMemoryStorage, SQLiteStorage
from .integrity import compute_checksum, verify_checksum
from ..utils.helpers import to_utc_datetime, utc_now_iso
from ..utils.logging import get_logger
#: Sort key for an entry whose timestamp cannot be read as one, so an
#: unreadable value orders first instead of raising during a sort.
_EPOCH = datetime(1, 1, 1, tzinfo=timezone.utc)
# Issue #825, Part B Tier 3 — configurable base URI for export_prov(), shared
# with RDFExporter's NamespaceManager "semantica" entry (semantica/export/
# rdf_exporter.py) so KG-exported and PROV-exported URIs for the same
@@ -368,8 +363,8 @@ class ProvenanceManager:
source_quote=kwargs.get("source_quote"),
confidence=kwargs.get("confidence", 1.0),
metadata=metadata or {},
first_seen=existing.first_seen if existing else utc_now_iso(),
last_updated=utc_now_iso(),
first_seen=existing.first_seen if existing else datetime.utcnow().isoformat(),
last_updated=datetime.utcnow().isoformat(),
parent_entity_id=parent_id,
used_entities=list(kwargs.get("used_entities", [])),
activity_started_at_time=activity_info["activity_started_at_time"],
@@ -460,8 +455,8 @@ class ProvenanceManager:
source_location=kwargs.get("source_location"),
confidence=kwargs.get("confidence", 1.0),
metadata=metadata or {},
first_seen=utc_now_iso(),
last_updated=utc_now_iso(),
first_seen=datetime.utcnow().isoformat(),
last_updated=datetime.utcnow().isoformat(),
activity_started_at_time=activity_info["activity_started_at_time"],
activity_ended_at_time=activity_info["activity_ended_at_time"],
acted_on_behalf_of=kwargs.get("acted_on_behalf_of"),
@@ -539,7 +534,7 @@ class ProvenanceManager:
# split (issue #825, Part A item 4).
derived_from_id=parent_chunk_id,
metadata=metadata,
timestamp=utc_now_iso(),
timestamp=datetime.utcnow().isoformat(),
activity_started_at_time=activity_info["activity_started_at_time"],
activity_ended_at_time=activity_info["activity_ended_at_time"],
)
@@ -609,7 +604,7 @@ class ProvenanceManager:
**metadata,
**source.metadata
},
timestamp=utc_now_iso(),
timestamp=datetime.utcnow().isoformat(),
activity_started_at_time=activity_info["activity_started_at_time"],
activity_ended_at_time=activity_info["activity_ended_at_time"],
)
@@ -964,27 +959,11 @@ class ProvenanceManager:
Returns:
List of matching entries as dicts, sorted by timestamp ascending.
"""
# Compare instants, not spellings. Since #1114 new entries carry a
# +00:00 offset while entries written earlier do not, and a raw string
# comparison orders those two by length: an inclusive naive bound equal
# to a stored offset-bearing timestamp would sort below it and drop the
# record. A bound in another offset was mis-ordered the same way.
start_at = to_utc_datetime(start)
end_at = to_utc_datetime(end)
entries = [e for e in self.storage.retrieve_all() if e.timestamp]
if start_at is None or end_at is None:
# A bound this module cannot read as a timestamp keeps the historical
# string comparison rather than raising on a call that used to work.
matches = [e for e in entries if start <= e.timestamp <= end]
else:
matches = [
e for e in entries
if (at := to_utc_datetime(e.timestamp)) is not None
and start_at <= at <= end_at
]
matches.sort(key=lambda e: (to_utc_datetime(e.timestamp) or _EPOCH, e.timestamp))
matches = [
e for e in self.storage.retrieve_all()
if e.timestamp and start <= e.timestamp <= end
]
matches.sort(key=lambda e: e.timestamp)
return [e.to_dict() for e in matches]
def get_all_sources(self, entity_id: str) -> List[Dict[str, Any]]:
@@ -1092,7 +1071,7 @@ class ProvenanceManager:
entry = copy.deepcopy(existing)
entry.invalidated = True
entry.invalidated_at_time = utc_now_iso()
entry.invalidated_at_time = datetime.utcnow().isoformat()
entry.invalidated_by = agent_id
entry.invalidation_reason = reason
entry.previous_version_id = history_id
@@ -1184,22 +1163,8 @@ class ProvenanceManager:
"""
entries = self.storage.retrieve_all()
if since:
since_at = to_utc_datetime(since)
if since_at is None:
entries = [e for e in entries
if getattr(e, "timestamp", "") >= since]
else:
entries = [
e for e in entries
if (at := to_utc_datetime(getattr(e, "timestamp", None)))
is not None and at >= since_at
]
entries.sort(
key=lambda e: (
to_utc_datetime(getattr(e, "timestamp", None)) or _EPOCH,
getattr(e, "timestamp", ""),
)
)
entries = [e for e in entries if getattr(e, "timestamp", "") >= since]
entries.sort(key=lambda e: getattr(e, "timestamp", ""))
if format == "json":
return [
+1 -3
View File
@@ -30,8 +30,6 @@ from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any
from datetime import datetime
from ..utils.helpers import utc_now_iso
@dataclass
class ProvenanceEntry:
@@ -93,7 +91,7 @@ class ProvenanceEntry:
source_quote: Optional[str] = None
# Temporal tracking (from kg.ProvenanceTracker)
timestamp: str = field(default_factory=lambda: utc_now_iso())
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
first_seen: Optional[str] = None
last_updated: Optional[str] = None
+13 -22
View File
@@ -38,7 +38,6 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from ..ingest.ssrf import request_with_ssrf_guard
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.helpers import read_json_file, write_json_file
from ..utils.logging import get_logger
@@ -402,25 +401,18 @@ class SeedDataManager:
"""
try:
from ..ingest.db_ingestor import DBIngestor
except ImportError as e:
raise ProcessingError(
"Database ingestion module not available. Install required dependencies."
) from e
try:
# Initialize DB ingestor
db_ingestor = DBIngestor(config={"connection_string": connection_string})
# Execute query or export table. Both ingestor methods take the
# connection string as their first argument — the constructor's
# config is not a substitute for it (#973).
# Execute query or export table
if query:
# Execute custom query
result = db_ingestor.execute_query(connection_string, query)
result = db_ingestor.execute_query(query)
records = result if isinstance(result, list) else [result]
elif table_name:
# Export table
table_data = db_ingestor.export_table(connection_string, table_name)
table_data = db_ingestor.export_table(table_name)
records = table_data.rows if hasattr(table_data, "rows") else []
else:
raise ProcessingError("Either 'query' or 'table_name' must be provided")
@@ -437,11 +429,11 @@ class SeedDataManager:
self.logger.info(f"Loaded {len(records)} records from database")
return records
except ProcessingError:
raise
except (ImportError, OSError):
raise ProcessingError(
"Database ingestion module not available. Install required dependencies."
)
except Exception as e:
# OSError here is a real connection/driver failure, not a missing
# module — report the actual cause and keep the chain (#973).
raise ProcessingError(f"Failed to load from database: {e}") from e
def load_from_api(
@@ -457,8 +449,8 @@ class SeedDataManager:
"""
Load seed data from API.
Makes an SSRF-protected HTTP GET request to an API endpoint and parses
the JSON response. Handles various response structures (list, dict with
Makes an HTTP GET request to an API endpoint and parses the JSON
response. Handles various response structures (list, dict with
'entities', 'data', 'results', 'items' keys). Automatically adds
entity_type, relationship_type, and source metadata if provided.
@@ -494,17 +486,16 @@ class SeedDataManager:
... )
"""
try:
import requests
# Build full URL
if endpoint:
full_url = f"{api_url.rstrip('/')}/{endpoint.lstrip('/')}"
else:
full_url = api_url
# Prepare headers — copy the caller's dict so we never mutate it in-place.
# Without the copy, adding "Authorization" here would silently modify the
# caller's original dict and potentially leak the key to subsequent calls
# that reuse the same dict without expecting it to contain credentials.
request_headers = dict(headers) if headers else {}
# Prepare headers
request_headers = headers or {}
if api_key:
request_headers["Authorization"] = f"Bearer {api_key}"
+5 -39
View File
@@ -108,7 +108,6 @@ License: MIT
import re
import difflib
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional, Tuple, Union
@@ -154,39 +153,6 @@ spacy, SPACY_AVAILABLE = safe_import("spacy")
_nlp_cache = None
_embedder_cache = None
# Cache for models loaded by name, so extraction functions do not pay
# spacy.load() on every call. Entries record the spacy module they were loaded
# from: tests patch `methods.spacy` with a mock, and an entry produced by a
# different module object must not be handed back to a later caller.
_spacy_model_cache: Dict[str, Tuple[Any, Any]] = {}
_spacy_model_cache_lock = threading.Lock()
def load_spacy_model(name: str):
"""Load a spaCy model once per process, keyed by model name.
Raises whatever ``spacy.load`` raises (``OSError`` for a missing model), so
callers keep their existing fallback behavior.
"""
cached = _spacy_model_cache.get(name)
if cached is not None and cached[0] is spacy:
return cached[1]
with _spacy_model_cache_lock:
cached = _spacy_model_cache.get(name)
if cached is not None and cached[0] is spacy:
return cached[1]
nlp = spacy.load(name)
_spacy_model_cache[name] = (spacy, nlp)
return nlp
def clear_spacy_model_cache() -> None:
"""Drop every cached spaCy model. Intended for tests."""
with _spacy_model_cache_lock:
_spacy_model_cache.clear()
def get_text_embedder():
"""
Get or load the TextEmbedder model for high-accuracy semantic similarity.
@@ -710,11 +676,11 @@ def extract_entities_ml(
return extract_entities_pattern(text, **kwargs)
try:
nlp = load_spacy_model(model)
nlp = spacy.load(model)
except OSError:
logger.warning(f"spaCy model {model} not found, using en_core_web_sm")
try:
nlp = load_spacy_model("en_core_web_sm")
nlp = spacy.load("en_core_web_sm")
except OSError:
logger.warning(
"spaCy model not available, falling back to pattern extraction"
@@ -1434,12 +1400,12 @@ def extract_relations_similarity(
# Prefer larger models for vectors
for model_name in ["en_core_web_lg", "en_core_web_md", "en_core_web_sm"]:
if spacy.util.is_package(model_name):
nlp = load_spacy_model(model_name)
nlp = spacy.load(model_name)
break
if not nlp:
# Try loading what we have
try:
nlp = load_spacy_model("en_core_web_sm")
nlp = spacy.load("en_core_web_sm")
except:
pass
except Exception:
@@ -1539,7 +1505,7 @@ def extract_relations_dependency(
return extract_relations_pattern(text, entities, **kwargs)
try:
nlp = load_spacy_model(model)
nlp = spacy.load(model)
except OSError:
logger.warning(f"spaCy model {model} not found")
return extract_relations_pattern(text, entities, **kwargs)
+1 -6
View File
@@ -144,12 +144,7 @@ class NERExtractor:
self._ml_runtime_usable = True
if "ml" in self.method and SPACY_AVAILABLE:
try:
# Deferred import: keeps semantic_extract.methods out of the
# module-level import graph and routes loading through the
# process-level cache so repeated NERExtractor constructions
# never pay the ~120 ms spacy.load() cost more than once.
from .methods import load_spacy_model
self.nlp = load_spacy_model(self.model_name)
self.nlp = spacy.load(self.model_name)
except OSError:
self.logger.warning(
f"spaCy model {self.model_name} not found. ML method will fallback."
+2 -3
View File
@@ -97,7 +97,7 @@ from .semantic_chunker import Chunk
logger = get_logger("split_methods")
# Try to import optional dependencies
_, SPACY_AVAILABLE = safe_import("spacy")
spacy, SPACY_AVAILABLE = safe_import("spacy")
nltk, NLTK_AVAILABLE = safe_import("nltk")
tiktoken, TIKTOKEN_AVAILABLE = safe_import("tiktoken")
@@ -336,8 +336,7 @@ def split_by_sentences(
# Try spaCy first
if SPACY_AVAILABLE and kwargs.get("use_spacy", True):
try:
from ..semantic_extract.methods import load_spacy_model
nlp = load_spacy_model("en_core_web_sm")
nlp = spacy.load("en_core_web_sm")
doc = nlp(text)
sentences = [sent.text for sent in doc.sents]
except Exception:
+2 -11
View File
@@ -36,8 +36,7 @@ from ..utils.helpers import safe_import
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
_, SPACY_AVAILABLE = safe_import("spacy")
spacy, SPACY_AVAILABLE = safe_import("spacy")
@dataclass
@@ -80,19 +79,11 @@ class SemanticChunker:
if SPACY_AVAILABLE:
model_name = config.get("model", "en_core_web_sm")
try:
from ..semantic_extract.methods import load_spacy_model
self.nlp = load_spacy_model(model_name)
self.nlp = spacy.load(model_name)
except OSError:
self.logger.warning(
f"spaCy model {model_name} not found. Using fallback chunking."
)
except Exception:
self.logger.warning(
"spaCy model %s failed to initialize and will be disabled "
"for this chunker instance. Using fallback chunking.",
model_name,
exc_info=True,
)
def chunk(self, text: str, **options) -> List[Chunk]:
"""
+2 -2
View File
@@ -383,9 +383,9 @@ class AnzoStore:
if self._is_uri_value(obj):
if obj.startswith("<") and obj.endswith(">"):
inner = obj[1:-1]
sparql_escaping.validate_uri(inner)
if " " in inner or ">" in inner:
raise ValueError(f"IRI contains invalid characters: {obj!r}")
return obj
sparql_escaping.validate_uri(obj)
return f"<{obj}>"
escaped = sparql_escaping.escape_literal(obj)
+1 -12
View File
@@ -50,23 +50,12 @@ _DISALLOWED_URI_CHARS_RE = re.compile(r"[\s<>\"{}|\\^`]")
#
# Shared by BlazegraphStore and RDF4JStore so the detection logic has one
# canonical implementation rather than being duplicated per-backend.
#
# The comment alternative must consume the whole comment up to a line
# terminator. Written as a bare `\#[^\n]*`, the trailing `*` backtracks: for
# "# CONSTRUCT ...\nSELECT ...", the engine gives back everything after the
# '#', letting the CONSTRUCT *inside the comment* satisfy the query-form
# keyword and misreporting a SELECT as a CONSTRUCT. Requiring a terminator
# ([\n\r], or end of input for a trailing comment) makes that backtracking
# impossible: if the character class gives a character back, the next
# character is by definition not a terminator, so the group cannot match.
# Both LF and CR are treated as terminators because the SPARQL grammar ends
# a comment at either.
CONSTRUCT_QUERY_RE = re.compile(
r"""
\A # anchor to start of string
(?: # skip zero or more of:
\s+ # whitespace
| \#[^\n\r]*(?:[\n\r]|\Z) # comment, to end of line or end of input
| \#[^\n]* # comments (until newline)
| PREFIX\s+[\w\-]*:\s*<[^>]*> # PREFIX declaration
| BASE\s+<[^>]*> # BASE declaration
)*
-8
View File
@@ -80,11 +80,7 @@ from .helpers import (
hash_data,
merge_dicts,
normalize_entities,
normalize_graph_payload,
parse_timestamp,
to_utc_datetime,
utc_now,
utc_now_iso,
read_json_file,
retry_on_error,
safe_filename,
@@ -187,7 +183,6 @@ __all__ = [
"format_data",
"clean_text",
"normalize_entities",
"normalize_graph_payload",
"hash_data",
"safe_filename",
"ensure_directory",
@@ -196,9 +191,6 @@ __all__ = [
"get_file_size",
"format_timestamp",
"parse_timestamp",
"to_utc_datetime",
"utc_now",
"utc_now_iso",
"merge_dicts",
"chunk_list",
"flatten_dict",
-16
View File
@@ -1,16 +0,0 @@
"""Helpers for reading entity identifiers consistently across the KG pipeline."""
from typing import Any
def get_entity_id(entity: Any) -> Any:
"""Return a truthy identifier from either supported entity ID field.
The KG pipeline treats empty and otherwise falsy identifiers as missing.
Prefer the canonical ``id`` field when it is populated, then fall back to
the compatible ``entity_id`` alias.
"""
if isinstance(entity, dict):
return entity.get("id") or entity.get("entity_id") or None
return getattr(entity, "id", None) or getattr(entity, "entity_id", None) or None
+8 -450
View File
@@ -63,16 +63,9 @@ import importlib
import json
import os
import re
import types
from collections import Counter
from collections.abc import Iterable as IterableABC
from collections.abc import Mapping
from dataclasses import asdict, is_dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Type, Union
from .exceptions import ProcessingError, ValidationError
from typing import Any, Dict, List, Optional, Tuple, Type, Union
def format_data(data: Any, format_type: str = "json") -> str:
@@ -320,65 +313,6 @@ def format_timestamp(
return dt.strftime(format_str)
def utc_now() -> datetime:
"""
Current instant as a timezone-aware UTC datetime.
``datetime.now()`` reads the local clock and ``datetime.utcnow()`` reads UTC,
but both return a naive datetime, and the two are indistinguishable once
serialized: a consumer cannot tell which zone the value belongs to, and an
RDF timestamp without an offset is not comparable against one that has an
offset (a SPARQL FILTER drops it rather than reporting an error). Use this
for any timestamp that leaves the process.
Returns:
Current UTC time, timezone-aware
"""
return datetime.now(timezone.utc)
def utc_now_iso() -> str:
"""
Current instant as an ISO 8601 string carrying an explicit UTC offset.
Returns:
Timestamp string such as ``2026-08-19T14:19:04.229937+00:00``, which is
a valid ``xsd:dateTimeStamp`` and orders correctly against timestamps
written in any other timezone
"""
return utc_now().isoformat()
def to_utc_datetime(value: Union[str, datetime, None]) -> Optional[datetime]:
"""
Read an ISO 8601 timestamp as a timezone-aware UTC instant.
Timestamps written before #1114 carry no offset. They were produced by
``datetime.utcnow()``, so a missing offset is read as UTC: that keeps a
stored naive value and the same instant written with an offset comparing
equal, instead of ordering by how the timestamp happens to be spelled.
Args:
value: ISO 8601 string or datetime. ``Z`` is accepted as the offset.
Returns:
Timezone-aware UTC datetime, or None if the value cannot be read as a
timestamp, so callers can fall back rather than raise on stored data
"""
if value is None:
return None
if isinstance(value, datetime):
parsed = value
else:
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def parse_timestamp(timestamp_str: str, format_str: Optional[str] = None) -> datetime:
"""
Parse timestamp string to datetime.
@@ -457,7 +391,9 @@ def chunk_list(items: List[Any], chunk_size: int) -> List[List[Any]]:
Returns:
List of chunks
"""
return [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)]
return [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)]
def flatten_dict(
d: Dict[str, Any], parent_key: str = "", sep: str = "."
) -> Dict[str, Any]:
@@ -471,32 +407,18 @@ def flatten_dict(
Returns:
Flattened dictionary
Raises:
ValueError: If two input paths produce the same flattened key.
"""
result = {}
items = []
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
nested = flatten_dict(v, new_key, sep=sep)
for key, value in nested.items():
if key in result:
raise ValueError(
f"Key collision while flattening dictionary: {key}"
)
result[key] = value
items.extend(flatten_dict(v, new_key, sep=sep).items())
else:
if new_key in result:
raise ValueError(
f"Key collision while flattening dictionary: {new_key}"
)
result[new_key] = v
items.append((new_key, v))
return result
return dict(items)
def get_nested_value(
@@ -662,367 +584,3 @@ def classify_path_distance(hop_count: int) -> str:
if hop_count <= 6:
return "mid-range"
return "distant"
# Graph payloads circulate under two vocabularies: 'entities'/'relationships'
# (kg builders, most exporters) and 'nodes'/'edges' (ContextGraph.to_dict,
# Neo4jCSVExporter, the Explorer routes). Consumers each reconciled them
# locally, with at least three competing idioms, so the same payload could be
# exported, silently dropped, or rejected depending on which consumer read it.
# This is the single place that decision is made.
_ENTITY_KEYS = ("entities", "nodes")
_RELATIONSHIP_KEYS = ("relationships", "edges")
_TRIPLET_KEYS = ("triplets",)
# Keys that legitimately travel alongside the collections without being
# records themselves, so their presence is never evidence that records were
# dropped: ContextGraph.to_dict() carries 'statistics', JSON envelopes carry
# 'metadata' and 'count'.
_CONTEXT_KEYS = ("metadata", "statistics", "count")
def _require_recognized_keys(
payload: Mapping, recognized_keys: Sequence[str], *, what: str
) -> None:
"""Reject a mapping that shares no key with the recognized set.
A consumer that reads a fixed set of keys turns an unrecognized mapping
into an empty result that looks like a legitimate one. An empty mapping is
allowed through -- it carries nothing that could be lost.
Args:
payload: Mapping to check.
recognized_keys: Keys the consumer reads.
what: Noun for the error message, e.g. ``"Graph payload"``.
Raises:
ValidationError: if ``payload`` is non-empty and shares no key with
``recognized_keys``.
"""
if not payload or any(key in payload for key in recognized_keys):
return
supplied = ", ".join(f"'{key}'" for key in sorted(map(str, payload)))
expected = ", ".join(f"'{key}'" for key in recognized_keys)
raise ValidationError(
f"{what} has no recognized key. Supplied: {supplied}. "
f"Expected at least one of: {expected}."
)
def _require_nothing_dropped(
payload: Mapping,
recognized_keys: Sequence[str],
resolved: Iterable[Any],
*,
what: str,
) -> None:
"""Reject a mapping that resolved to nothing while still holding records.
Checking that a recognized key is *present* is not enough:
``{"entities": [], "data": [...]}`` clears that bar and still resolves to
empty, dropping every record under 'data'. Presence answers "did the
caller use our vocabulary"; this answers the question that actually
matters, "did anything the caller supplied survive".
Only non-empty lists count as evidence of dropped records. A payload can
carry scalars and dicts that are not collections -- ContextGraph.to_dict()
always includes 'statistics' -- and an empty graph must stay exportable.
Args:
payload: Mapping to check.
recognized_keys: Keys the consumer reads.
resolved: The collections the consumer resolved from ``payload``.
what: Noun for the error message, e.g. ``"Graph payload"``.
Raises:
ValidationError: if nothing resolved and an unread key holds a
non-empty list.
"""
if any(resolved):
return
dropped = sorted(
str(key)
for key, value in payload.items()
if key not in recognized_keys
and key not in _CONTEXT_KEYS
and isinstance(value, (list, tuple))
and value
)
if not dropped:
return
named = ", ".join(f"'{key}'" for key in dropped)
expected = ", ".join(f"'{key}'" for key in recognized_keys)
raise ValidationError(
f"{what} resolved to nothing, but {named} still holds records. "
f"Exporting it would drop them silently. Supply the records under "
f"one of: {expected}."
)
def _is_record(value: Any) -> bool:
"""Report whether a value can stand in for a graph record.
Consumers read records either as mappings (``entity.get("type")`` in the
LPG and Arango exporters) or as objects with attributes
(``Neo4jCSVExporter._record_to_dict`` accepts dataclasses and anything
carrying a ``__dict__``). Both are legitimate, so both are accepted here;
strings, numbers, and nested sequences are not records under either
reading.
Modules and class/type objects are excluded even though they carry
``__dict__``: they are not graph records under any supported reading, and
passing them through the boundary would produce ``AttributeError`` inside
exporters rather than a ``ValidationError`` at the boundary where the
problem is visible.
"""
return isinstance(value, Mapping) or is_dataclass(value) or (
hasattr(value, "__dict__")
and not isinstance(value, (types.ModuleType, type))
)
def _record_to_dict(record: Any) -> Dict[str, Any]:
"""Convert an accepted record to a plain dict.
:func:`_is_record` accepts mappings, dataclasses, and objects carrying
``__dict__`` as legitimate record shapes, but consumers of
:func:`normalize_graph_payload` -- YAML serialization, ``entity.get(...)``
in the LPG and Arango exporters -- read records as dicts. Converting here,
at the boundary, means every exporter gets the same shape regardless of
which reading the caller used; previously only ``Neo4jCSVExporter``
converted object-shaped records locally, so a dataclass record passed
validation for the other exporters only to crash with a raw
``AttributeError`` once used.
"""
if isinstance(record, Mapping):
return dict(record)
if is_dataclass(record):
return asdict(record)
return {
key: value for key, value in vars(record).items() if not key.startswith("_")
}
def _coerce_records(key: str, value: Any) -> List[Any]:
"""Validate one collection value and materialize it as a list of records.
This runs before any truthiness or ``list()`` call, because both mislead
on malformed input: ``list("abc")`` quietly turns a string into three
single-character "records", and ``list(42)`` raises a bare ``TypeError``
from deep inside the exporter that named the exporter rather than the
offending payload key. Neither reaches the caller as an actionable
message, so the shapes that produce them are rejected by name instead.
``None`` is deliberately not rejected: JSON round-trips an absent
collection to null, and treating that as "no records under this key" is
the same answer an explicit ``[]`` gets. It is not silent data loss --
a null collection alongside records under an unread key is still caught
by :func:`_require_nothing_dropped`.
Args:
key: Payload key the value came from, for the error message.
value: The raw value stored under ``key``.
Returns:
The records as a new list, so the result never aliases the input.
Raises:
ValidationError: if ``value`` is a string, bytes, a mapping, or any
non-iterable scalar; or if any element is not a record.
"""
if value is None:
return []
if isinstance(value, (str, bytes, bytearray)):
raise ValidationError(
f"Graph payload key '{key}' holds a {type(value).__name__}, not a "
f"collection of records. Iterating it would yield characters, not "
f"records. Supply a list of records."
)
if isinstance(value, Mapping):
raise ValidationError(
f"Graph payload key '{key}' holds a mapping, not a collection of "
f"records. If it is a single record, wrap it in a list; if it is "
f"keyed by ID, supply its values as a list."
)
if not isinstance(value, IterableABC):
raise ValidationError(
f"Graph payload key '{key}' holds a "
f"{type(value).__name__}, not a collection of records. Supply a "
f"list of records."
)
records = list(value)
for index, record in enumerate(records):
if not _is_record(record):
raise ValidationError(
f"Graph payload key '{key}' holds a "
f"{type(record).__name__} at index {index}, not a record. "
f"Records must be mappings or objects with attributes."
)
return [_record_to_dict(record) for record in records]
def _canonical_record_multiset(records: List[Dict[str, Any]]) -> "Counter[str]":
"""Represent records as an order-independent multiset for equality checks.
Two spellings of the same collection (``entities`` and ``nodes``) can
legitimately list identical records in a different order -- a caller
round-tripping through a dict-keyed cache or a set has no reason to
preserve list order. Comparing with plain list equality would treat that
as a conflict and reject a payload that carries no real data loss, so
records are compared as a multiset of their canonical JSON form instead.
"""
return Counter(
json.dumps(record, sort_keys=True, default=str) for record in records
)
def _resolve_collection(
payload: Dict[str, Any], keys: Tuple[str, ...]
) -> List[Dict[str, Any]]:
"""Pick one collection from a payload that may use either vocabulary.
Both spellings may legitimately be present: ``JSONExporter`` writes
'entities' and 'nodes' side by side, so a round-trip of its output carries
both, one of them empty. Where only one holds records, that one wins.
Two non-empty, unequal spellings are a different matter -- there is no
basis for preferring either, and picking one would silently discard the
other -- so that is refused rather than guessed at.
Every spelling present is validated, not just the one that wins: a
malformed 'nodes' alongside a well-formed 'entities' is a payload the
caller should hear about, and validating only the winner would let it
through on the strength of the other key.
Args:
payload: Mapping to read from.
keys: Accepted spellings, most canonical first.
Returns:
The resolved collection, or an empty list if no spelling is present.
Raises:
ValidationError: if a spelling holds something other than a collection
of records; or if two spellings are both present, both non-empty,
and hold different records, order ignored.
"""
present = {
key: _coerce_records(key, payload[key]) for key in keys if key in payload
}
populated = {key: value for key, value in present.items() if value}
if len(populated) > 1:
values = list(populated.values())
canonical = [_canonical_record_multiset(value) for value in values]
if any(entry != canonical[0] for entry in canonical[1:]):
named = " and ".join(f"'{key}'" for key in populated)
raise ValidationError(
f"Graph payload carries {named} with different contents; "
f"cannot determine which to export. Supply one, or make them "
f"identical."
)
for key in keys:
value = present.get(key)
if value:
# Already a fresh list from _coerce_records, so the result cannot
# alias the caller's collection.
return value
# Every spelling present is empty (or none is): an explicit empty
# collection is a legitimate answer, distinct from "unrecognized".
return []
def _require_mapping(data: Any, expected_keys: Sequence[str]) -> None:
"""Reject non-mapping export input with an actionable error.
Shared by every consumer of :func:`normalize_graph_payload` so that a
wrong *type* fails the same way everywhere. Handed a sequence (or any
other non-mapping), every downstream key lookup would fail with a bare
``AttributeError: 'list' object has no attribute 'get'``, which tells the
caller nothing about the shape expected -- and ``normalize_graph_payload``
itself raises ``ValidationError`` for this case, which would leave
exporters that skip this guard raising a different exception type than
the ones that call it, for the identical mistake.
A list is rejected rather than wrapped: these formats distinguish
entities from relationships from triplets (or nodes/edges), so inferring
which one a bare list represents would silently mislabel the records.
Args:
data: Candidate export payload.
expected_keys: Key names the caller reads, named in the error so the
caller learns the expected shape.
Raises:
ProcessingError: if ``data`` is not a mapping.
"""
if not isinstance(data, Mapping):
keys = "/".join(f"'{key}'" for key in expected_keys)
raise ProcessingError(
f"Cannot export object of type '{type(data).__name__}': "
f"expected a dict with {keys}."
)
def normalize_graph_payload(
payload: Dict[str, Any],
) -> Dict[str, List[Dict[str, Any]]]:
"""Reduce a graph payload to one canonical vocabulary.
Accepts either 'entities'/'relationships' or 'nodes'/'edges' (or a mix)
and returns the canonical spelling, so consumers read one shape instead of
reimplementing the reconciliation.
This is the validation boundary for graph payloads: it either returns
collections of records or raises. Nothing that reaches an exporter through
it needs re-checking, and nothing malformed passes through it as a
valid-looking empty graph.
Args:
payload: Graph payload mapping.
Returns:
``{"entities": [...], "relationships": [...], "triplets": [...]}``.
Raises:
ValidationError: if ``payload`` is not a mapping; if a recognized key
holds something other than a collection of records; if two
spellings of the same collection are both non-empty and differ; if
a non-empty mapping contains no recognized key; or if it resolves
to nothing while an unread key still holds records. The last two
would otherwise hand the caller a valid-looking result with their
records silently dropped.
Example:
>>> normalize_graph_payload({"nodes": [{"id": "n1"}], "edges": []})
{'entities': [{'id': 'n1'}], 'relationships': [], 'triplets': []}
"""
if not isinstance(payload, Mapping):
raise ValidationError(
f"Cannot normalize graph payload of type "
f"'{type(payload).__name__}': expected a mapping."
)
recognized = _ENTITY_KEYS + _RELATIONSHIP_KEYS + _TRIPLET_KEYS
_require_recognized_keys(payload, recognized, what="Graph payload")
resolved = {
"entities": _resolve_collection(payload, _ENTITY_KEYS),
"relationships": _resolve_collection(payload, _RELATIONSHIP_KEYS),
"triplets": _resolve_collection(payload, _TRIPLET_KEYS),
}
_require_nothing_dropped(
payload, recognized, resolved.values(), what="Graph payload"
)
return resolved
-55
View File
@@ -109,61 +109,6 @@ class TestContextModule(unittest.TestCase):
self.assertEqual(neighbors[0]["id"], "n2")
self.assertEqual(neighbors[0]["relationship"], "knows")
def test_add_edge_is_idempotent(self):
graph = ContextGraph()
graph.add_node("a", "t")
graph.add_node("b", "t")
self.assertTrue(graph.add_edge("a", "b", "rel"))
self.assertFalse(graph.add_edge("a", "b", "rel"))
self.assertFalse(graph.add_edge("a", "b", "rel"))
self.assertEqual(len(graph.edges), 1)
self.assertEqual(len(graph.edge_type_index["rel"]), 1)
self.assertEqual(len(graph._adjacency["a"]), 1)
self.assertEqual(graph.stats()["edge_count"], 1)
self.assertLessEqual(graph.density(), 1.0)
def test_parallel_edges_with_distinct_attributes_are_kept(self):
graph = ContextGraph()
graph.add_node("a", "t")
graph.add_node("b", "t")
graph.add_edge("a", "b", "rel", confidence=0.9)
graph.add_edge("a", "b", "rel", confidence=0.5)
graph.add_edge("a", "b", "other")
self.assertEqual(len(graph.edges), 3)
self.assertEqual(len({e.edge_id for e in graph.edges}), 3)
def test_reingest_does_not_duplicate_edges(self):
graph = ContextGraph()
entities = [
{"id": "alice", "type": "person"},
{"id": "acme", "type": "org"},
]
relationships = [
{"source_id": "alice", "target_id": "acme", "type": "works_at"}
]
for _ in range(3):
graph.build_from_entities_and_relationships(entities, relationships)
self.assertEqual(len(graph.edges), 1)
def test_clear_resets_edge_dedupe_index(self):
graph = ContextGraph()
graph.add_node("a", "t")
graph.add_node("b", "t")
graph.add_edge("a", "b", "rel")
graph.clear()
graph.add_node("a", "t")
graph.add_node("b", "t")
self.assertTrue(graph.add_edge("a", "b", "rel"))
self.assertEqual(len(graph.edges), 1)
def test_get_nodes_by_label_returns_metadata_copy(self):
graph = ContextGraph()
graph.add_node("n1", "person", "Alice", role="engineer")
@@ -1,142 +0,0 @@
#!/usr/bin/env python3
"""Regression tests for the ContextGraph module docstring example.
The "Example Usage" block in ``semantica/context/context_graph.py`` previously
called ``add_node``/``add_edge`` with keyword arguments those methods do not
accept (``type=`` and ``properties=``), so the documented example raised
``TypeError`` -- and the near-miss variants silently nested the properties dict
instead of failing.
These tests keep the documented example executable and pin the two behaviours
that made the original mistake easy to miss.
"""
import doctest
import re
from typing import Dict, List
import pytest
import semantica.context.context_graph as context_graph_module
from semantica.context.context_graph import ContextGraph
# The example block runs to the next top-level section header (a line starting
# in column 0, e.g. "Production Use Cases:") or the end of the docstring.
# Terminating on the next header rather than on a blank line keeps the capture
# intact when the example gains blank lines or extra paragraphs.
_EXAMPLE_BLOCK_RE = re.compile(r"^Example Usage:\n(.*?)(?=^\S|\Z)", re.DOTALL | re.MULTILINE)
# ``type=`` as its own keyword, but not the legitimate ``node_type=``/``edge_type=``.
_BARE_TYPE_KWARG_RE = re.compile(r"(?<![\w])type\s*=")
def _example_block() -> str:
"""Return the 'Example Usage' block from the module docstring."""
doc = context_graph_module.__doc__ or ""
match = _EXAMPLE_BLOCK_RE.search(doc)
assert match, "module docstring no longer contains an 'Example Usage:' block"
block = match.group(1).strip()
assert block, "the 'Example Usage:' block in the module docstring is empty"
return block
def _example_statements() -> List[str]:
"""Return the documented ``>>>`` statements, continuation lines included."""
statements = [example.source for example in doctest.DocTestParser().get_examples(_example_block())]
assert statements, "the 'Example Usage:' block no longer contains any '>>>' statements"
return statements
def _statements_calling(method: str) -> List[str]:
"""Return the documented statements that call ``graph.<method>(``."""
return [stmt for stmt in _example_statements() if "graph.{}(".format(method) in stmt]
def _run_example() -> Dict[str, object]:
"""Execute the documented example verbatim and return its namespace."""
source = "".join(_example_statements())
namespace: Dict[str, object] = {}
exec(compile(source, "<context_graph module docstring>", "exec"), namespace)
return namespace
class TestDocstringExampleIsRunnable:
"""The documented example must execute exactly as written."""
def test_documented_calls_execute(self):
# Run the docstring text itself so this test cannot drift from the docs.
ns = _run_example()
graph = ns["graph"]
assert "Python" in graph.nodes
assert "Programming" in graph.nodes
assert graph.nodes["Python"].node_type == "language"
assert graph.nodes["Programming"].node_type == "concept"
neighbors = graph.get_neighbors("Python", hops=1)
assert any(n["id"] == "Programming" for n in neighbors)
# record_decision must return a non-empty string ID.
assert isinstance(ns["decision_id"], str) and ns["decision_id"]
# find_precedents must be called with that ID and return a list.
assert isinstance(ns["precedents"], list)
def test_node_properties_are_stored_flat(self):
"""``popularity`` must land as a top-level property, not nested.
Passing the previously documented ``properties={...}`` does not raise --
it stores a dict *inside* the properties dict, which is why the original
docs bug could reach a user's graph unnoticed.
"""
graph = ContextGraph(advanced_analytics=False)
graph.add_node("Python", "language", popularity="high")
assert graph.nodes["Python"].properties == {"popularity": "high"}
assert graph.find_node("Python")["metadata"]["popularity"] == "high"
assert "properties" not in graph.nodes["Python"].properties
def test_edge_type_is_positional_not_a_property(self):
"""``related_to`` must be the edge type, not a stray metadata key."""
graph = ContextGraph(advanced_analytics=False)
graph.add_node("Python", "language")
graph.add_node("Programming", "concept")
graph.add_edge("Python", "Programming", "related_to")
edge = graph.edges[0]
assert edge.edge_type == "related_to"
assert "type" not in edge.metadata
class TestDocstringExampleDoesNotRegress:
"""Guard the docstring text itself, not just equivalent code."""
def test_add_node_example_supplies_node_type_positionally(self):
calls = _statements_calling("add_node")
assert calls, "the 'Example Usage:' block no longer calls graph.add_node()"
for call in calls:
assert not _BARE_TYPE_KWARG_RE.search(call), (
f"add_node example passes type= as a keyword: {call!r}. "
"node_type is positional-required; type= falls through to "
"**properties and the call raises TypeError."
)
assert "properties=" not in call, (
f"add_node example passes properties=: {call!r}. "
"add_node has no properties parameter; extra properties are "
"passed as **kwargs."
)
def test_add_edge_example_supplies_edge_type_positionally(self):
calls = _statements_calling("add_edge")
assert calls, "the 'Example Usage:' block no longer calls graph.add_edge()"
for call in calls:
assert not _BARE_TYPE_KWARG_RE.search(call), (
f"add_edge example passes type= as a keyword: {call!r}. "
"The parameter is edge_type; type= is silently absorbed into "
"**properties and pollutes edge metadata."
)
def test_broken_form_still_raises(self):
"""Pin the signature contract the example has to respect."""
graph = ContextGraph(advanced_analytics=False)
with pytest.raises(TypeError, match="node_type"):
graph.add_node("Python", type="language", properties={"popularity": "high"})
@@ -1,595 +0,0 @@
"""Tests for ContextGraph retraction and purge (issue #955).
``ContextGraph`` had 56 public methods and none that removed anything: the only
option was ``clear()``, which discards the whole graph. Two operations are
added, with deliberately different contracts.
Retraction closes an entity's validity window. The entity stops being active
going forward, but ``state_at()`` before the retraction still returns it, so
decisions recorded against it remain explainable. Purge is destructive: the
entity is gone from history too, leaving only a tombstone recording that a
purge happened and why -- never the purged content.
The audit-trail assertions run against a real ``TemporalVersionManager`` rather
than a mock callback, since the behaviour under test is precisely that these
operations reach the existing mutation-recording path.
"""
import json
import os
import tempfile
import threading
import unittest
from datetime import datetime
from semantica.change_management import TemporalVersionManager
from semantica.context import ContextEdge, ContextGraph
BEFORE = "2025-06-01T00:00:00Z"
BETWEEN = "2025-09-01T00:00:00Z"
CUTOFF = "2026-01-01T00:00:00Z"
AFTER = "2026-06-01T00:00:00Z"
def _graph():
"""alice --works_at--> acme, plus an unrelated bob."""
graph = ContextGraph(advanced_analytics=False)
graph.add_node("alice", "person")
graph.add_node("acme", "org")
graph.add_node("bob", "person")
graph.add_edge("alice", "acme", "works_at")
return graph
def _ids_at(graph, when):
return {node.get("id") for node in graph.state_at(when).get("nodes", [])}
def _index_totals(graph):
return {
"nodes": len(graph.nodes),
"node_index": sum(len(v) for v in graph.node_type_index.values()),
"edges": len(graph.edges),
"edge_index": sum(len(v) for v in graph.edge_type_index.values()),
"adjacency": sum(len(v) for v in graph._adjacency.values()),
}
class TestRetractNode(unittest.TestCase):
def test_retracted_node_leaves_the_active_view(self):
graph = _graph()
self.assertTrue(graph.retract_node("alice", at=CUTOFF))
active = {node["id"] for node in graph.find_active_nodes()}
self.assertNotIn("alice", active)
self.assertIn("bob", active)
def test_history_before_the_retraction_is_preserved(self):
graph = _graph()
graph.retract_node("alice", at=CUTOFF)
self.assertIn("alice", _ids_at(graph, BEFORE))
self.assertNotIn("alice", _ids_at(graph, AFTER))
def test_retraction_record_captures_reason_and_time(self):
graph = _graph()
graph.retract_node("alice", reason="employment ended", at=CUTOFF)
record = graph.get_retraction("alice")
self.assertEqual(record["entity_id"], "alice")
self.assertEqual(record["entity_kind"], "node")
self.assertEqual(record["reason"], "employment ended")
self.assertIn("2026-01-01", record["retracted_at"])
def test_retracting_twice_is_a_no_op(self):
graph = _graph()
self.assertTrue(graph.retract_node("alice", reason="first", at=CUTOFF))
self.assertFalse(graph.retract_node("alice", reason="second"))
self.assertEqual(graph.get_retraction("alice")["reason"], "first")
def test_retracting_an_unknown_node_returns_false(self):
graph = _graph()
self.assertFalse(graph.retract_node("nobody"))
self.assertIsNone(graph.get_retraction("nobody"))
def test_cascade_retracts_incident_edges_in_both_directions(self):
graph = _graph()
graph.add_edge("bob", "alice", "knows") # inbound, not in _adjacency['alice']
graph.retract_node("alice", at=CUTOFF)
for edge in graph.edges:
self.assertIsNotNone(
graph.get_retraction(edge.edge_id),
f"edge {edge.edge_type} touching alice was not retracted",
)
def test_cascade_can_be_disabled(self):
graph = _graph()
graph.retract_node("alice", at=CUTOFF, cascade=False)
edge = graph.edges[0]
self.assertIsNone(graph.get_retraction(edge.edge_id))
def test_retraction_does_not_remove_the_record(self):
"""Retraction is a temporal change, not a deletion."""
graph = _graph()
graph.retract_node("alice", at=CUTOFF)
self.assertTrue(graph.has_node("alice"))
self.assertIsNotNone(graph.find_node("alice"))
class TestRetractEdge(unittest.TestCase):
def test_edge_is_retracted_without_touching_endpoints(self):
graph = _graph()
edge_id = graph.edges[0].edge_id
self.assertTrue(graph.retract_edge(edge_id, reason="wrong extraction"))
self.assertIsNotNone(graph.get_retraction(edge_id))
active = {node["id"] for node in graph.find_active_nodes()}
self.assertIn("alice", active)
self.assertIn("acme", active)
def test_retracting_an_unknown_edge_returns_false(self):
self.assertFalse(_graph().retract_edge("no-such-edge"))
def test_retracting_an_edge_twice_is_a_no_op(self):
graph = _graph()
edge_id = graph.edges[0].edge_id
self.assertTrue(graph.retract_edge(edge_id))
self.assertFalse(graph.retract_edge(edge_id))
class TestPurge(unittest.TestCase):
def test_purged_node_is_absent_from_history(self):
graph = _graph()
self.assertTrue(graph.purge_node("alice", reason="erasure request #1"))
self.assertNotIn("alice", _ids_at(graph, BEFORE))
self.assertFalse(graph.has_node("alice"))
def test_tombstone_records_the_purge_without_the_content(self):
graph = ContextGraph(advanced_analytics=False)
graph.add_node("alice", "person", email="alice@example.com")
graph.purge_node("alice", reason="erasure request #1")
tombstone = graph.get_tombstone("alice")
self.assertEqual(tombstone["entity_id"], "alice")
self.assertEqual(tombstone["reason"], "erasure request #1")
self.assertIn("purged_at", tombstone)
self.assertNotIn(
"alice@example.com",
str(tombstone),
"tombstone retained purged content, defeating the purpose of a purge",
)
def test_purge_cascades_to_incident_edges(self):
graph = _graph()
graph.add_edge("bob", "alice", "knows")
graph.purge_node("alice")
remaining = {(e.source_id, e.target_id) for e in graph.edges}
self.assertEqual(remaining, set())
def test_purge_keeps_every_index_consistent(self):
"""The invariant clear() already upholds must hold here too."""
graph = ContextGraph(advanced_analytics=False)
for i in range(5):
graph.add_node(f"n{i}", f"t{i % 2}")
graph.add_edge("n0", "n1", "a")
graph.add_edge("n1", "n2", "b")
graph.add_edge("n2", "n0", "a")
graph.add_edge("n3", "n0", "b")
graph.purge_node("n0")
totals = _index_totals(graph)
self.assertEqual(totals["node_index"], totals["nodes"])
self.assertEqual(totals["edge_index"], totals["edges"])
self.assertEqual(totals["adjacency"], totals["edges"])
self.assertEqual(totals["edges"], 1) # only n1->n2 survives
def test_purge_edge_leaves_endpoints_in_place(self):
graph = _graph()
edge_id = graph.edges[0].edge_id
self.assertTrue(graph.purge_edge(edge_id))
self.assertEqual(len(graph.edges), 0)
self.assertTrue(graph.has_node("alice"))
self.assertTrue(graph.has_node("acme"))
totals = _index_totals(graph)
self.assertEqual(totals["edge_index"], 0)
self.assertEqual(totals["adjacency"], 0)
def test_purging_unknown_entities_returns_false(self):
graph = _graph()
self.assertFalse(graph.purge_node("nobody"))
self.assertFalse(graph.purge_edge("no-such-edge"))
def test_purge_supersedes_an_earlier_retraction(self):
graph = _graph()
graph.retract_node("alice", reason="left", at=CUTOFF)
graph.purge_node("alice", reason="erasure request #2")
self.assertIsNone(graph.get_retraction("alice"))
self.assertIsNotNone(graph.get_tombstone("alice"))
class TestRetractionNeverWidensTheWindow(unittest.TestCase):
"""Retraction closes a validity window; it must never extend one.
An entity added with ``valid_until`` already in the past was inactive from
that point on. Overwriting the bound with a later retraction time would
make ``state_at`` report it active over a span it previously was not.
"""
def test_a_node_keeps_an_earlier_valid_until(self):
graph = ContextGraph(advanced_analytics=False)
graph.add_node("alice", "person", valid_until=BEFORE)
self.assertTrue(graph.retract_node("alice", at=AFTER))
self.assertEqual(graph.nodes["alice"].valid_until, BEFORE)
self.assertNotIn("alice", _ids_at(graph, BETWEEN))
def test_an_edge_keeps_an_earlier_valid_until(self):
graph = ContextGraph(advanced_analytics=False)
graph.add_node("alice", "person")
graph.add_node("acme", "org")
graph.add_edge("alice", "acme", "works_at", valid_until=BEFORE)
edge = graph.edges[0]
self.assertTrue(graph.retract_edge(edge.edge_id, at=AFTER))
self.assertEqual(edge.valid_until, BEFORE)
self.assertFalse(edge.is_active(datetime(2025, 9, 1)))
def test_cascade_keeps_an_earlier_edge_bound(self):
graph = ContextGraph(advanced_analytics=False)
graph.add_node("alice", "person")
graph.add_node("acme", "org")
graph.add_edge("alice", "acme", "works_at", valid_until=BEFORE)
graph.retract_node("alice", at=AFTER)
self.assertEqual(graph.edges[0].valid_until, BEFORE)
def test_an_open_window_is_still_closed_at_the_retraction_time(self):
graph = _graph()
graph.retract_node("alice", at=CUTOFF)
self.assertEqual(graph.nodes["alice"].valid_until, "2026-01-01T00:00:00")
class TestPurgeTimestamp(unittest.TestCase):
"""Purge accepts an explicit effective time, as retraction does."""
def test_node_tombstone_records_the_supplied_time(self):
graph = _graph()
graph.purge_node("alice", reason="erasure request #4", at=CUTOFF)
self.assertEqual(
graph.get_tombstone("alice")["purged_at"], "2026-01-01T00:00:00"
)
def test_edge_tombstone_records_the_supplied_time(self):
graph = _graph()
edge_id = graph.edges[0].edge_id
graph.purge_edge(edge_id, at=CUTOFF)
self.assertEqual(
graph.get_tombstone(edge_id)["purged_at"], "2026-01-01T00:00:00"
)
def test_cascaded_edge_tombstones_share_the_supplied_time(self):
graph = _graph()
edge_id = graph.edges[0].edge_id
graph.purge_node("alice", at=CUTOFF)
self.assertEqual(
graph.get_tombstone(edge_id)["purged_at"], "2026-01-01T00:00:00"
)
def test_purge_time_defaults_to_now(self):
graph = _graph()
graph.purge_node("alice")
self.assertIn("purged_at", graph.get_tombstone("alice"))
class TestIdKeyspaces(unittest.TestCase):
"""Node ids are caller-supplied and edge ids are UUIDs, so they can collide."""
def _colliding(self):
graph = _graph()
edge_id = graph.edges[0].edge_id
graph.add_node(edge_id, "person")
return graph, edge_id
def test_an_edge_retraction_does_not_block_a_colliding_node(self):
graph, edge_id = self._colliding()
self.assertTrue(graph.retract_edge(edge_id, reason="edge"))
self.assertTrue(graph.retract_node(edge_id, reason="node"))
self.assertEqual(graph.get_retraction(edge_id, "edge")["reason"], "edge")
self.assertEqual(graph.get_retraction(edge_id, "node")["reason"], "node")
def test_purging_a_node_leaves_a_colliding_edge_alone(self):
graph, edge_id = self._colliding()
self.assertTrue(graph.purge_node(edge_id))
self.assertEqual(len(graph.edges), 1)
self.assertIsNone(graph.get_tombstone(edge_id, "edge"))
self.assertIsNotNone(graph.get_tombstone(edge_id, "node"))
def test_an_unknown_entity_kind_is_rejected(self):
with self.assertRaises(ValueError):
_graph().get_retraction("alice", "vertex")
class TestDuplicateEdgeId(unittest.TestCase):
"""``edge_id`` is content-derived; before #926, two identical ``add_edge``
calls produced two edge objects sharing one id. #926 stops *new*
duplicates through ``add_edge``/``add_edges``, but a graph can still carry
one from a save made before that fix, or from any other path that builds
a ``ContextEdge`` directly -- so retraction/purge must still handle it.
Every duplicate must be reached, or a retraction/tombstone record can
claim an edge is gone/inactive while a live copy remains in the graph.
"""
def _duplicated(self):
"""A graph with two distinct ``ContextEdge`` objects sharing one
edge_id, reproducing pre-#926 (or any hand-built) duplicate state
without going through the now-deduping ``add_edge``.
"""
graph = _graph()
original = graph.edges[0]
duplicate = ContextEdge(
source_id=original.source_id,
target_id=original.target_id,
edge_type=original.edge_type,
weight=original.weight,
)
self.assertEqual(duplicate.edge_id, original.edge_id)
graph.edges.append(duplicate)
graph.edge_type_index[duplicate.edge_type].append(duplicate)
graph._adjacency[duplicate.source_id].append(duplicate)
edge_id = original.edge_id
self.assertEqual({e.edge_id for e in graph.edges}, {edge_id})
self.assertEqual(len(graph.edges), 2)
return graph, edge_id
def test_retract_edge_closes_every_duplicate(self):
graph, edge_id = self._duplicated()
self.assertTrue(graph.retract_edge(edge_id, reason="dup", at=CUTOFF))
for edge in graph.edges:
self.assertEqual(edge.valid_until, "2026-01-01T00:00:00")
self.assertFalse(edge.is_active(datetime(2026, 6, 1)))
def test_retract_node_cascade_closes_every_duplicate(self):
graph, edge_id = self._duplicated()
self.assertTrue(graph.retract_node("alice", at=CUTOFF))
for edge in graph.edges:
self.assertEqual(edge.valid_until, "2026-01-01T00:00:00")
def test_purge_edge_removes_every_duplicate(self):
graph, edge_id = self._duplicated()
self.assertTrue(graph.purge_edge(edge_id, reason="dup"))
self.assertFalse(any(e.edge_id == edge_id for e in graph.edges))
def test_purge_node_cascade_removes_every_duplicate(self):
graph, edge_id = self._duplicated()
self.assertTrue(graph.purge_node("alice"))
self.assertFalse(any(e.edge_id == edge_id for e in graph.edges))
def test_repeat_purge_edge_does_not_overwrite_the_tombstone(self):
"""Once every duplicate is gone, a second call must no-op, not
silently 'complete' the purge again and clobber the original record."""
graph, edge_id = self._duplicated()
self.assertTrue(graph.purge_edge(edge_id, reason="first"))
self.assertFalse(graph.purge_edge(edge_id, reason="second"))
self.assertEqual(graph.get_tombstone(edge_id)["reason"], "first")
class TestPurgeCrossGraphLinks(unittest.TestCase):
"""link_graph() registers a link, a marker node and a bridge edge."""
def _linked(self):
graph = _graph()
other = ContextGraph(advanced_analytics=False)
other.add_node("target", "topic")
return graph, other, graph.link_graph(other, "alice", "target")
def test_purging_the_source_removes_link_marker_and_registration(self):
graph, _, link_id = self._linked()
graph.purge_node("alice", reason="erasure request #5")
self.assertFalse(graph.has_node(f"__cross_graph_{link_id}"))
with self.assertRaises(KeyError):
graph.navigate_to(link_id)
totals = _index_totals(graph)
self.assertEqual(totals["node_index"], totals["nodes"])
self.assertEqual(totals["edge_index"], totals["edges"])
self.assertEqual(totals["adjacency"], totals["edges"])
def test_the_marker_purge_is_recorded_as_cascaded(self):
graph, _, link_id = self._linked()
graph.purge_node("alice")
tombstone = graph.get_tombstone(f"__cross_graph_{link_id}")
self.assertEqual(tombstone["cascaded_from"], "alice")
def test_a_purged_link_is_not_serialized(self):
graph, _, _ = self._linked()
graph.purge_node("alice")
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, "graph.json")
graph.save_to_file(path)
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
self.assertEqual(data["links"], [])
def test_cascade_disabled_still_deregisters_the_link(self):
"""The source node is gone either way, so the link cannot resolve."""
graph, _, link_id = self._linked()
graph.purge_node("alice", cascade=False)
with self.assertRaises(KeyError):
graph.navigate_to(link_id)
self.assertTrue(graph.has_node(f"__cross_graph_{link_id}"))
def test_purging_the_bridge_edge_deregisters_the_link(self):
graph, _, link_id = self._linked()
bridge = next(
edge for edge in graph.edges if edge.metadata.get("link_id") == link_id
)
self.assertTrue(graph.purge_edge(bridge.edge_id))
with self.assertRaises(KeyError):
graph.navigate_to(link_id)
def test_purging_the_marker_node_deregisters_the_link(self):
graph, _, link_id = self._linked()
self.assertTrue(graph.purge_node(f"__cross_graph_{link_id}"))
with self.assertRaises(KeyError):
graph.navigate_to(link_id)
self.assertTrue(graph.has_node("alice"))
def test_an_unrelated_link_survives(self):
graph, other, link_id = self._linked()
graph.purge_node("bob")
self.assertEqual(graph.navigate_to(link_id), (other, "target"))
class TestClearResetsRecords(unittest.TestCase):
def test_clear_drops_retractions_and_tombstones(self):
graph = _graph()
graph.retract_node("alice", at=CUTOFF)
graph.purge_node("bob")
graph.clear()
self.assertEqual(graph.list_retractions(), [])
self.assertEqual(graph.list_tombstones(), [])
def test_load_from_file_drops_records_from_the_previous_graph(self):
source = _graph()
graph = ContextGraph(advanced_analytics=False)
graph.add_node("alice", "person")
graph.add_node("carol", "person")
graph.retract_node("alice", at=CUTOFF)
graph.purge_node("carol")
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, "graph.json")
source.save_to_file(path)
graph.load_from_file(path)
self.assertEqual(graph.list_retractions(), [])
self.assertEqual(graph.list_tombstones(), [])
# The reloaded alice is a fresh record, not one already retracted.
self.assertTrue(graph.retract_node("alice", at=CUTOFF))
class TestAuditTrailIntegration(unittest.TestCase):
"""Against the real TemporalVersionManager, not a mock callback."""
def _attached(self):
manager = TemporalVersionManager()
graph = _graph()
manager.attach_to_graph(graph)
return manager, graph
def _ops(self, manager, entity_id):
history = manager.storage.get_entity_history(entity_id) or []
return [entry.get("operation") for entry in history]
def test_retraction_is_recorded_as_an_update(self):
manager, graph = self._attached()
graph.retract_node("alice", reason="left", at=CUTOFF)
self.assertIn("UPDATE_NODE", self._ops(manager, "alice"))
def test_purge_is_recorded_as_a_removal(self):
manager, graph = self._attached()
graph.purge_node("acme", reason="erasure request #3")
self.assertIn("REMOVE_NODE", self._ops(manager, "acme"))
def test_operations_use_the_documented_mutation_vocabulary(self):
"""MutationRecord documents ADD/UPDATE/REMOVE for nodes and edges."""
manager, graph = self._attached()
graph.retract_node("alice", at=CUTOFF)
graph.purge_node("bob")
allowed = {
"ADD_NODE",
"UPDATE_NODE",
"REMOVE_NODE",
"ADD_EDGE",
"UPDATE_EDGE",
"REMOVE_EDGE",
}
seen = set()
for entity_id in ("alice", "acme", "bob"):
seen.update(self._ops(manager, entity_id))
self.assertTrue(seen)
self.assertTrue(
seen <= allowed, f"undocumented mutation operation(s): {seen - allowed}"
)
class TestMutationEmissionIsSelfContained(unittest.TestCase):
"""Audit payloads must be snapshotted before the lock is released.
The callback fires outside the lock, so anything read from
``_retractions``/``_tombstones`` at emission time can already have been
wiped by a concurrent ``clear()``. A callback that clears the graph on its
first call stands in for that interleaving deterministically.
"""
def _clearing_callback(self, graph, seen):
def callback(operation, entity_id, payload):
seen.append((operation, entity_id, payload))
if len(seen) == 1:
graph.clear()
return callback
def test_purge_emits_every_mutation_after_a_concurrent_clear(self):
graph = _graph()
graph.add_edge("bob", "alice", "knows")
seen = []
graph.mutation_callback = self._clearing_callback(graph, seen)
self.assertTrue(graph.purge_node("alice", reason="erasure request #6"))
self.assertEqual(
[operation for operation, _, _ in seen],
["REMOVE_EDGE", "REMOVE_EDGE", "REMOVE_NODE"],
)
for _, entity_id, payload in seen:
self.assertEqual(payload["entity_id"], entity_id)
self.assertEqual(payload["reason"], "erasure request #6")
def test_retraction_emits_every_mutation_after_a_concurrent_clear(self):
graph = _graph()
graph.add_edge("bob", "alice", "knows")
seen = []
graph.mutation_callback = self._clearing_callback(graph, seen)
self.assertTrue(graph.retract_node("alice", reason="left", at=CUTOFF))
self.assertEqual(
[operation for operation, _, _ in seen],
["UPDATE_NODE", "UPDATE_EDGE", "UPDATE_EDGE"],
)
for _, _, payload in seen:
self.assertEqual(payload["retraction"]["reason"], "left")
class TestConcurrency(unittest.TestCase):
def test_concurrent_purges_keep_indexes_consistent(self):
"""Post-condition, not timing: threads must finish and indexes agree."""
graph = ContextGraph(advanced_analytics=False)
for i in range(60):
graph.add_node(f"n{i}", "t")
for i in range(59):
graph.add_edge(f"n{i}", f"n{i + 1}", "rel")
errors = []
def purge(start):
try:
for i in range(start, 60, 4):
graph.purge_node(f"n{i}")
except Exception as exc: # surfaced below, never swallowed
errors.append(f"{type(exc).__name__}: {exc}")
threads = [
threading.Thread(target=purge, args=(offset,)) for offset in range(4)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=30)
self.assertEqual([t.name for t in threads if t.is_alive()], [])
self.assertEqual(errors, [])
self.assertEqual(len(graph.nodes), 0)
totals = _index_totals(graph)
self.assertEqual(totals["node_index"], 0)
self.assertEqual(totals["edge_index"], 0)
self.assertEqual(totals["adjacency"], 0)
self.assertEqual(totals["edges"], 0)
if __name__ == "__main__":
unittest.main()
-150
View File
@@ -1,150 +0,0 @@
"""Tests for ContextGraph.to_kg_dict() — the official KG-shape adapter.
These tests lock in the contract that to_kg_dict() emits the
``{"entities", "relationships"}`` / ``source_id`` shape expected by
downstream consumers (RDFExporter, TemporalGraphQuery.query_time_range),
so users never need to hand-map field names.
"""
from semantica.context.context_graph import ContextEdge, ContextGraph, ContextNode
def _build_graph():
g = ContextGraph()
g._add_internal_node(ContextNode(node_id="e1", node_type="entity", content="Alice"))
g._add_internal_node(ContextNode(node_id="e2", node_type="entity", content="Bob"))
g._add_internal_node(
ContextNode(node_id="c1", node_type="conversation", content="chat log")
)
g._add_internal_edge(
ContextEdge(
source_id="e1",
target_id="e2",
edge_type="knows",
valid_from="2024-01-01",
valid_until="2024-12-31",
)
)
# Edge touching a non-entity node — used to test entities_only filtering.
g._add_internal_edge(
ContextEdge(source_id="c1", target_id="e1", edge_type="mentions")
)
return g
def test_basic_shape():
kg = _build_graph().to_kg_dict()
assert set(kg.keys()) == {"entities", "relationships", "statistics"}
# Entity shape uses id/text/type (not id/content).
entity = next(e for e in kg["entities"] if e["id"] == "e1")
assert entity["text"] == "Alice"
assert entity["type"] == "entity"
def test_relationship_uses_source_id_target_id():
kg = _build_graph().to_kg_dict()
rel = next(r for r in kg["relationships"] if r["type"] == "knows")
assert rel["source_id"] == "e1"
assert rel["target_id"] == "e2"
# "source"/"target" (the internal names) must NOT leak through.
assert "source" not in rel
assert "target" not in rel
def test_temporal_fields_passthrough():
kg = _build_graph().to_kg_dict()
rel = next(r for r in kg["relationships"] if r["type"] == "knows")
assert rel["valid_from"] == "2024-01-01"
assert rel["valid_until"] == "2024-12-31"
def test_statistics_counts():
kg = _build_graph().to_kg_dict()
assert kg["statistics"]["entity_count"] == len(kg["entities"])
assert kg["statistics"]["relationship_count"] == len(kg["relationships"])
def test_entities_only_filters_nodes():
kg = _build_graph().to_kg_dict(entities_only=True)
types = {e["type"] for e in kg["entities"]}
assert types == {"entity"}
assert len(kg["entities"]) == 2
def test_entities_only_drops_dangling_relationships():
# The "mentions" edge points from a conversation node (filtered out under
# entities_only) and must not appear as a dangling relationship.
kg = _build_graph().to_kg_dict(entities_only=True)
rel_types = {r["type"] for r in kg["relationships"]}
assert "mentions" not in rel_types
assert rel_types == {"knows"}
def test_returned_dicts_are_isolated_from_internal_state():
g = _build_graph()
kg = g.to_kg_dict()
entity = next(e for e in kg["entities"] if e["id"] == "e1")
# Mutating the returned dict must not corrupt internal node properties.
entity["properties"]["injected"] = True
assert "injected" not in g.nodes["e1"].properties
def test_null_properties_and_metadata_do_not_crash():
"""Nodes loaded from JSON ``null`` keep None props/metadata; to_kg_dict
must normalize them instead of raising TypeError (Qodo bug 1)."""
g = ContextGraph()
n = ContextNode(node_id="e1", node_type="entity", content="Alice")
n.properties = None
n.metadata = None
g._add_internal_node(n)
kg = g.to_kg_dict()
entity = kg["entities"][0]
assert entity["properties"] == {}
assert entity["metadata"] == {}
def test_non_string_node_id_is_normalized_and_keeps_edges():
"""ContextEdge coerces endpoints to str; entity ids must be coerced too
so entities_only filtering does not drop valid edges (Qodo bug 3)."""
g = ContextGraph()
g._add_internal_node(ContextNode(node_id=1, node_type="entity", content="one"))
g._add_internal_node(ContextNode(node_id=2, node_type="entity", content="two"))
g._add_internal_edge(ContextEdge(source_id=1, target_id=2, edge_type="links"))
kg = g.to_kg_dict(entities_only=True)
ids = {e["id"] for e in kg["entities"]}
assert ids == {"1", "2"}
assert all(isinstance(e["id"], str) for e in kg["entities"])
# The edge must survive filtering despite the int-vs-str origin.
assert {r["type"] for r in kg["relationships"]} == {"links"}
def test_output_is_consumable_by_kg_utilities():
"""to_kg_dict output must validate and be traversable by KG utilities that
historically read ``source``/``target`` (Qodo bug 2, consumer side)."""
from semantica.kg.graph_validator import GraphValidator, ValidationSeverity
from semantica.kg.temporal_query import TemporalGraphQuery
g = ContextGraph()
g._add_internal_node(ContextNode(node_id="e1", node_type="entity", content="Alice"))
g._add_internal_node(ContextNode(node_id="e2", node_type="entity", content="Bob"))
g._add_internal_edge(ContextEdge(source_id="e1", target_id="e2", edge_type="knows"))
kg = g.to_kg_dict()
# Validator requires entity ``name``; add it so only endpoint compat is tested.
for e in kg["entities"]:
e["name"] = e["text"]
result = GraphValidator().validate(kg)
endpoint_errors = [
i for i in result.issues
if i.code in {"MISSING_FIELD", "DANGLING_EDGE"}
and i.element_type == "relationship"
]
assert endpoint_errors == [], endpoint_errors
# TemporalGraphQuery.analyze_evolution must see the relationship for "e1".
tq = TemporalGraphQuery()
filtered = tq.analyze_evolution(kg, entity="e1")
assert filtered is not None
+1 -224
View File
@@ -1,6 +1,5 @@
"""Integration tests for the explorer API."""
from datetime import datetime
import json
from pathlib import Path
import uuid
@@ -445,63 +444,6 @@ class TestDecisions:
assert violation_response.json()["compliant"] is False
@pytest.fixture(scope="module")
def recorded_client():
"""Client over a graph whose decisions were written by record_decision()."""
graph = ContextGraph(advanced_analytics=False)
entities = ["applicant_A7291"]
graph.record_decision(
category="credit_application",
scenario="Personal loan, $85k income, 31% DTI",
reasoning="Income meets threshold; employment stable",
outcome="proceed_to_underwriting",
confidence=0.88,
entities=entities,
)
graph.record_decision(
category="loan_underwriting",
scenario="Underwriting review for A-7291",
reasoning="DTI within policy; clean 36-month credit history",
outcome="approved",
confidence=0.94,
entities=entities,
)
with TestClient(create_app(session=GraphSession(graph))) as test_client:
yield test_client
class TestRecordedDecisions:
"""Decisions written by record_decision(), not hand-built decision nodes.
record_decision() stores ``timestamp`` as a float epoch. The fixtures above
set no timestamp at all, so these routes were only ever exercised against
decision nodes that could not trigger the float/str mismatch.
"""
def test_list_decisions_serializes_float_timestamp(self, recorded_client):
response = recorded_client.get("/api/decisions")
assert response.status_code == 200
payload = response.json()
assert len(payload) == 2
for item in payload:
assert isinstance(item["timestamp"], str)
datetime.fromisoformat(item["timestamp"])
def test_get_decision(self, recorded_client):
listed = recorded_client.get("/api/decisions").json()
decision_id = listed[0]["decision_id"]
response = recorded_client.get(f"/api/decisions/{decision_id}")
assert response.status_code == 200
assert response.json()["decision_id"] == decision_id
def test_filter_by_category(self, recorded_client):
response = recorded_client.get("/api/decisions?category=loan_underwriting")
assert response.status_code == 200
payload = response.json()
assert len(payload) == 1
assert payload[0]["outcome"] == "approved"
class TestTemporal:
def test_snapshot_now(self, client):
response = client.get("/api/temporal/snapshot")
@@ -660,20 +602,7 @@ class TestEnrichment:
def test_extract(self, client):
response = client.post("/api/enrich/extract", json={"text": "Alice works at Acme Corp."})
# 503 is reserved for a genuinely absent semantic_extract module; it must
# not be reachable on an install where the module imports cleanly.
# Runtime errors from the extraction stack surface as 500, not 422.
assert response.status_code in (200, 422, 500)
def test_extract_returns_entities(self, client):
response = client.post(
"/api/enrich/extract",
json={"text": "Apple CEO Tim Cook announced record earnings in Cupertino."},
)
assert response.status_code == 200
payload = response.json()
assert payload["entities"], "extraction returned no entities"
assert any("Tim Cook" in str(entity) for entity in payload["entities"])
assert response.status_code in (200, 422, 503)
def test_link_prediction(self, client):
response = client.post("/api/enrich/links", json={"node_id": "python", "top_n": 5})
@@ -1228,155 +1157,3 @@ class TestClassifyDistance:
def test_large_hop_count_is_distant(self):
assert classify_path_distance(20) == "distant"
# ---------------------------------------------------------------------------
# Timestamp validator unit tests (no HTTP server needed)
# ---------------------------------------------------------------------------
class TestDecisionResponseTimestampValidator:
"""Unit tests for DecisionResponse._normalize_timestamp.
These run directly against the Pydantic model, not through the HTTP stack,
so they are fast and isolated from the rest of the Explorer infrastructure.
"""
def _make(self, ts):
from semantica.explorer.schemas import DecisionResponse
import pytest as _pytest
return DecisionResponse(decision_id="x", timestamp=ts)
def test_none_passes_through(self):
from semantica.explorer.schemas import DecisionResponse
dr = DecisionResponse(decision_id="x", timestamp=None)
assert dr.timestamp is None
def test_string_passes_through_unchanged(self):
from semantica.explorer.schemas import DecisionResponse
iso = "2024-08-14T10:23:45+00:00"
dr = DecisionResponse(decision_id="x", timestamp=iso)
assert dr.timestamp == iso
def test_float_epoch_becomes_iso_string(self):
from datetime import datetime, timezone
from semantica.explorer.schemas import DecisionResponse
epoch = 1723600000.5
dr = DecisionResponse(decision_id="x", timestamp=epoch)
assert isinstance(dr.timestamp, str)
parsed = datetime.fromisoformat(dr.timestamp)
assert abs(parsed.timestamp() - epoch) < 1.0
def test_int_epoch_becomes_iso_string(self):
from datetime import datetime
from semantica.explorer.schemas import DecisionResponse
epoch = 1723600000
dr = DecisionResponse(decision_id="x", timestamp=epoch)
assert isinstance(dr.timestamp, str)
datetime.fromisoformat(dr.timestamp)
def test_nan_raises_validation_error(self):
import math
import pytest
from pydantic import ValidationError
from semantica.explorer.schemas import DecisionResponse
with pytest.raises(ValidationError):
DecisionResponse(decision_id="x", timestamp=math.nan)
def test_positive_inf_raises_validation_error(self):
import math
import pytest
from pydantic import ValidationError
from semantica.explorer.schemas import DecisionResponse
with pytest.raises(ValidationError):
DecisionResponse(decision_id="x", timestamp=math.inf)
def test_negative_inf_raises_validation_error(self):
import math
import pytest
from pydantic import ValidationError
from semantica.explorer.schemas import DecisionResponse
with pytest.raises(ValidationError):
DecisionResponse(decision_id="x", timestamp=-math.inf)
def test_dict_raises_validation_error(self):
import pytest
from pydantic import ValidationError
from semantica.explorer.schemas import DecisionResponse
with pytest.raises(ValidationError):
DecisionResponse(decision_id="x", timestamp={"$date": 1723600000})
def test_list_raises_validation_error(self):
import pytest
from pydantic import ValidationError
from semantica.explorer.schemas import DecisionResponse
with pytest.raises(ValidationError):
DecisionResponse(decision_id="x", timestamp=[1723600000])
def test_bool_raises_validation_error(self):
import pytest
from pydantic import ValidationError
from semantica.explorer.schemas import DecisionResponse
with pytest.raises(ValidationError):
DecisionResponse(decision_id="x", timestamp=True)
def test_oserror_range_epoch_raises_validation_error(self):
import pytest
from pydantic import ValidationError
from semantica.explorer.schemas import DecisionResponse
# Milliseconds mistakenly stored where seconds were expected.
with pytest.raises(ValidationError):
DecisionResponse(decision_id="x", timestamp=1723600000000)
def test_overflow_range_epoch_raises_validation_error(self):
import pytest
from pydantic import ValidationError
from semantica.explorer.schemas import DecisionResponse
with pytest.raises(ValidationError):
DecisionResponse(decision_id="x", timestamp=1e20)
# ---------------------------------------------------------------------------
# /api/enrich/extract input-size and import-boundary tests
# ---------------------------------------------------------------------------
class TestEnrichExtractValidation:
"""Tests for the input constraints and exception handling added to
POST /api/enrich/extract."""
def test_oversized_input_rejected_before_nlp(self, client):
"""A payload exceeding the 10 000-character limit must be rejected with
422 before any NLP work is attempted."""
oversized = "a " * 5_001 # 10 002 characters
response = client.post("/api/enrich/extract", json={"text": oversized})
assert response.status_code == 422
def test_input_at_limit_is_accepted(self, client):
"""A payload at exactly the maximum length must not be rejected by the
schema validator (NLP may still fail, but the schema must accept it)."""
at_limit = "a" * 10_000
response = client.post("/api/enrich/extract", json={"text": at_limit})
# 503 = module missing, 500 = runtime error from the extraction stack,
# 200 = success. What must NOT happen is a schema rejection (422 from
# Pydantic due to max_length), since this input is exactly at the limit.
assert response.status_code in (200, 500, 503)
def test_import_failure_returns_503_not_422(self, client, monkeypatch):
"""A genuine ImportError on the semantic_extract import must produce 503
(dependency unavailable), NOT 422 (extraction failed)."""
import semantica.explorer.routes.enrich as enrich_module
def _failing_import(name, *args, **kwargs):
if "semantic_extract" in name:
raise ImportError("semantic_extract not installed")
return original_import(name, *args, **kwargs)
import builtins
original_import = builtins.__import__
monkeypatch.setattr(builtins, "__import__", _failing_import)
response = client.post(
"/api/enrich/extract",
json={"text": "Apple was founded by Steve Jobs."},
)
assert response.status_code == 503
assert "semantic_extract" in response.json()["detail"].lower()
-139
View File
@@ -1,139 +0,0 @@
"""The JSON-LD paths must mint the same IRIs as the RDF paths (issue #1101).
#1101 was fixed for the Turtle, N-Triples and RDF/XML serializers: an entity
arriving without an id gets a deterministic IRI in the declared namespace. The
JSON-LD paths were left interpolating the entity's own text into
``f"semantica:entity/{text}"`` and the relationship's endpoints into
``f"semantica:rel/{source}_{target}"``, which fails three ways:
* a text containing a space produces an invalid IRI, and a JSON-LD parser drops
the whole node rather than complaining, so the entity vanishes from the export;
* relationships carrying ``source``/``target`` rather than ``source_id``/
``target_id`` all minted ``semantica:rel/_``, so every one of them collapsed
onto a single node whose types and endpoints merged;
* the JSON-LD @id and the Turtle IRI for one entity disagreed, so the two
serializations of one knowledge graph were two different graphs.
``JSONExporter.export_entities`` and ``export_relationships`` also wrote
``semantica:entities`` into a context that never declared the ``semantica``
prefix, which a JSON-LD processor reads as an IRI in the scheme ``semantica``
the original #1101 failure mode, on a path the first fix did not cover.
"""
import json
import pytest
from semantica.export.json_exporter import JSONExporter
from semantica.export.rdf_exporter import (
RDFExporter,
SEMANTICA_NS,
mint_entity_iri,
mint_relationship_iri,
)
KG = {
"entities": [
{"text": "Acme Corp", "type": "https://example.org/Org"},
{"id": "https://example.org/e2", "text": "Bob"},
],
"relationships": [
{"source": "https://example.org/a", "target": "https://example.org/b",
"type": "https://example.org/employs"},
{"source": "https://example.org/b", "target": "https://example.org/a",
"type": "https://example.org/works_for"},
],
}
def _graph(document: str):
"""Parse a JSON-LD document the way a consumer would."""
rdflib = pytest.importorskip("rdflib")
graph = rdflib.Graph()
graph.parse(data=document, format="json-ld")
return graph
def test_jsonld_entity_id_is_the_minted_iri_not_the_interpolated_text():
graph = json.loads(RDFExporter().export_to_rdf(KG, format="jsonld"))["@graph"]
assert graph[0]["@id"] == mint_entity_iri("Acme Corp")
assert graph[0]["@id"].startswith(SEMANTICA_NS)
assert "semantica:entity/" not in json.dumps(graph)
def test_json_exporter_mints_the_same_entity_iri_as_the_rdf_exporter():
"""One knowledge graph, two exporters, one node identity."""
from_rdf = json.loads(RDFExporter().export_to_rdf(KG, format="jsonld"))["@graph"]
from_json = JSONExporter()._convert_kg_to_jsonld(KG)
assert from_json["semantica:entities"][0]["@id"] == from_rdf[0]["@id"]
assert from_json["semantica:relationships"][0]["@id"] == from_rdf[2]["@id"]
def test_minted_jsonld_iri_agrees_with_the_turtle_serialization():
"""The two serializations of one graph must name the same entity alike."""
turtle = RDFExporter().export_to_rdf(KG, format="turtle")
jsonld = json.loads(RDFExporter().export_to_rdf(KG, format="jsonld"))
minted = mint_entity_iri("Acme Corp")
assert f"<{minted}>" in turtle
assert jsonld["@graph"][0]["@id"] == minted
def test_entity_whose_text_contains_a_space_survives_a_jsonld_parse():
"""The regression that lost data: an invalid IRI is dropped, not reported."""
kg = {"entities": [{"text": "Acme Corp"}], "relationships": []}
graph = _graph(json.dumps(JSONExporter()._convert_kg_to_jsonld(kg)))
subjects = {str(s) for s in graph.subjects()}
assert mint_entity_iri("Acme Corp") in subjects
def test_relationships_carrying_source_and_target_do_not_collide():
"""Two relationships, two nodes: ``semantica:rel/_`` merged them into one."""
jsonld = json.loads(RDFExporter().export_to_rdf(KG, format="jsonld"))
relationships = [n for n in jsonld["@graph"]
if n["@type"] == "semantica:Relationship"]
ids = {node["@id"] for node in relationships}
assert len(ids) == len(relationships) == 2
assert ids == {
mint_relationship_iri(0, "https://example.org/a", "https://example.org/b"),
mint_relationship_iri(1, "https://example.org/b", "https://example.org/a"),
}
graph = _graph(json.dumps(jsonld))
assert len({str(s) for s in graph.subjects()} & ids) == 2
def test_no_export_path_writes_an_iri_in_the_semantica_scheme(tmp_path):
"""Nothing may expand to the scheme ``semantica`` rather than the namespace."""
documents = [
RDFExporter().export_to_rdf(KG, format="jsonld"),
json.dumps(JSONExporter()._convert_kg_to_jsonld(KG)),
]
exporter = JSONExporter()
exporter.export_entities(KG["entities"], tmp_path / "entities.json")
exporter.export_relationships(KG["relationships"], tmp_path / "relationships.json")
documents.append((tmp_path / "entities.json").read_text())
documents.append((tmp_path / "relationships.json").read_text())
for document in documents:
for term in _graph(document).all_nodes():
assert not str(term).startswith("semantica:"), document
for predicate in _graph(document).predicates():
assert not str(predicate).startswith("semantica:"), document
def test_entity_and_relationship_lists_expand_into_the_declared_namespace(tmp_path):
exporter = JSONExporter()
exporter.export_entities(KG["entities"], tmp_path / "entities.json")
exporter.export_relationships(KG["relationships"], tmp_path / "relationships.json")
predicates = set()
for name in ("entities.json", "relationships.json"):
predicates |= {str(p) for p in _graph((tmp_path / name).read_text()).predicates()}
assert f"{SEMANTICA_NS}entities" in predicates
assert f"{SEMANTICA_NS}relationships" in predicates
-57
View File
@@ -301,60 +301,3 @@ def test_nested_properties_are_json_serialized(tmp_path):
by_id = {row[0]: row for row in rows[1:]}
assert by_id["node1"][2] == '{"k":"v"}'
assert by_id["node1"][3] == "[1,2,3]"
def test_unrecognized_mapping_is_refused_rather_than_exported_empty(tmp_path):
"""The Neo4j path reads mappings on the shared normalizer's default terms.
An ``export_json`` envelope names no graph key, so it resolves to nothing.
Written out, that is a pair of header-only CSVs indistinguishable from a
genuinely empty graph -- the silent-empty export the shared contract
exists to prevent.
"""
exporter = Neo4jCSVExporter()
with pytest.raises(ValidationError) as excinfo:
exporter.export({"data": [{"id": "e1"}]}, tmp_path)
message = str(excinfo.value)
assert "data" in message
assert "entities" in message
assert not (tmp_path / "nodes.csv").exists()
assert not (tmp_path / "relationships.csv").exists()
def test_records_under_an_unread_key_are_not_dropped_silently(tmp_path):
"""Naming a recognized key is not enough if nothing resolves from it."""
exporter = Neo4jCSVExporter()
with pytest.raises(ValidationError):
exporter.export({"nodes": [], "data": [{"id": "e1"}]}, tmp_path)
assert not (tmp_path / "nodes.csv").exists()
def test_malformed_collection_value_is_refused(tmp_path):
"""``list("abc")`` would otherwise export one node per character."""
exporter = Neo4jCSVExporter()
for value in ("abc", 42, {"id": "n1"}):
with pytest.raises(ValidationError) as excinfo:
exporter.export({"nodes": value}, tmp_path)
assert "nodes" in str(excinfo.value)
assert not (tmp_path / "nodes.csv").exists()
def test_graph_objects_still_use_the_attribute_path(tmp_path):
"""Only mappings changed; objects are not mappings and are unaffected."""
class Graph:
def __init__(self):
self.nodes = [{"id": "e1", "type": "Person", "name": "Acme"}]
self.edges = []
exporter = Neo4jCSVExporter()
exporter.export(Graph(), tmp_path)
assert "Acme" in (tmp_path / "nodes.csv").read_text(encoding="utf-8")
@@ -1,141 +0,0 @@
"""Minted IRIs must be stable and must sit in the declared namespace (issue #1101).
An entity that arrives without an id gets one minted for it. That identifier was
built from Python's builtin ``hash()``, which is randomised per process, so the
same entity received a different IRI on every run and exports could not be
diffed, deduplicated against an earlier load, or joined to a provenance record
written by an earlier process.
It was also written as ``semantica:entity_N`` inside angle brackets, which is an
IRI in the scheme ``semantica`` rather than the expansion of the declared
``semantica:`` prefix, so it never joined with anything written through it.
"""
import os
import subprocess
import sys
from semantica.export.rdf_exporter import (
DEFAULT_ENTITY_TYPE,
DEFAULT_RELATION_TYPE,
RDFExporter,
SEMANTICA_NS,
mint_entity_iri,
mint_relationship_iri,
)
UNIDENTIFIED = {
"entities": [{"text": "Acme Corp", "type": "https://example.org/Org"}],
"relationships": [],
}
def test_minted_entity_iri_is_stable_within_a_process():
assert mint_entity_iri("Acme Corp") == mint_entity_iri("Acme Corp")
def test_minted_entity_iri_is_stable_across_processes():
"""The regression that matters: identity must survive a restart."""
script = (
"from semantica.export.rdf_exporter import mint_entity_iri;"
"print(mint_entity_iri('Acme Corp'))"
)
runs = {
subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=True,
env={**os.environ, "PYTHONHASHSEED": seed},
).stdout.strip()
for seed in ("0", "1", "random")
}
assert len(runs) == 1, f"minted IRI differs between processes: {runs}"
def test_minted_iris_are_in_the_declared_namespace():
assert mint_entity_iri("Acme Corp").startswith(SEMANTICA_NS)
assert mint_relationship_iri(0, "a", "b").startswith(SEMANTICA_NS)
def test_distinct_entities_get_distinct_iris():
assert mint_entity_iri("Acme Corp") != mint_entity_iri("Acme Corporation")
def test_turtle_export_writes_a_resolvable_minted_iri():
turtle = RDFExporter().export_to_rdf(UNIDENTIFIED, format="turtle")
assert f"<{SEMANTICA_NS}entity_" in turtle
assert "<semantica:entity_" not in turtle, "scheme 'semantica' is not the prefix"
def test_ntriples_export_agrees_with_turtle_on_the_minted_iri():
exporter = RDFExporter()
minted = mint_entity_iri("Acme Corp")
assert minted in exporter.export_to_rdf(UNIDENTIFIED, format="turtle")
assert minted in exporter.export_to_rdf(UNIDENTIFIED, format="ntriples")
def test_default_types_are_written_as_full_iris_in_turtle():
untyped = {"entities": [{"id": "https://example.org/e1", "text": "A"}],
"relationships": [{"source": "https://example.org/e1",
"target": "https://example.org/e2"}]}
turtle = RDFExporter().export_to_rdf(untyped, format="turtle")
assert f"<{DEFAULT_ENTITY_TYPE}>" in turtle
assert f"<{DEFAULT_RELATION_TYPE}>" in turtle
assert "<semantica:Entity>" not in turtle
assert "<semantica:related_to>" not in turtle
def test_default_entity_type_is_a_full_iri_in_rdfxml():
"""RDF/XML's rdf:resource is an attribute value, not a QName context, so a
prefixed default there (``semantica:Entity``) resolves to the scheme
``semantica`` rather than the declared namespace the same failure mode
fixed for Turtle in #1101, missed here because the original tests only
checked Turtle output.
"""
untyped = {"entities": [{"id": "https://example.org/e1", "text": "A"}],
"relationships": []}
rdfxml = RDFExporter().export_to_rdf(untyped, format="rdfxml")
assert f'rdf:resource="{DEFAULT_ENTITY_TYPE}"' in rdfxml
assert 'rdf:resource="semantica:Entity"' not in rdfxml
def test_temporal_minting_uses_either_endpoint_representation():
"""Relationships may carry source/target or source_id/target_id (#1109 review).
Minting from source_id alone hashed empty strings for every relationship
that used the other representation, so once the IRI became deterministic,
unrelated relationships at the same index collided on it and their temporal
data aliased when the exports were loaded together.
"""
def temporal(rel):
return RDFExporter().export_to_rdf(
{"entities": [], "relationships": [rel]},
format="turtle",
include_temporal=True,
)
a = temporal({"source": "https://example.org/a", "target": "https://example.org/b",
"type": "https://example.org/worksFor", "valid_from": "2020-01-01T00:00:00Z"})
b = temporal({"source": "https://example.org/c", "target": "https://example.org/d",
"type": "https://example.org/worksFor", "valid_from": "2020-01-01T00:00:00Z"})
assert f"<{SEMANTICA_NS}rel_" in a
assert a != b, "different endpoints must not mint the same temporal IRI"
def test_temporal_minting_agrees_across_the_two_representations():
"""The same relationship written either way is the same relationship."""
def mint(rel):
return mint_relationship_iri(
0,
rel.get("source_id") or rel.get("source") or "",
rel.get("target_id") or rel.get("target") or "",
)
assert mint({"source": "a", "target": "b"}) == mint({"source_id": "a", "target_id": "b"})
-141
View File
@@ -1,141 +0,0 @@
"""Timestamps that leave the process must carry a timezone (issue #1114).
Every timestamp an exporter wrote was naive: ``datetime.now().isoformat()``
reads the local clock, ``datetime.utcnow().isoformat()`` reads UTC, and the two
serialize identically, so nothing downstream can tell which zone a value belongs
to. In RDF the consequence is not a parse error but a silent one: under XSD 1.1
a value with no timezone compared against one with a timezone is indeterminate
whenever they fall inside the +/-14 hour window, SPARQL turns that into an error,
and FILTER discards errors as non-matches. A timezone-qualified query therefore
returns an answer with every Semantica-written record quietly missing from it.
"""
from datetime import datetime, timedelta, timezone
import pytest
from semantica.export.json_exporter import JSONExporter
from semantica.export.report_generator import ReportGenerator
from semantica.export.yaml_exporter import SemanticNetworkYAMLExporter
from semantica.utils.helpers import utc_now, utc_now_iso
KG = {
"entities": [{"id": "https://example.org/e1", "text": "Bob"}],
"relationships": [],
}
def assert_offset_aware(value):
"""An ISO 8601 string is only an instant if it says which zone it is in."""
assert isinstance(value, str), value
parsed = datetime.fromisoformat(value)
assert parsed.tzinfo is not None, f"timezone-naive timestamp: {value!r}"
assert parsed.utcoffset() is not None
def test_utc_now_iso_is_offset_aware():
assert_offset_aware(utc_now_iso())
assert utc_now().tzinfo is not None
def test_jsonld_export_timestamp_is_offset_aware():
document = JSONExporter()._convert_kg_to_jsonld(KG)
assert_offset_aware(document["semantica:exportedAt"])
def test_json_export_metadata_timestamp_is_offset_aware(tmp_path):
import json
exporter = JSONExporter()
exporter.export_entities(KG["entities"], tmp_path / "entities.json")
exporter.export_relationships([], tmp_path / "relationships.json")
for name in ("entities.json", "relationships.json"):
payload = json.loads((tmp_path / name).read_text())
assert_offset_aware(payload["metadata"]["exported_at"])
def test_yaml_export_timestamp_is_offset_aware():
yaml = pytest.importorskip("yaml")
document = SemanticNetworkYAMLExporter().export_entities(KG["entities"])
payload = yaml.safe_load(document)
assert_offset_aware(payload["metadata"]["exported_at"])
def test_report_timestamp_is_offset_aware():
import json
report = json.loads(
ReportGenerator().generate_quality_report({"score": 0.9}, format="json")
)
assert_offset_aware(report["generated_at"])
def test_exported_timestamp_compares_against_a_timezone_aware_instant():
"""The naive form raised TypeError here, or compared as if it were UTC."""
exported = datetime.fromisoformat(
JSONExporter()._convert_kg_to_jsonld(KG)["semantica:exportedAt"]
)
assert exported <= utc_now()
assert exported > datetime(2020, 1, 1, tzinfo=timezone.utc)
def test_exported_timestamp_survives_a_timezone_qualified_sparql_filter():
"""The regression in #1114: a strict engine dropped the naive value."""
pyoxigraph = pytest.importorskip("pyoxigraph")
exported = JSONExporter()._convert_kg_to_jsonld(KG)["semantica:exportedAt"]
store = pyoxigraph.Store()
store.load(
(
'<https://example.org/export> '
'<https://semantica.dev/ns#exportedAt> '
f'"{exported}"^^<http://www.w3.org/2001/XMLSchema#dateTime> .'
).encode(),
format=pyoxigraph.RdfFormat.N_TRIPLES,
)
# The bound has to sit inside the +/-14 hour window that makes an
# untimezoned comparison indeterminate. A bound years away is determinate
# even for a naive value, and the test would pass without the fix.
bound = (utc_now() + timedelta(hours=1)).isoformat().replace("+00:00", "Z")
rows = list(store.query(
"PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> "
"SELECT ?e WHERE { ?e <https://semantica.dev/ns#exportedAt> ?t . "
f'FILTER (?t < "{bound}"^^xsd:dateTime) }}'
))
assert len(rows) == 1, "the export was dropped by a timezone-qualified filter"
def test_document_iri_carrying_an_offset_is_a_valid_iri():
"""The offset puts '+' and ':' in the @id; both are legal in a path."""
rdflib = pytest.importorskip("rdflib")
document_iri = JSONExporter()._convert_kg_to_jsonld(KG)["@id"]
assert "+00:00" in document_iri
assert rdflib.term._is_valid_uri(document_iri)
graph = rdflib.Graph()
graph.add((
rdflib.URIRef(document_iri),
rdflib.RDF.type,
rdflib.URIRef("https://semantica.dev/ns#KnowledgeGraph"),
))
reparsed = rdflib.Graph().parse(data=graph.serialize(format="nt"), format="nt")
assert document_iri in {str(s) for s in reparsed.subjects()}
def test_vocabulary_range_matches_what_the_exporter_writes():
"""The declared range says the offset is required; the export must carry it."""
rdflib = pytest.importorskip("rdflib")
from rdflib.namespace import RDFS, XSD
from semantica.ontology.vocabulary import NAMESPACE, vocabulary_turtle
graph = rdflib.Graph()
graph.parse(data=vocabulary_turtle(), format="turtle")
declared = graph.value(rdflib.URIRef(f"{NAMESPACE}exportedAt"), RDFS.range)
assert declared == XSD.dateTimeStamp
exported = JSONExporter()._convert_kg_to_jsonld(KG)["semantica:exportedAt"]
assert datetime.fromisoformat(exported).utcoffset() is not None
@@ -1,181 +0,0 @@
"""Regression tests for YAML export input validation (issue #952).
``export_yaml`` declared ``Union[Dict[str, Any], List[Dict[str, Any]]]`` but
both YAML exporters read their payload by key, so a list reached
``semantic_network.get(...)`` and surfaced as a bare
``AttributeError: 'list' object has no attribute 'get'`` from inside the
exporter an error that names neither the offending argument nor the shape
expected.
A list is rejected rather than wrapped. These formats distinguish entities
from relationships from triplets, so inferring which collection a bare list
represents would silently mislabel the records; and wrapping it under an
unrecognised key would write a structurally valid file with every collection
empty, trading a loud failure for silent data loss.
Both directions are pinned: non-mappings raise ``ProcessingError`` with an
actionable message, and every mapping that worked before still exports.
"""
import os
import shutil
import tempfile
import unittest
from collections import OrderedDict, defaultdict
import yaml
from semantica.export.methods import export_yaml
from semantica.export.yaml_exporter import (
SemanticNetworkYAMLExporter,
YAMLSchemaExporter,
)
from semantica.utils.exceptions import ProcessingError
# Non-mapping payloads that must be rejected. A list of dicts is the shape
# from #952; the rest guard the same path against other sequence/scalar types.
NON_MAPPINGS = {
"list_of_dicts": [{"id": "1", "name": "Acme"}],
"empty_list": [],
"tuple_of_dicts": ({"id": "1"},),
"list_of_scalars": ["a", "b"],
"string": "entities",
"bytes": b"entities",
"int": 42,
"none": None,
"set": {"a"},
}
# Both YAML methods, with a minimal valid payload and the key names the
# corresponding error message must mention.
METHODS = {
"semantic_network": {
"valid": {
"entities": [{"id": "1", "name": "Acme"}],
"relationships": [],
"triplets": [],
},
"expected_key": "entities",
"top_level_key": "entities",
},
"schema": {
"valid": {"classes": [{"name": "Thing"}], "properties": []},
"expected_key": "classes",
"top_level_key": "classes",
},
}
class TestExportYamlRejectsNonMappings(unittest.TestCase):
"""Non-mapping input fails loudly, through the public wrapper."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True)
def _path(self, name="out.yaml"):
return os.path.join(self.tmpdir, name)
def test_fixture_tables_are_populated(self):
"""Guard against a vacuous suite.
Every test below iterates a table; emptying or renaming one would let
those loops pass without asserting anything.
"""
self.assertGreaterEqual(len(NON_MAPPINGS), 9)
self.assertEqual(set(METHODS), {"semantic_network", "schema"})
def test_non_mapping_raises_processing_error(self):
for method in METHODS:
for label, payload in NON_MAPPINGS.items():
with self.subTest(method=method, case=label):
with self.assertRaises(ProcessingError):
export_yaml(payload, self._path(), method=method)
def test_error_names_the_offending_type_and_expected_keys(self):
"""The message must be actionable, not just the right exception type."""
for method, spec in METHODS.items():
with self.subTest(method=method):
with self.assertRaises(ProcessingError) as ctx:
export_yaml([{"id": "1"}], self._path(), method=method)
message = str(ctx.exception)
self.assertIn("list", message)
self.assertIn(spec["expected_key"], message)
def test_no_file_is_written_when_input_is_rejected(self):
"""A rejected export must not leave a partial or empty artefact."""
for method in METHODS:
with self.subTest(method=method):
path = self._path(f"{method}_rejected.yaml")
with self.assertRaises(ProcessingError):
export_yaml([{"id": "1"}], path, method=method)
self.assertFalse(os.path.exists(path))
def test_exporter_classes_reject_non_mappings_directly(self):
"""Validation lives in the exporters, not only the convenience wrapper.
Callers using the classes directly get the same contract.
"""
for label, payload in NON_MAPPINGS.items():
with self.subTest(exporter="SemanticNetworkYAMLExporter", case=label):
with self.assertRaises(ProcessingError):
SemanticNetworkYAMLExporter().export_semantic_network(payload)
with self.subTest(exporter="YAMLSchemaExporter", case=label):
with self.assertRaises(ProcessingError):
YAMLSchemaExporter().export_ontology_schema(payload)
class TestExportYamlStillAcceptsMappings(unittest.TestCase):
"""Everything that exported before must still export."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True)
def _path(self, name="out.yaml"):
return os.path.join(self.tmpdir, name)
def _load(self, path):
with open(path, encoding="utf-8") as handle:
return yaml.safe_load(handle)
def test_valid_mapping_exports_for_each_method(self):
for method, spec in METHODS.items():
with self.subTest(method=method):
path = self._path(f"{method}.yaml")
export_yaml(spec["valid"], path, method=method)
self.assertTrue(os.path.exists(path))
loaded = self._load(path)
self.assertIn(spec["top_level_key"], loaded)
def test_semantic_network_records_survive_the_round_trip(self):
path = self._path("network.yaml")
export_yaml(METHODS["semantic_network"]["valid"], path)
loaded = self._load(path)
self.assertEqual(loaded["entities"], [{"id": "1", "name": "Acme"}])
def test_empty_mapping_is_still_accepted(self):
"""An empty dict is a mapping; rejecting it would be a behaviour change."""
for method in METHODS:
with self.subTest(method=method):
path = self._path(f"{method}_empty.yaml")
export_yaml({}, path, method=method)
self.assertTrue(os.path.exists(path))
def test_mapping_subclasses_are_accepted(self):
"""Validation is by Mapping, not dict, so these must keep working."""
valid = METHODS["semantic_network"]["valid"]
subclasses = {
"OrderedDict": OrderedDict(valid),
"defaultdict": defaultdict(list, valid),
}
for label, payload in subclasses.items():
with self.subTest(case=label):
path = self._path(f"{label}.yaml")
export_yaml(payload, path)
loaded = self._load(path)
self.assertEqual(loaded["entities"], valid["entities"])
if __name__ == "__main__":
unittest.main()
@@ -1,434 +0,0 @@
"""Tests for YAML export key recognition (issue #953).
``SemanticNetworkYAMLExporter`` built its output from ``.get(key, [])``
lookups, so a mapping keyed by anything it did not read -- an ``export_json``
envelope, a typo'd 'entitys', ``ContextGraph.to_dict()``'s 'nodes'/'edges' --
serialized to a structurally valid file with every collection empty. Nothing
signalled the loss: no exception, no warning, and the progress log reported a
completed export. ``YAMLSchemaExporter`` had the same defect over a different
key set.
The exporters are run for real rather than mocked, and the written files are
parsed back, since the behaviour under test is what actually lands on disk.
"""
from pathlib import Path
import pytest
import yaml
from semantica.context.context_graph import ContextGraph
from semantica.export.methods import export_json, export_yaml
from semantica.export.yaml_exporter import (
SemanticNetworkYAMLExporter,
YAMLSchemaExporter,
)
from semantica.utils.exceptions import ProcessingError, ValidationError
ENTITIES = [{"id": "e1", "name": "Acme"}, {"id": "e2", "name": "Beta"}]
RELATIONSHIPS = [{"id": "r1", "source": "e1", "target": "e2", "type": "PARTNER"}]
TRIPLETS = [{"subject": "e1", "predicate": "partner_of", "object": "e2"}]
def _load(path):
with open(path, "r", encoding="utf-8") as handle:
return yaml.safe_load(handle)
class TestSemanticNetworkKeyRecognition:
"""An unrecognized mapping is refused instead of silently emptied."""
def test_export_json_envelope_is_rejected(self, tmp_path):
"""The realistic trigger: re-exporting an export_json payload.
``export_json`` wraps records as ``{"data": [...], "count": N,
"metadata": {...}}``. Feeding that straight to ``export_yaml`` used to
write a file with every record gone. Note the envelope's 'metadata'
key is deliberately not enough to make the payload recognized --
treating it as sufficient would readmit exactly this case.
"""
json_path = tmp_path / "records.json"
export_json(ENTITIES, json_path)
envelope = yaml.safe_load(json_path.read_text(encoding="utf-8"))
assert "data" in envelope and "metadata" in envelope
yaml_path = tmp_path / "records.yaml"
with pytest.raises(ValidationError) as excinfo:
export_yaml(envelope, yaml_path)
message = str(excinfo.value)
assert "'data'" in message, "error should name the supplied keys"
assert "'entities'" in message, "error should name the expected keys"
assert not yaml_path.exists(), "a rejected export must write nothing"
@pytest.mark.parametrize(
"payload",
[
{"records": ENTITIES},
{"entitys": ENTITIES},
{"data": ENTITIES},
{"metadata": {"source": "test"}},
],
ids=["records", "typo", "data", "metadata-only"],
)
def test_unrecognized_mappings_are_rejected(self, payload):
exporter = SemanticNetworkYAMLExporter()
with pytest.raises(ValidationError):
exporter.export_semantic_network(payload)
@pytest.mark.parametrize(
"payload",
[
{"entities": [], "data": ENTITIES},
{"nodes": [], "edges": [], "records": ENTITIES},
{"triplets": [], "data": ENTITIES, "metadata": {"source": "test"}},
],
ids=["entities-empty", "nodes-edges-empty", "triplets-empty"],
)
def test_recognized_but_empty_with_records_elsewhere_is_rejected(self, payload):
"""Presence of a recognized key is not proof the records survived.
``{"entities": [], "data": [...]}`` clears a presence-only check and
still resolves to empty, dropping everything under 'data' -- the same
silent-empty export by a narrower route.
"""
exporter = SemanticNetworkYAMLExporter()
with pytest.raises(ValidationError) as excinfo:
exporter.export_semantic_network(payload)
message = str(excinfo.value)
assert "holds records" in message
assert "'entities'" in message, "error should name where records belong"
def test_empty_graph_with_non_record_keys_still_exports(self, tmp_path):
"""The rejection must key on dropped *records*, not on unread keys.
``ContextGraph.to_dict()`` always carries a populated 'statistics'
dict, so an empty graph would be refused if any unread key counted.
"""
graph = ContextGraph()
path = tmp_path / "empty_graph.yaml"
export_yaml(graph.to_dict(), path)
written = _load(path)
assert written["entities"] == []
assert written["relationships"] == []
def test_empty_mapping_still_exports(self, tmp_path):
"""An empty graph is legitimate and carries nothing that could be lost."""
path = tmp_path / "empty.yaml"
export_yaml({}, path)
written = _load(path)
assert written["entities"] == []
assert written["relationships"] == []
assert written["triplets"] == []
def test_recognized_keys_still_export(self, tmp_path):
path = tmp_path / "network.yaml"
export_yaml(
{
"entities": ENTITIES,
"relationships": RELATIONSHIPS,
"triplets": TRIPLETS,
"metadata": {"source": "test"},
},
path,
)
written = _load(path)
assert written["entities"] == ENTITIES
assert written["relationships"] == RELATIONSHIPS
assert written["triplets"] == TRIPLETS
assert written["metadata"]["source"] == "test"
def test_nodes_edges_alias_exports_records(self, tmp_path):
path = tmp_path / "aliased.yaml"
export_yaml({"nodes": ENTITIES, "edges": RELATIONSHIPS}, path)
written = _load(path)
assert written["entities"] == ENTITIES
assert written["relationships"] == RELATIONSHIPS
def test_context_graph_to_dict_round_trips(self, tmp_path):
"""The most direct path from this library's own graph type to YAML.
Built from a real ``ContextGraph`` rather than a hand-written
'nodes'/'edges' dict, so the test breaks if ``to_dict()`` changes
vocabulary.
"""
graph = ContextGraph()
graph.add_node("n1", node_type="Person", content="Alice")
graph.add_node("n2", node_type="Org", content="Acme")
graph.add_edge("n1", "n2", "WORKS_FOR")
path = tmp_path / "context.yaml"
export_yaml(graph.to_dict(), path)
written = _load(path)
assert len(written["entities"]) == 2
assert len(written["relationships"]) == 1
def test_conflicting_spellings_are_refused(self):
"""Two populated spellings of one collection: no basis to pick either."""
exporter = SemanticNetworkYAMLExporter()
with pytest.raises(ValidationError):
exporter.export_semantic_network(
{"entities": ENTITIES, "nodes": [{"id": "other"}]}
)
def test_non_mapping_raises_processing_error(self):
"""A wrong type is a different failure from a wrong-keyed mapping.
ProcessingError says the object cannot be exported at all;
ValidationError says the mapping's contents are unusable. Pinned here
so the two do not quietly converge.
"""
exporter = SemanticNetworkYAMLExporter()
with pytest.raises(ProcessingError):
exporter.export_semantic_network(ENTITIES)
def test_rejected_export_creates_no_output_directory(self, tmp_path):
"""Validation runs before the output directory is created."""
target = tmp_path / "nested" / "out.yaml"
exporter = SemanticNetworkYAMLExporter()
with pytest.raises(ValidationError):
exporter.export({"data": ENTITIES}, target)
assert not target.parent.exists()
class TestPipelineExportKeyRecognition:
"""export_for_pipeline read the same defaulted lookups, so it had the bug too."""
def test_unrecognized_mapping_is_rejected(self):
exporter = SemanticNetworkYAMLExporter()
with pytest.raises(ValidationError):
exporter.export_for_pipeline({"data": ENTITIES})
def test_non_mapping_raises_processing_error(self):
exporter = SemanticNetworkYAMLExporter()
with pytest.raises(ProcessingError):
exporter.export_for_pipeline(ENTITIES)
def test_aliases_resolve_into_the_semantic_network(self):
exporter = SemanticNetworkYAMLExporter()
written = yaml.safe_load(
exporter.export_for_pipeline({"nodes": ENTITIES, "edges": RELATIONSHIPS})
)
assert written["semantic_network"]["entities"] == ENTITIES
assert written["semantic_network"]["relationships"] == RELATIONSHIPS
def test_metadata_is_preserved(self):
exporter = SemanticNetworkYAMLExporter()
written = yaml.safe_load(
exporter.export_for_pipeline(
{"entities": ENTITIES, "metadata": {"source": "test"}}
)
)
assert written["metadata"]["source"] == "test"
assert written["semantic_network"]["entities"] == ENTITIES
class TestSchemaKeyRecognition:
"""method="schema" emitted empty classes/properties/namespaces the same way."""
def test_unrecognized_mapping_is_rejected(self, tmp_path):
path = tmp_path / "schema.yaml"
with pytest.raises(ValidationError) as excinfo:
export_yaml({"nodes": [{"id": "1"}]}, path, method="schema")
message = str(excinfo.value)
assert "'nodes'" in message
assert "'classes'" in message
assert not path.exists()
def test_non_mapping_raises_processing_error(self):
exporter = YAMLSchemaExporter()
with pytest.raises(ProcessingError):
exporter.export_ontology_schema([{"id": "1"}])
def test_recognized_but_empty_with_records_elsewhere_is_rejected(self):
"""The schema path had the same presence-only hole."""
exporter = YAMLSchemaExporter()
with pytest.raises(ValidationError) as excinfo:
exporter.export_ontology_schema({"classes": [], "nodes": [{"id": "1"}]})
assert "holds records" in str(excinfo.value)
def test_ontology_metadata_without_records_still_exports(self):
"""A schema described only by its identity is not a dropped export."""
exporter = YAMLSchemaExporter()
written = yaml.safe_load(
exporter.export_ontology_schema(
{"uri": "http://example.org/o", "classes": []}
)
)
assert written["ontology"]["uri"] == "http://example.org/o"
assert written["classes"] == []
def test_empty_mapping_still_exports(self, tmp_path):
path = tmp_path / "schema.yaml"
export_yaml({}, path, method="schema")
written = _load(path)
assert written["classes"] == []
assert written["properties"] == []
assert written["namespaces"] == {}
@pytest.mark.parametrize(
"payload",
[
{"classes": ["Person"], "properties": ["WORKS_FOR"]},
{"namespaces": {"ex": "http://example.org/"}},
{"uri": "http://example.org/ontology"},
],
ids=["classes-properties", "namespaces-only", "uri-only"],
)
def test_recognized_keys_still_export(self, payload, tmp_path):
path = tmp_path / "schema.yaml"
export_yaml(payload, path, method="schema")
written = _load(path)
assert written["classes"] == payload.get("classes", [])
assert written["properties"] == payload.get("properties", [])
assert written["ontology"]["uri"] == payload.get("uri", "")
# ── Fix regression: scalar recognized keys must not short-circuit the ──
# ── dropped-records check (version, uri, title, description). ──────────
@pytest.mark.parametrize(
"scalar_key, scalar_value",
[
("version", "1.0"),
("uri", "http://example.org/ontology"),
("title", "My Ontology"),
("description", "A test ontology"),
],
ids=["version", "uri", "title", "description"],
)
def test_scalar_recognized_key_does_not_excuse_records_under_unread_key(
self, scalar_key, scalar_value
):
"""A truthy scalar such as version='1.0' must not silence the dropped-
records check. Before the fix, any truthy value from _SCHEMA_KEYS
would make _require_nothing_dropped believe something resolved and
return early, silently discarding a list under an unread key.
"""
exporter = YAMLSchemaExporter()
with pytest.raises(ValidationError) as excinfo:
exporter.export_ontology_schema(
{scalar_key: scalar_value, "nodes": [{"id": "c1"}]}
)
assert "holds records" in str(excinfo.value), str(excinfo.value)
def test_valid_classes_with_scalar_metadata_is_accepted(self):
"""classes/properties populated alongside version/uri must still work."""
exporter = YAMLSchemaExporter()
written = yaml.safe_load(
exporter.export_ontology_schema(
{
"classes": [{"id": "Person"}],
"properties": [{"id": "name"}],
"version": "2.0",
"uri": "http://example.org/o",
}
)
)
assert written["classes"] == [{"id": "Person"}]
assert written["properties"] == [{"id": "name"}]
assert written["ontology"]["version"] == "2.0"
assert written["ontology"]["uri"] == "http://example.org/o"
class TestFailureIsObservable:
"""The complaint in #953 was that the logs affirmatively reported success."""
def test_no_success_is_logged_for_a_rejected_export(self, tmp_path, caplog):
path = tmp_path / "out.yaml"
with caplog.at_level("DEBUG"):
with pytest.raises(ValidationError):
export_yaml({"data": ENTITIES}, path)
assert "Exported YAML to" not in caplog.text
assert any(
record.levelname in ("WARNING", "ERROR", "CRITICAL")
for record in caplog.records
), "a rejected export should leave something at warning or above"
class _RecordingTracker:
"""Records the exporter's own progress calls, which are what is under test."""
def __init__(self):
self.stopped = []
self._next_id = 0
def start_tracking(self, **kwargs):
self._next_id += 1
return str(self._next_id)
def update_tracking(self, tracking_id, **kwargs):
pass
def stop_tracking(self, tracking_id, status=None, message=None):
self.stopped.append((status, message))
class TestProgressReflectsTheWrite:
"""Serialization completing is not the same as the file landing on disk."""
def test_failed_write_is_not_reported_as_completed(self, tmp_path):
"""A write failure after serialization must not leave a clean tracker.
The path's parent is an existing *file*, so directory creation fails
after `export_semantic_network` has already reported its own
completion.
"""
blocker = tmp_path / "blocker"
blocker.write_text("not a directory", encoding="utf-8")
target = blocker / "nested" / "out.yaml"
exporter = SemanticNetworkYAMLExporter()
tracker = _RecordingTracker()
exporter.progress_tracker = tracker
with pytest.raises(OSError):
exporter.export({"entities": ENTITIES}, target)
assert not target.exists()
statuses = [status for status, _ in tracker.stopped]
assert "failed" in statuses, f"write failure went unreported: {tracker.stopped}"
assert not any(
status == "completed" and "Exported YAML" in (message or "")
for status, message in tracker.stopped
), "no span may claim a completed export when nothing was written"
def test_successful_write_is_reported_as_completed(self, tmp_path):
target = tmp_path / "out.yaml"
exporter = SemanticNetworkYAMLExporter()
tracker = _RecordingTracker()
exporter.progress_tracker = tracker
exporter.export({"entities": ENTITIES}, target)
assert target.exists()
assert all(status == "completed" for status, _ in tracker.stopped)
assert any(
"Exported YAML" in (message or "") for _, message in tracker.stopped
), "the write should report its own completion, not just serialization"
class TestUnaffectedExporters:
"""export_json's own behaviour is untouched -- only the YAML path changed."""
def test_export_json_still_accepts_a_bare_list(self, tmp_path):
path = tmp_path / "records.json"
export_json(ENTITIES, path)
assert Path(path).exists()
-35
View File
@@ -1,35 +0,0 @@
"""
Shared pytest fixtures for the ingest test suite.
The ``mock_dns`` fixture is applied to *every* test in this directory
(``autouse=True``). It stubs out ``socket.getaddrinfo`` inside the SSRF
guard module so that unit tests that mock ``requests.Session.request`` do not
accidentally hit the network for DNS resolution which would fail in offline
CI environments and cause intermittent timeouts.
Tests that explicitly need to exercise DNS-related behaviour (e.g. checking
that a hostname resolving to a private IP is blocked) override this fixture
by patching ``semantica.ingest.ssrf.socket.getaddrinfo`` with their own
``side_effect`` *inside* the test body; that inner patch wins because
``unittest.mock.patch`` applies patches in innermost-last order.
"""
from __future__ import annotations
import socket
from unittest.mock import patch
import pytest
_PUBLIC_IP = "93.184.216.34" # example.com — a safe, routable public address
@pytest.fixture(autouse=True)
def mock_dns():
"""Map every hostname to a safe public IP for the duration of each test."""
with patch(
"semantica.ingest.ssrf.socket.getaddrinfo",
return_value=[
(socket.AF_INET, socket.SOCK_STREAM, 6, "", (_PUBLIC_IP, 0))
],
):
yield
File diff suppressed because it is too large Load Diff
+20 -20
View File
@@ -10,20 +10,19 @@ class TestCookbookIntegration:
@pytest.fixture
def mock_mcp_server(self):
# MCPClient._send_request_http now routes through request_with_ssrf_guard,
# which calls requests.request (not httpx.post / requests.post directly).
# Patch at the point where the guard issues the actual HTTP call.
with patch("requests.Session.request") as mock_request:
def side_effect(method, url, json=None, **kwargs):
# We need to patch both httpx and requests because MCPClient tries httpx first
with patch("httpx.post") as mock_httpx_post, \
patch("requests.post") as mock_requests_post:
def side_effect(url, json=None, **kwargs):
if not json:
return MagicMock()
rpc_method = json.get("method")
method = json.get("method")
response_mock = MagicMock()
response_mock.status_code = 200
if rpc_method == "initialize":
if method == "initialize":
response_mock.json.return_value = {
"jsonrpc": "2.0",
"id": json.get("id"),
@@ -33,7 +32,7 @@ class TestCookbookIntegration:
"serverInfo": {"name": "test_server", "version": "1.0"}
}
}
elif rpc_method == "resources/list":
elif method == "resources/list":
response_mock.json.return_value = {
"jsonrpc": "2.0",
"id": json.get("id"),
@@ -45,7 +44,7 @@ class TestCookbookIntegration:
]
}
}
elif rpc_method == "tools/list":
elif method == "tools/list":
response_mock.json.return_value = {
"jsonrpc": "2.0",
"id": json.get("id"),
@@ -57,7 +56,7 @@ class TestCookbookIntegration:
]
}
}
elif rpc_method == "resources/read":
elif method == "resources/read":
response_mock.json.return_value = {
"jsonrpc": "2.0",
"id": json.get("id"),
@@ -67,13 +66,13 @@ class TestCookbookIntegration:
]
}
}
elif rpc_method == "tools/call":
elif method == "tools/call":
tool_name = json.get("params", {}).get("name")
content = [{"type": "text", "text": "Tool Output"}]
if tool_name == "query_inventory":
content = [{"type": "text", "text": '{"warehouse_id": "WH001", "level": 100}'}]
response_mock.json.return_value = {
"jsonrpc": "2.0",
"id": json.get("id"),
@@ -87,11 +86,12 @@ class TestCookbookIntegration:
"id": json.get("id"),
"result": {}
}
return response_mock
mock_request.side_effect = side_effect
yield mock_request
mock_httpx_post.side_effect = side_effect
mock_requests_post.side_effect = side_effect
yield mock_httpx_post
def test_financial_data_integration(self, mock_mcp_server):
"""
+2 -2
View File
@@ -222,7 +222,7 @@ def test_discover_feeds_empty() -> None:
"semantica.ingest.ssrf.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
):
with patch("requests.Session.request", side_effect=fake_request):
with patch("requests.request", side_effect=fake_request):
feeds = ingestor.discover_feeds("http://site.com")
assert len(feeds) == 0
@@ -254,7 +254,7 @@ def test_discover_feeds_found() -> None:
"semantica.ingest.ssrf.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
):
with patch("requests.Session.request", side_effect=fake_request):
with patch("requests.request", side_effect=fake_request):
feeds = ingestor.discover_feeds("http://site.com")
assert "http://site.com/rss.xml" in feeds

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