Compare commits

..
Author SHA1 Message Date
Mohd Kaif 19ff5bf200 Merge branch 'main' into fix/codeql-action-pin 2026-08-29 13:27:43 +05:30
Sameer Kadam 4bf525d409 feat(ingest): add production-ready Salesforce ingestor (#1240)
feat(ingest): add Salesforce ingestor

Adds first-class Salesforce ingestion support, following the existing
Connector + Data + Ingestor architecture already used by the
Snowflake and Databricks integrations: SalesforceConnector /
SalesforceData / SalesforceIngestor, exposed lazily from
semantica.ingest so the base install stays unaffected.

SalesforceConnector supports both auth landscapes Salesforce actually
uses in practice: username + password + security token (SOAP login,
on-prem/sandbox), and session_id + instance_url for reusing an
existing authenticated session. Production and sandbox are selected
through domain, credentials can come from environment variables, and
the connector never intentionally puts credential material into logs,
exceptions, or its own repr.

SalesforceIngestor covers ingest_sobject(), ingest_query(),
list_sobjects(), get_sobject_schema(), and export_as_documents(),
against standard sObjects, custom objects (__c), custom metadata
objects (__mdt), platform events (__e), namespaced objects, and
relationship-field traversal (Owner.Name). Pagination follows
nextRecordsUrl/query_more() automatically and stops once a caller's
limit is satisfied rather than continuing to fetch full pages past it.

Dynamically constructed SOQL is validated before it's sent: sObject
names, field names, relationship paths, ORDER BY expressions, and
numeric limits are checked, and WHERE fragments are screened against
common injection primitives after masking quoted string literals so a
value like status = 'union' doesn't false-positive. Raw SOQL passed
directly to ingest_query() stays intentionally caller-controlled,
since that method is documented as the advanced/unvalidated escape
hatch.

Salesforce-specific attributes metadata is stripped from returned
records before they're handed to the rest of the pipeline, while
relationship data, normal field values, and datetime normalization
are preserved. export_as_documents() uses the Salesforce Id as the
stable document identifier and keeps the source record in document
metadata for provenance.

Wired into the unified ingestion API via ingest_salesforce() and
ingest(source_type="salesforce", ...), registered with
MethodRegistry under sobject/query/list_sobjects/schema/documents.
Isolated behind the semantica[db-salesforce] extra
(simple-salesforce>=1.12.0), included in db-all.

JWT Bearer authentication and Bulk API 2.0 are intentionally out of
scope for this first connector; both are documented as deliberate
follow-ups rather than gaps.

fix(ingest): address Salesforce review findings

- limit now validates as a non-negative integer before use; negative,
  string, and float values raise ValidationError instead of silently
  returning an empty result, raising a bare TypeError, or building an
  invalid LIMIT 0 query
- fields is validated as a non-empty list of strings; a bare string
  (e.g. "Id") no longer gets iterated character-by-character into
  nonsense field names, and an empty list no longer builds a
  syntactically invalid SELECT
- the generic connection-failure path now raises with `from None`
  instead of chaining the original exception, so credential or
  request detail from the underlying library can't surface through a
  traceback
- the unified ingest() dispatch no longer coerces a non-dict source
  into None and silently falling back to environment credentials; an
  invalid source now raises
- _validate_order_by rewritten to validate each dot-separated
  component through _validate_field_name, rejecting malformed
  fragments like "Name." or "Owner..Name" that the previous regex let
  through
- CI conflicts from parallel merges resolved; upstream markdown
  dependency changes preserved

test(ingest): add Salesforce JWT coverage

Adds construction and connect() coverage for the JWT Bearer auth path
(consumer_key + privatekey/privatekey_file), the one auth mode that
had no dedicated tests despite handling private key material.
Also removes _SAFE_ORDER_RE, left behind as dead code once
_validate_order_by was rewritten to use _validate_field_name per
component, and fixes a test-isolation leak where an earlier test left
SALESFORCE_AVAILABLE=True behind for a later test that expected it
False when simple-salesforce isn't installed.
2026-08-29 12:55:37 +05:00
Zohaib Hassnain 8858beb6d9 ci: resync github/codeql-action pin to current v4 2026-08-29 12:36:40 +05:00
Alex Smolya d3183d0ab3 feat(explorer): add deterministic rendering E2E example and test (#1037) (#1041)
feat(explorer): add deterministic rendering E2E example and test (#1037)

Adds a deterministic Explorer graph baseline and coverage for the full
build -> persist -> API -> frontend hydration -> canvas rendering path,
so a regression anywhere along that chain shows up in CI instead manually.

examples/explorer_deterministic_rendering_example.py builds the
canonical 4-node, 3-edge graph (Alice -WORKS_AT-> Acme, Bob -KNOWS->
Alice, Acme -LOCATED_IN-> New York) with ContextGraph.add_node()/
add_edge(), persists it with save_to_file() and reloads it with
GraphSession.from_file(), printing the setup prerequisites and the
expected node/edge/label checklist for anyone running it by hand.

tests/explorer/test_explorer_deterministic_rendering_e2e.py covers
graph construction, the serialize/deserialize round trip, GraphSession
loading, and the Explorer API's /api/graph/* responses against the
exact expected nodes, edges, and labels, plus all three auth modes
(unconfigured, API-key required, anonymous opt-in).

fix(explorer): address Qodo review findings for deterministic rendering e2e (#1037)

- configure SEMANTICA_ALLOW_ANONYMOUS=true and document
  SEMANTICA_API_KEY as the alternative in the reproduction
  instructions, so the documented commands don't 503 on a clean
  checkout
- add clean-checkout prerequisites and a visual verification
  checklist to the example
- add edge-label (WORKS_AT, KNOWS, LOCATED_IN), zoom-tier, and
  hover-interaction coverage to the frontend test
- add an explicit auth-enforcement integration test for the
  deterministic graph endpoints

fix(explorer): connect deterministic rendering E2E path

The frontend test built its own node/edge objects directly with
batchMergeNodes()/batchMergeEdges(), bypassing the real loading path
entirely -- it never went through useLoadGraph, never mounted the
canvas, and its fixture didn't even carry the same fields the backend
actually returns (e.g. no color values), so a break in API hydration,
the edge.type -> edgeType mapping, or canvas label rendering could
still pass.

Adds deterministicExplorerRendering.e2e.ts, which mounts the real
Explorer app in Chromium, serves API-shaped /api/graph/nodes and
/api/graph/edges responses through route interception, drives the
app through its actual useLoadGraph hydration path into a real Sigma
canvas, and asserts on captured canvas fillText() calls that
WORKS_AT, KNOWS, and LOCATED_IN are genuinely drawn, both after load
and after Zoom In.

fix(explorer): preserve upstream markdown dependencies
ci(explorer): isolate deterministic backend test dependencies

Wires the new Python test into ci.yml as its own focused step (it
previously only ran manually), installs Playwright's Chromium
browser before the frontend suite, and keeps the deterministic
backend test's dependency install separate from the rest of the
pipeline so it doesn't pull in unrelated optional extras during
collection.

fix(explorer): remove redundant edge label hydration

An earlier commit in this PR added an explicit `label` field to
hydrated edge attributes on the theory that it was needed for edge
labels to render. Review traced through GraphCanvas.tsx's label
resolution (`attrs.edgeType || data.label || ""`, from the earlier
#1009 fix already on main) and found that `edgeType` is set
unconditionally on every edge during hydration, so it always wins the
`||` before `data.label` is ever consulted -- the added field and its
plumbing in useLoadGraph.ts and graphStore.ts never did anything.
Removed both; reran the real Chromium E2E test against the reverted
code and confirmed all three labels still render identically, closing
out the question of whether anything else was actually broken.
2026-08-29 12:25:58 +05:00
Kevin Zhang da642f12fa fix(export): @vocab mints into the shipped ns# namespace (#1236)
fix(export): keep caller data out of the shipped ns# namespace

Every JSON-LD context set @vocab to https://semantica.dev/vocab/,
which 404s, so every bare term in caller data (extracted entity/
relationship types, arbitrary metadata keys) minted under a namespace
the package never ships. The obvious fix, pointing @vocab at
SEMANTICA_NS instead, turned out to be worse than the dead link: since
that namespace is real and populated, every bare term a caller happens
to use now expands into something that looks like official Semantica
vocabulary. An extracted type "ORG" became ns#ORG, a class the
vocabulary never defines. A metadata key "source" attached a plain
string value to sem:source, an owl:ObjectProperty that already exists
in semantica-ns.ttl with a resource-valued range, silently corrupting
its semantics.

@vocab is now removed from all five contexts (four in
json_exporter.py, one in rdf_exporter.py) rather than repointed.
Every document already used explicit semantica: prefixes for its own
terms, so nothing else in the output changes; an unscoped bare term
now simply fails to expand, which is standard JSON-LD behavior for a
context that doesn't know it, instead of being silently claimed by
our namespace.

Two call sites needed to stop handing caller data to @type/bare terms
in the first place:

- Entity nodes are always typed semantica:Entity now, with the
  caller's label carried as a semantica:type string instead of
  minted into @type. This matches how relationship nodes already
  carried their type. sem:type's domain in semantica-ns.ttl opens up
  to cover entities as well as relationships, following the
  sem:confidence precedent, since the property is now legitimately
  emitted for both.
- semantica:metadata gets an explicit @json term definition, so a
  caller's metadata dict travels as one rdf:JSON literal instead of
  having its keys expand as separate predicates. A metadata key can
  no longer collide with a real ontology term no matter what the
  caller names it.

Both JSONExporter and RDFExporter.serialize_to_jsonld got the same
treatment, since they build separate JSON-LD structures for the same
underlying data.

The regression tests assert the negative space this bug lived in: no
context declares @vocab, no caller type label appears as an rdf:type
under ns#, and no caller metadata key appears as a predicate under
ns# at all, only as content inside the single JSON literal.

Closes #1146
2026-08-28 19:53:35 +05:00
Aldrin Joseph 5376f046ca fix(explorer): dedupe temporal snapshot requests and apply latest-wins (#1241)
fix(explorer): dedupe temporal snapshot requests and apply latest-wins

The temporal snapshot effect fetched /api/temporal/snapshot with no
idempotency or ordering guards. Upstream churn (timeline recreation
while bounds settle, play ticks resetting the playhead, drag events)
could re-request the same `at` repeatedly, and with variable network
latency an older position's response could land after a newer one's,
overwriting the active-node count, so the chip visibly lagged the
scrubber.

Add a small stateful guard module (temporalSnapshotGuards.ts) built
around a per-position cache, keyed by the debounced timestamp's
primitive millisecond value rather than the Date object, so upstream
object-identity churn cannot defeat the dedup on its own:

- at most one in-flight request per scrubber position, so identical
  `at` values arriving while a request is pending are dropped instead
  of firing a fresh fetch, breaking the idle/play polling loop;
- successful snapshots are cached per position and re-applied when the
  scrubber returns to it (play wrap-around, back-scrubbing) without a
  network round trip;
- a response is applied only while the scrubber is still on the
  position it was requested for, so an out-of-order response can never
  clobber a newer position's count;
- failed, cancelled, or superseded requests release their position so
  it can be fetched again the next time it's visited, rather than
  stalling it permanently;
- reset() drops all cached and in-flight state when the underlying
  graph summary changes (reload/retry), since snapshots cached against
  the previous graph no longer describe anything real. Keyed on the
  summary query's data identity, which react-query keeps stable
  (staleTime: Infinity plus structural sharing) unless the graph data
  itself was replaced, so reset fires exactly on a real reload and not
  on cosmetic re-renders.

The snapshot effect is wired through the guards end to end: begin()
returns either a fresh sequence number to fetch under or a cached
snapshot to reapply directly; the same shouldApply()/apply() gate
handles both the network and cached-reapply paths so they can't drift
apart; finish() runs from both the fetch's failure branch and its
cleanup function, so a cancelled or failed request is always retryable
on the next visit instead of leaving its position stuck in-flight.

16 unit tests cover dedup, independent positions, revisit re-apply,
play wrap-around, failure retry, stale-sequence protection (a late
response or a late release from a superseded request cannot act on a
newer request's position), reset-on-reload, and cache-bound eviction.

Closes #1128
2026-08-28 19:45:13 +05:00
Guofang.Tang 56d9e9a857 fix(ontology): coalesce normalized property collisions (#1231)
fix(ontology): coalesce normalized property collisions

Different raw property spellings can normalize to the same ontology
name and IRI. works_for and worksFor, for example, both normalize to
worksFor, but property inference emitted a separate definition for
each spelling, so the generated ontology declared two distinct
properties under what would become the same IRI once minted. The same
collapse could also happen across kinds: a relationship type and an
entity attribute that normalize to the same name would previously
produce a data property and an object property sharing one name, with
no signal that anything was wrong.

infer_properties() now runs a coalescing pass after object and data
properties are both inferred. Properties are grouped by (kind, name).
Object properties that collide are merged in occurrence order:
domains and ranges are unioned rather than overwritten, so a property
seen across several source classes keeps every domain instead of
losing all but the first, and occurrence_count is summed across the
merged spellings so downstream confidence/frequency signals stay
correct. Data properties merge domains the same way and reconcile
differing ranges through the existing _get_more_general_type()
widening logic already used elsewhere in this file, rather than a new
implementation.

A name that resolves to both an object property and a data property
is not silently coalesced into either one, since the two kinds mean
different things in the emitted ontology. That case raises a
ValidationError up front, naming every colliding name and which kinds
collided, so the conflict surfaces before an ambiguous ontology is
written rather than after.

Verified beyond the two cases in the new test file: a data property
colliding across two different domain classes correctly unions the
domain instead of keeping only the first class, and three distinct
spellings of the same relationship type collapse into one property
with the occurrence count correctly summed across all three.

Follow-up to #1170 (relationship endpoint types) and #1171 (retained
data properties for normalized class names).
2026-08-28 16:23:46 +05:00
KaifAhmad1 ecb33a5b7d chore(release): prepare v0.6.7
Bump version, cut CHANGELOG's Unreleased section into 0.6.7, backfill
changelog entries for merged PRs missing from it, and refresh
version-dependent references in README/docs.
2026-08-28 15:51:17 +05:30
Kevin Zhang 100e95a098 feat(ingest): add SAP OData ingestor (#1228) (#1234)
Adds `SAPODataConnector`, `SAPODataEntity`, and `SAPIngestor` for ingesting master and transactional data from SAP OData services, mainly things like Business Partners and Sales Orders.

Tested around S/4HANA Cloud, SuccessFactors, and on-prem NetWeaver Gateway style OData endpoints.

Main pieces included:

* OAuth2 client credentials and Basic auth support. Both go through the existing `ssrf.py` checks, including the OAuth token request.
* Small EDMX parser used by `discover_service()` so we don't need to pull in `pyodata`.
* Server-side pagination support for both OData versions:

  * v2: `__next`, including plain string and `__deferred` formats
  * v4: `@odata.nextLink`
* Keeps the service path in the base URL correctly whether the URL has a trailing slash or not. This is normalized in `SAPIngestor.__init__`.
* Adds an `ingest-sap` extra with just `requests`, so there is no SAP/proprietary SDK dependency.

This is meant to be a fairly small first version of the connector without adding a lot of SAP-specific dependencies.

Closes #1228
2026-08-28 14:54:02 +05:00
cxzg007and江俊杰 cce5ea177c fix(pipeline): set_parallelism now enables dependency-layer parallel execution (closes #1223) (#1226)
PipelineBuilder.set_parallelism() validated and stored a level in
pipeline config, but ExecutionEngine._execute_steps() had no parallel
code path and no code ever read it back, so steps always ran strictly
sequentially regardless of the configured value. parallelism was also
lost across a serialize/deserialize round trip, since the nested
config key was never promoted to the top-level dict build_pipeline()
reads.

Steps are now grouped into dependency layers (declaration order
preserved within each layer). A layer runs concurrently, bounded by
ThreadPoolExecutor(max_workers=min(configured parallelism, engine
max_workers)), only when every one of the following holds: more than
one step in the layer, the shared input is a dict, every step is
opted in via the new PipelineStep.parallel_safe flag, and no step is
in delta_mode. Any layer that doesn't meet all four falls back to the
existing sequential path unchanged.

Each step's input is deep-copied before any handler in the layer
starts, so concurrent steps never share mutable state. Layer results
are merged back in declaration order, not completion order; keys
whose value is unchanged from the shared input are treated as an
echo rather than a write, so two handlers both returning {**data, ...}
don't spuriously conflict on keys neither of them actually touched.
Genuinely conflicting values for the same key raise ProcessingError
naming both the key and the two steps involved. Retry policy, step
status, result/error tracking, and progress reporting are shared
between the sequential and parallel paths so behavior stays identical
either way. On step failure, not yet started futures in the same
layer are cancelled and the error propagates, so no downstream layer
ever runs.

parallel_safe is opt-in per step because handlers that share mutable
state or depend on strict ordering are not safe to run concurrently.
ParallelismManager.execute_pipeline_steps_parallel() is deliberately
not reused here; the engine implements its own bounded layer
scheduler so retry/status semantics stay identical between the
sequential and parallel code paths instead of diverging.

fix(pipeline): address qodo review findings on PR #1226

- detect circular/unknown dependencies in parallel grouping
  (ValidationError instead of RecursionError/KeyError)
- skip unchanged echoed keys in parallel result merging to
  avoid false conflicts
- fail before COMPLETED status when a parallel step returns a
  non-dict; never retry such contract violations
- require strict bool parallel_safe in builder and engine gate
- make per-step progress tracking IDs unique across same-type
  parallel steps

---------

Co-authored-by: 江俊杰 <jiangjunjie.37@jd.com>
2026-08-28 12:33:39 +05:00
Yunare MaiaandSameer Kadam e12eec40a1 refactor(ner): remove dead _extract_with_spacy method and unused self.nlp (#1220)
* test(ner): fix NER configuration tests for the typed LLM extraction API

Two of the three failing tests tracked in #1059 were still red after
#1070 was closed because the mocks targeted the pre-typed provider API:

- test_ner_llm_config mocked generate_structured, but the LLM path now
  goes through generate_typed with a Pydantic schema. Mock the typed
  response (namespace items with .text/.label/.start/.end/.confidence)
  and expect extraction_method 'llm_typed'.
- test_ner_pattern_config asserted 'Apple Inc' without the trailing
  dot, but the ORG pattern captures it via (?:\.|\b). Assert 'Apple
  Inc.' to match current production behavior.

Verified locally: 8/8 pass in test_ner_configurations.py; the
performance-test failures in tests/semantic_extract/ reproduce on a
clean main checkout and are unrelated.

Fixes #1059

Signed-off-by: Yunare Maia <yunare@gmail.com>

* refactor(ner): remove dead _extract_with_spacy method and unused self.nlp

_extract_with_spacy() had no callers: the ML dispatch path goes through
get_entity_method('ml') -> extract_entities_ml(), which loads the spaCy
model lazily via the process-level cache in methods.py. The instance
attribute self.nlp was only read by that dead method, so __init__ now
just validates the runtime (keeping the _ml_runtime_usable gate) instead
of eagerly loading a model that was never used.

Fixes #1058

Signed-off-by: Yunare Maia <yunare@gmail.com>

* test(split): rewrite NERExtractor cache tests to not rely on removed .nlp attribute

NERExtractor.nlp was removed in this PR as part of dead-code cleanup
(the attribute was only used by the equally-dead _extract_with_spacy()).
The three affected tests in TestNERExtractorSpacyModelCache previously
verified cache behavior through .nlp identity comparisons; rewrite them
to use load-call counts and direct se_methods.load_spacy_model() cache
queries instead:

- test_ner_extractor_reuses_cached_model_across_instances: drop the
  e1.nlp is e2.nlp is e3.nlp assertion; len(calls)==1 already proves
  reuse; add a cache query to confirm the cached object is non-None.

- test_ner_extractor_distinct_model_names_load_separately: store each
  mock nlp in a dict keyed by name, then query the cache to assert
  sm_cached is loaded['en_core_web_sm'] and sm_cached is not lg_cached.

- test_ner_extractor_failed_load_not_cached_and_retried: replace
  extractor.nlp is None/not None with is-not-None construction checks
  and a final cache query that verifies the recovered model is the
  exact object returned by working_load.

All three tests still exercise the original behavioral contract (no
crash on missing model, failures not cached / retried, successful load
shared across instances); they just no longer rely on a private
instance attribute that no longer exists.

---------

Signed-off-by: Yunare Maia <yunare@gmail.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-27 17:56:03 +05:30
Kyou 4da27c38bb fix(config): honor boolean env overrides in Config.get() (#1038)
fix(config): honor boolean env overrides in Config.get() (#1038)

Config.get() checked int before bool. Since bool subclasses int, boolean
environment values could be ignored or returned as integers.

Check bool first and strip whitespace before parsing boolean environment
values. This applies to the config modules for conflicts, deduplication,
split, embeddings, export, ingest, kg, normalize, ontology, and parse.

Also make _load_env_vars() use the same whitespace handling for mapped and
generic environment variables.

Fixes #1035
2026-08-27 16:33:09 +05:00
7f928f9f8e fix(parse): warn when PDF parse yields no text layer (scanned PDFs) (#1021)
* fix(parse): import email.message and repair pdfplumber test mock

- email_parser.py uses email.message.Message at class-definition time but
  only did 'import email', so 'import semantica.parse' fails in a fresh
  Python process unless something else imported email.message first
- test_pdf_parser patched semantica.parse.pdf_parser.pdfplumber, which
  never exists as a module attribute (pdfplumber is imported inside
  PDFParser.parse); inject a fake module via sys.modules instead

* fix(parse): warn when PDF parse yields no text layer (scanned PDFs)

Scanned (image-only) PDFs parsed via the default pdfplumber route
returned an empty full_text with progress status 'completed' - no error,
no warning - so the failure only surfaced far downstream. Warn in
PDFParser.parse() when every parsed page yields no text (and extract_text
is enabled), pointing users to method='docling' with enable_ocr=True.

* fix(parse): improve scanned PDF detection

---------

Co-authored-by: shanyu910 <208111055+shanyu910@users.noreply.github.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-27 16:51:04 +05:30
aoright 65e6dcfef5 fix(worker): remove unused sys import and organize imports (#1061)
Signed-off-by: aoright <102943475+aoright@users.noreply.github.com>
2026-08-27 16:08:33 +05:00
pravit-ampandPravit Ampapathini 0775b0114e test(provenance): assert stored records in KG provenance suites (#946) (#1132)
* fix(provenance): use timezone-aware UTC and assert stored records (#946)

Replace datetime.utcnow() in ProvenanceManager, ProvenanceEntry,
BridgeAxiom, and GraphBuilderWithProvenance with
datetime.now(timezone.utc), matching PipelineWithProvenance.

KG workflow and integration tests now read provenance back through
get_provenance() and assert algorithm metadata instead of generated
IDs, and call tracker methods that actually persist records.

* fix(provenance): compare provenance timestamps as instants, not strings

query_recorded_between() and audit_log() filtered and sorted on raw ISO
strings. With the timezone-aware change, a store can hold both pre-existing
naive stamps and offset-bearing ones, and the two are not string-comparable:
"...500000+00:00" sorts above "...500000", so a record at the identical
instant as a naive bound falls outside the range that should contain it.

Both now parse through _parse_timestamp() before comparing, reading naive
values as UTC. This mirrors ProvenanceTracker._parse_dt() in kg/, the class
ProvenanceManager replaces, so both sides of the migration answer a range
query the same way. Unparseable stored timestamps are skipped and logged
rather than silently dropped; unparseable bounds raise ValueError.

---------

Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
2026-08-27 15:40:38 +05:00
Mohd Kaif f4c3064571 Merge pull request #1113 from cxzg007/fix/rdf-name-label-normalization
fix(export): normalize entity name to label on all RDF paths
2026-08-27 16:08:53 +05:30
KaifAhmad1 b2dc633796 Merge remote-tracking branch 'origin/main' into pr-1113-work
# Conflicts:
#	semantica/export/rdf_exporter.py
2026-08-27 15:51:50 +05:30
Mohd Kaif 13b287b974 Merge pull request #1173 from yzxcj797/fix/neo4j-edge-id-space-1136
fix(graph_store): resolve application ids to internal ids when creating relationships
2026-08-27 15:39:02 +05:30
Mohd Kaif cec9bee099 Merge branch 'main' into fix/neo4j-edge-id-space-1136 2026-08-27 15:31:51 +05:30
Mohd Kaif 36ced4e826 Merge pull request #1225 from LeonSGP43/cookbook-index-22-25
docs(cookbook): add index entries for notebooks 22-25
2026-08-27 15:24:02 +05:30
LeonSGP43 6032b4e0bc docs(cookbook): add index entries for notebooks 22-25
Index the four module notebooks merged via #989-#992 (Provenance
Tracking, Reasoning, Change Management, Seed Data) in the cookbook
landing page, as committed in tracking issue #1032.

Signed-off-by: LeonSGP43 <cine.dreamer.one@gmail.com>
2026-08-27 17:39:48 +08:00
yzxcj797 8db95f00c6 fix(utils): raise on key collision in flatten_dict instead of silently dropping values (#1012) 2026-08-27 15:07:53 +05:30
Guofang.Tang 23baf21d5a fix(ontology): retain data properties for normalized class names (#1171)
* fix(ontology): retain properties for normalized class names

* perf(ontology): precompute normalized class lookup
2026-08-27 13:32:14 +05:00
cxzg007and江俊杰 5d54919804 feat(reasoning): rule-driven actions with provenance (#1096)
* feat(reasoning): rule-driven actions with provenance

Add a structured Action layer so matched rules can trigger side effects
instead of only deriving new facts, turning the reasoner into a
production-rule system.

L1 - Action type system:
- Action base class with execute(bindings, reasoner) + ?var substitution
- AssertAction (optional write-back to KnowledgeGraph), RetractAction,
  CallAction (structured replacement for the unused Rule.handler),
  EmitEventAction (delivers to a registered event sink)
- Rule.actions field; wired into Reasoner.forward_chain() and
  ReteEngine.execute_matches() (via optional bind_reasoner)

L2 - Provenance-aware actions:
- Reasoner records fired actions (rule, bindings, confidence) to
  action_log when provenance is enabled
- Fix dangling import in reasoning_provenance.py (ReasoningEngine ->
  Reasoner, infer -> infer_facts)

Backward compatible: rules using the legacy handler still fire (wrapped
as a CallAction); rules without actions behave exactly as before.

Adds tests/reasoning/test_rule_actions.py (9 tests).

Closes #1095

* fix(reasoning): address qodo review findings on rule actions

- Token-aware variable substitution to avoid ?x/?xy prefix collision
- KnowledgeGraph write-back protocol (explicit API -> canonical translation -> ValueError)
- Structured action_log entries with timestamp
- Decouple action firing from conclusion dedup via per-activation tracking
  (fires known conclusions once; retract-self no longer loops to max_iterations)
- Add Reasoner.infer_with_results preserving confidence; infer_facts delegates
- Forward provenance flag in ReasoningProvenance; drop **kwargs; propagate confidence
- Populate Rete Match.bindings from rule conditions
- Add regression tests for each fix

* fix(reasoning): persist fired action activations

* fix(reasoning): deduplicate Rete action execution

* fix(reasoning): canonicalize action activation identity

* docs(reasoning): explain action replay controls

---------

Co-authored-by: 江俊杰 <jiangjunjie.37@jd.com>
2026-08-27 13:18:07 +05:00
cxzg007and江俊杰 c9c777993b fix(pipeline): wire registered step handlers (#1215)
* fix(pipeline): wire registered step handlers

Resolve handlers registered by step type, keep explicit handlers authoritative, and prevent builder control fields from leaking into runtime kwargs.

Refs #1214

* fix(pipeline): preserve dependencies on deserialize

* fix(pipeline): dispatch falsy handlers via identity check

---------

Co-authored-by: 江俊杰 <jiangjunjie.37@jd.com>
2026-08-27 13:11:42 +05:00
LeonSGPandLeonSGP43 9cec305a75 docs(cookbook): add Seed Data module notebook (#992)
* docs(cookbook): add Seed Data module notebook

Add cookbook/introduction/25_Seed_Data.ipynb covering the seed module
with verified, executable examples:

- SeedDataManager.register_source with a CSV source
- load_source record enrichment (entity_type/source provenance)
- create_foundation_graph entity/relationship/metadata structure
- validate_quality gating

The seed module ships seed_usage.md but has no cookbook coverage. All
API calls and outputs were executed against
semantica/seed/seed_manager.py.

Signed-off-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>

* docs(cookbook): isolate seed CSV in a temp dir and execute notebook in Jupyter

- Write companies.csv into a session-scoped tempfile.mkdtemp() directory
  instead of the working directory, so a user's existing companies.csv
  can never be silently clobbered (review finding)
- Run the notebook through a fresh Jupyter kernel (restart + run all +
  save): real execution counts, print() cells saved as stream outputs

Signed-off-by: LeonSGP43 <cine.dreamer.one@gmail.com>

---------

Signed-off-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
Signed-off-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
2026-08-27 13:06:23 +05:00
LeonSGPandLeonSGP43 3d0ce55fd7 docs(cookbook): add Change Management module notebook (#991)
* docs(cookbook): add Change Management module notebook

Add cookbook/introduction/24_Change_Management.ipynb covering the
change_management module with verified, executable examples:

- ChangeLogEntry with email-validated author field
- InMemoryVersionStorage save/get/list_all/exists/delete round trip
- named tags (save_tag/get_tag) for release pinning
- compute_checksum / verify_checksum integrity verification with
  tamper detection

The change_management module currently has no cookbook coverage. All
API calls and outputs were executed against
semantica/change_management/change_log.py and version_storage.py.

Signed-off-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>

* docs(cookbook): clarify outputs verified against repo source, not PyPI release

Signed-off-by: LeonSGP43 <leonsgp43@users.noreply.github.com>

* docs(cookbook): execute change management notebook in Jupyter (real kernel run, stream outputs, execution counts)

Signed-off-by: LeonSGP43 <cine.dreamer.one@gmail.com>

---------

Signed-off-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
Signed-off-by: LeonSGP43 <leonsgp43@users.noreply.github.com>
Signed-off-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
2026-08-27 13:00:36 +05:00
LeonSGPandLeonSGP43 b13cc1cca2 docs(cookbook): add Reasoning module notebook (#990)
* docs(cookbook): add Reasoning module notebook

Add cookbook/introduction/23_Reasoning.ipynb covering the reasoning
module with verified, executable examples:

- Reasoner facade: add_fact / add_rule / forward_chain
- one-shot infer_facts(facts, rules)
- backward_chain goal proving with premises
- re-run-safe rule deduplication (#732)
- DatalogReasoner: semi-naive fixpoint evaluation + variable queries
- ExplanationGenerator: Explanation / ReasoningPath records

The reasoning module currently has no cookbook coverage even though it
ships reasoning_usage.md in the package. All API calls and outputs were
verified against semantica/reasoning/reasoner.py,
datalog_reasoner.py, and explanation_generator.py.

Signed-off-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>

* docs(cookbook): correct infer_facts semantics description (appends to instance state, no reset)

Signed-off-by: LeonSGP43 <leonsgp43@users.noreply.github.com>

* docs(cookbook): execute reasoning notebook in Jupyter (real kernel run, stream outputs, execution counts)

Signed-off-by: LeonSGP43 <cine.dreamer.one@gmail.com>

---------

Signed-off-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
Signed-off-by: LeonSGP43 <leonsgp43@users.noreply.github.com>
Signed-off-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
2026-08-27 12:55:02 +05:00
yzxcj797andSameer Kadam f187d4b5da fix(embeddings): stop the registry dispatch from calling wrappers back into themselves (#1005)
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-27 01:18:03 +05:30
Mohd Kaif 8e79c65542 Merge pull request #1205 from semantica-agi/dependabot/pip/google-genai-2.19.0
security(deps): bump google-genai from 2.18.1 to 2.19.0
2026-08-26 23:21:07 +05:30
Mohd Kaif 91ea31b460 Merge branch 'main' into dependabot/pip/google-genai-2.19.0 2026-08-26 23:09:30 +05:30
c49e77d059 fix(ingest): import sqlalchemy text where DBIngestor and DataExporter use it (#1017)
* fix(ingest): import sqlalchemy text where DBIngestor and DataExporter use it

sqlalchemy.text was imported function-locally in DatabaseConnector.connect
and test_connection, but called in DataExporter.export_table_data and
DBIngestor.execute_query, which never imported it. Both raised NameError,
re-wrapped by their except handlers into a ProcessingError reading
'Failed to execute query: name text is not defined' -- a message that
looks like a database fault rather than a missing import.

No test exercised either method, so this also repairs a pre-existing
failure in tests/ingest/test_notebook_02.py::test_08_database_ingestion.

Add SQLite-backed coverage for all three call sites, including the
SELECT COUNT(*) branch that only runs when no limit is passed and would
otherwise stay untested.

Closes #1015

* test(ingest): register setUp cleanups with addCleanup

TemporaryDirectory and the SQLAlchemy engine were released only in tearDown, which unittest skips when setUp raises partway through. Register each cleanup as soon as its resource exists so a failed setUp still disposes the engine and removes the temp directory. LIFO ordering keeps dispose before cleanup, as tearDown had it.

---------

Co-authored-by: Pravit Ampapathini <pravitampapathini@Pravits-MacBook-Air-3.local>
Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
2026-08-26 22:10:40 +05:00
af3308ad06 fix(ontology): preserve #-terminated namespaces in SHACLGenerator base_uri (#1082) (#1084)
* fix(ontology): preserve #-terminated namespaces in SHACLGenerator base_uri (#1082)

SHACLGenerator.__init__ normalized base_uri with rstrip('/') + '/', turning a #-terminated RDF namespace (e.g. http://example.org/manufacturing#) into ...#/. Every generated URI then landed in a different namespace than the instance data, so SHACL validation silently passed because the shapes targeted nothing.

__init__ now preserves a base_uri already ending in '/' or '#', matching the #-aware normalization generate() already applies. shapes_uri inherits the fix.

Adds test_hash_namespace_base_uri_is_not_mangled (fails on the old normalization), plus a CHANGELOG entry. Full ontology suite green.

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

* fix(ontology): collapse slash runs, only preserve #-terminated base_uri

Qodo review caught that preserving any endswith('/') base left redundant
trailing slashes (e.g. .../ns////) intact, leaking a different namespace
into emitted IRIs. Now only '#'-terminated bases are kept verbatim; slash
runs are collapsed to a single '/', matching generate() normalization.

Adds test_slash_run_normalization_regression.

---------

Co-authored-by: changshenhan <217217832+changshenhan@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-26 21:11:07 +05:00
Guofang.Tang 59af023447 fix(ontology): resolve relationship endpoint types for domain and range (#1170)
* fix(ontology): resolve relationship endpoint types

* fix(ontology): skip empty nested endpoint aliases
2026-08-26 21:03:41 +05:00
Mohd Kaif f4692eea80 Merge pull request #989 from LeonSGP43/cookbook/prov-o-provenance
docs(cookbook): add provenance tracking tutorial (PROV-O lineage, invalidation, checksums)
2026-08-26 20:26:36 +05:30
Mohd Kaif 2de029ac8d Merge branch 'main' into cookbook/prov-o-provenance 2026-08-26 19:50:39 +05:30
KaifAhmad1 1ce76055f5 docs(cookbook): record relationship endpoints explicitly in metadata
track_relationship() has no dedicated subject/object fields, so the
Step 2 example only stored relationship_id + type, leaving readers
unable to reconstruct which two entities the relationship connects.
Encode subject_entity_id/object_entity_id in metadata by convention,
and note the lack of dedicated fields in the prose.
2026-08-26 19:33:00 +05:30
yzxcj797andSameer6305 8cc5d364db fix(cli): write embed generate output in the format embed index reads (#1004)
* fix(cli): write embed generate output in the format embed index reads

* Address review: structured results get their own --output writer

deduplicate --output and ontology align --output were routed through
_write_embeddings_output, a helper for numeric matrices: it rejects the
dict/list shapes these commands produce and the .csv extension deduplicate
documents. New _write_result_output serializes structured results — JSON,
JSON-lines for lists, CSV for rows — and both commands use it. embed
generate keeps the embeddings writer, whose strictness is what #994 fixed.

On the pyarrow gap: the parquet writer already fails with an actionable
message (install pyarrow or use .json). Silently writing JSON bytes to a
.parquet path would recreate #994's magic-bytes failure, so the error stays
an error and the default suggestion stays .json.

* fix(cli): improve structured output serialization

---------

Co-authored-by: Sameer6305 <sskadam6305@gmail.com>
2026-08-26 19:17:01 +05:30
Kevin Zhang d76bff9ab0 refactor(export): consolidate duplicate Turtle/N-Triples literal escapers (#1221)
* refactor(export): consolidate duplicate Turtle/N-Triples literal escapers

_escape_literal (module-level) and RDFSerializer._escape_turtle_literal did
identical work in the same order (backslash, double-quote, newline, CR, tab).
Drop the newer static helper added in #1148 and route all call sites through
_escape_literal instead. Behaviour no-op.

Closes #1218.

* fix(export): handle datetime/None temporal bounds safely in OWL-Time

_escape_literal is str-only, so routing datetime or None temporal bounds
through it raised AttributeError during Turtle export. Stringify non-str
bounds (plain f-string semantics) before escaping, and render None as an
empty bound. Add regression tests for datetime bounds and end-only
intervals. Addresses Qodo high-priority finding #2 on #1221.

* fix(export): use isoformat for datetime temporal bounds

str() on a datetime drops the ISO-8601 T separator, producing a lexically invalid xsd:dateTimeStamp. Use isoformat() when available; strengthen the test to assert the exact T-separated form.

---------
2026-08-26 18:40:57 +05:00
dependabot[bot] 1e5ad49dc3 security(deps): bump google-genai from 2.18.1 to 2.19.0
Bumps [google-genai](https://github.com/googleapis/python-genai) from 2.18.1 to 2.19.0.
- [Release notes](https://github.com/googleapis/python-genai/releases)
- [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-genai/compare/v2.18.1...v2.19.0)

---
updated-dependencies:
- dependency-name: google-genai
  dependency-version: 2.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-26 13:31:46 +00:00
Derek TapleyandCursor f0aa581318 feat(integrations): add LangChain integration — retriever, vectorstor… (#1155)
* feat(integrations): add LangChain integration — retriever, vectorstore, tools

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(langchain): address Qodo review on HybridSearch hits and tools

Read nested HybridSearch metadata so retriever/vectorstore Documents
are not empty, make the agent tools real BaseTool subclasses, and
stop slicing tool JSON into invalid payloads.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 18:29:22 +05:00
Mohd Kaif 8a990c8bf5 Merge pull request #1203 from semantica-agi/dependabot/pip/pypickle-2.0.2
security(deps): bump pypickle from 2.0.1 to 2.0.2
2026-08-26 17:18:42 +05:30
Mohd Kaif 47c7ff5df8 Merge branch 'main' into dependabot/pip/pypickle-2.0.2 2026-08-26 17:10:48 +05:30
Mohd Kaif 92b8aa6993 Merge pull request #967 from toratto/fix/mcp-decision-persistence-and-graph-tools
fix: decision persistence/query bugs, CJK similarity, and MCP graph query/update tools
2026-08-26 16:49:17 +05:30
Mohd Kaif 970d3552d4 Merge branch 'main' into fix/mcp-decision-persistence-and-graph-tools 2026-08-26 16:39:13 +05:30
KaifAhmad1 88d73189dd fix(context): gate CJK bigram similarity fallback, persist recorded_at
_calculate_decision_content_similarity's character-bigram fallback was
unconditional, so ordinary multi-word English queries could pick up
incidental bigram overlap with unrelated decisions via max(word_sim,
bigram_sim). Gate it to only activate for CJK-like scripts or queries
with at most one whitespace token, matching its documented purpose.

Separately, _add_decision_to_graph never persisted recorded_at as a
node property, so _rebuild_decision_indexes/_sync_decision_from_node
(which already read it back) always recovered "" after any reload.
2026-08-26 16:38:39 +05:30
599729f2c0 fix(explorer): /api/decisions returns 422 — coerce decision timestamp to str (#937)
* fix(explorer): coerce decision timestamp to str to prevent 422 on /api/decisions

ContextGraph stores decision timestamps as POSIX floats (e.g. 1786513069.69),
but DecisionResponse.timestamp is typed Optional[str]. Pydantic strict
validation rejects the float and the whole /api/decisions endpoint returns
HTTP 422 "Invalid input", which breaks the Decisions workspace in the
Knowledge Explorer entirely (no decision can be listed).

Coerce the value to str (preserving None) in _node_to_decision so the
response validates. Verified: /api/decisions now returns 200 and the 3
sample decisions render in the Decisions workspace.

* test(explorer): cover decision timestamp coercion in _node_to_decision

Regression tests for the 422 fix in _node_to_decision. Covers the cases
that produced HTTP 422 (float / int timestamps from ContextGraph) and
the ones that must keep working (None, already-string, missing key).

Verified the suite catches the regression: with the fix reverted, the
float / int / nan / inf cases fail with the same ValidationError that
caused the 422; with the fix applied all 6 pass.

* fix(explorer): preserve decision timestamp normalization

The route-level str() cast introduced in the initial fix bypasses
DecisionResponse._normalize_timestamp, the field validator on main that
converts POSIX float epochs to ISO-8601 strings via
datetime.fromtimestamp(value, tz=UTC).isoformat().

With the cast in place the API emits raw numeric strings such as
'1786513069.69' instead of '2026-08-12T05:37:49+00:00', breaking
datetime.fromisoformat() for every caller and failing
TestRecordedDecisions::test_list_decisions_serializes_float_timestamp.
It also silently accepts nan/inf/out-of-range epochs that the validator
is designed to reject.

Restore _node_to_decision() to pass the raw stored value through
unchanged so DecisionResponse._normalize_timestamp remains the single
normalization boundary for all three affected endpoints:
  GET /api/decisions
  GET /api/decisions/{id}
  GET /api/decisions/{id}/precedents

Rewrite test_decision_route_timestamp.py so every assertion uses
datetime.fromisoformat() to verify ISO-8601 output and explicitly
asserts ValidationError for nan, inf, -inf and out-of-range epochs.
Add three TestClient integration tests covering the full production
path: record_decision() -> float stored in graph -> HTTP GET -> JSON.

---------

Co-authored-by: administrator <administrator@administratordeMac-mini.local>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-26 16:02:01 +05:30
KaifAhmad1 84ccc7c0e3 fix(mcp): extract_relations tool crashes with missing entities arg
RelationExtractor.extract_relations(text, entities, ...) requires
entities, but the tool called it with only text, raising TypeError
on every invocation. Run NER first and pass the resulting entities
through, matching how the rest of the pipeline extracts relations.
2026-08-26 15:30:26 +05:30
Sai GaneshandSameer Kadam fa6d645eea Add tests for max_tokens propagation in LLM methods (#925)
* Add tests for max_tokens propagation in LLM methods

This test verifies that the max_tokens parameter is correctly propagated to the generate_typed method for different extraction functions.

* fix(tests): make issue-176 regression tests discoverable by pytest

The contributor's PR added tests/optimize reproduce_issue_176.py — a file
with a space in its name that never matched pytest's test_*.py discovery
pattern, so the regression would have been silently skipped in CI/local runs.

The repository already contained a richer canonical regression file at
tests/reproduce_issue_176.py (11 tests across three classes) which had
the same naming problem: it was also never auto-discovered.

The contributor's file added only TestMaxTokensPropagation (3 tests), which
is a strict subset of what the canonical file already covers. No unique
coverage is lost by removing it.

Changes:
- Rename tests/reproduce_issue_176.py -> tests/test_reproduce_issue_176.py
  so all 11 regression tests are collected by 'pytest tests/'
- Remove tests/optimize reproduce_issue_176.py (redundant strict subset)

No production code changes. All 11 regression tests pass.

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-26 14:50:50 +05:30
cxzg007and江俊杰 97f7154220 fix(pipeline): preserve serializer round trips (#1217)
* fix(pipeline): preserve serializer round trips

* test(pipeline): cover dict input immutability in deserialize_pipeline

---------

Co-authored-by: 江俊杰 <jiangjunjie.37@jd.com>
2026-08-25 21:17:07 +05:00
Kevin Zhang 551b94c524 fix(export): escape Turtle/N-Triples string literals (closes #1098) (#1148)
* fix(export): escape Turtle/N-Triples string literals (fixes #1098)

Add RDFSerializer._escape_turtle_literal and apply it to the semantica:text
literal in serialize_to_turtle and the N-Triples text triple. Backslash,
double quote, newline, CR, and tab are escaped per the RDF 1.1 Turtle
STRING_LITERAL_QUOTE grammar, so entity text containing quotes or control
characters no longer emits invalid Turtle/N-Triples.

N-Triples previously escaped only quotes and newlines; now it also handles
backslashes and tabs via the shared escaper.

* fix(export): escape OWL-Time timestamp literals in Turtle output

Addresses Qodo finding on #1148: the OWL-Time branch of
serialize_to_turtle interpolated from_val/until_val directly into quoted
literals. Apply _escape_turtle_literal there too so timestamps containing
quotes, backslashes, or control characters cannot produce invalid Turtle.

* chore: remove stray local files (AGENTS.md, evals superpowers docs) from PR branch

---------
2026-08-25 20:57:57 +05:00
50468f9c90 perf(explorer): stop re-parsing markdown on every viewer re-render (#1118) (#1195)
Profiling the viewer in headless Chromium (real DOM, production React)
separated remark parse time, React commit time and DOM node count across
large-prose, large-code-block, deep-nested-list and GFM-table fixtures.

Two findings, one of which is fixed here.

1. Every re-render re-parsed the whole document and remounted the whole
   subtree. remarkPlugins and the ~20-entry components map were inline
   literals, so each render allocated fresh arrow components; React saw a new
   element type per mapped tag and replaced the DOM rather than updating it. A
   DOM-identity probe confirmed the remount on every fixture. Because
   react-markdown runs the remark pipeline inside its own render, an unrelated
   state change -- clicking Copy, toggling Preview/Source -- re-paid the full
   parse. Measured 364ms for a 1000-row GFM table and 1121ms for 2000 rows.

   Hoisting both props to module scope and memoising the rendered element on
   rawContent drops re-render cost to ~0.1ms across every fixture and removes
   the remount (DOM identity now survives). Initial mount and node switching
   are unchanged, since those are genuine parses.

2. Initial parse of large GFM tables is quadratic and lives upstream in
   remark-gfm: the same table text parses in 12.5ms without the plugin and
   1156ms with it at 2000 rows. Not addressed here -- any mitigation is a
   product decision and is tracked on the issue.

Note that document size is the wrong threshold for this: 562KB of prose parses
in 85ms while a 27KB GFM table takes 102ms. Row count, not bytes, predicts cost.

Rendered output is unchanged; the components map is moved verbatim. All 66
Explorer graph-workspace tests pass.

Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-25 19:44:47 +05:30
pravit-ampandPravit Ampapathini c7d608570c refactor(explorer): move isSafeUrl out of MarkdownContentViewer (#1119) (#1194)
MarkdownContentViewer.tsx exported the isSafeUrl helper alongside the
component so it could be unit tested, which tripped
react-refresh/only-export-components.

Move the helper into a sibling pure module, markdownUrlSafety.ts,
following the existing GraphWorkspace convention for testable non-component
logic (graphAnalytics.ts, pluginRegistryPredicates.ts,
temporalLifecyclePredicates.ts). The function body is moved verbatim — the
scheme allowlist, protocol-relative rejection, whitespace-only guard and
malformed-URL handling are unchanged — so the existing URL-safety tests pass
untouched apart from the import path.

The component module now exports only its component and prop type, clearing
the lint error without any change to the lint configuration.

Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
2026-08-25 16:32:34 +05:00
Mohd Kaif 5e8caadcb4 Merge pull request #1156 from 13g4d0/fix/ontology-ingestor-named-graph
Read JSON-LD named graphs in OntologyIngestor (#1129)
2026-08-25 16:43:26 +05:30
KaifAhmad1 d05ef9d09f fix(ingest): avoid copying every quad into a second Graph in OntologyIngestor
Dataset(default_union=True) presents triples from every named graph as a
single merged view and is itself an rdflib.Graph subclass, so it satisfies
_convert_to_dict()'s Graph-typed contract directly. Drops the O(n) manual
quad-copy loop while keeping the same named-graph fix and behavior.
2026-08-25 16:36:52 +05:30
Mohd Kaif 06a4b2c9aa Merge pull request #1151 from Arasz/fix/mcp-export-graph
fix(mcp): export_graph failed on every format — convert kg dict, disable progress
2026-08-25 16:25:19 +05:30
KaifAhmad1 e2fc76cea0 fix(mcp): reject unsupported export_graph formats instead of mislabeling JSON
_tool_export_graph fell through to json.dumps(kg) for any format outside
the RDF set, including values never declared in the tool's own inputSchema
enum. Nothing in this server validates tool-call args against inputSchema
before dispatch, so a typo'd or unsupported format (e.g. "yaml") silently
returned JSON data labeled with the wrong format and no error.

Validate against the declared format list up front and reuse the same
constant for the inputSchema enum so the two can't drift apart again.
2026-08-25 16:18:33 +05:30
a1a72cdd50 fix(triplet_store): OxigraphStore silently ignores storage_path; add_triplets skips flush (#970)
* fix(triplet_store): OxigraphStore silently ignores storage_path and skips flush

Two persistence bugs in OxigraphStore:

1. `storage_path=...` was silently swallowed by **config. The __init__
   parameter is named `path`, so passing the project-conventional
   `storage_path` (used by ProvenanceManager and other stores) left
   self.path = None and the store silently degraded to in-memory —
   no error, no warning, data gone on exit. Accept `storage_path` as
   an alias for `path`.

2. add_triplets never called flush(). pyoxigraph auto-flushes via
   background threads but, per its docs, "might lag a little bit" —
   that lag is a race where reopening or crashing immediately after a
   write observes fewer triples. Call flush() explicitly for on-disk
   stores to close the window.

Both verified: with the fix, `OxigraphStore(storage_path=...)` persists
across reopen; without it, data is lost.

* fix(triplet_store): improve oxigraph persistence

* test(triplet_store): clarify oxigraph persistence test

---------

Co-authored-by: administrator <administrator@administratordeMac-mini.local>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-25 15:57:31 +05:30
Sameer KadamandKaifAhmad1 2075eca0f3 fix: preserve generation kwargs in relation extraction (#1213)
* fix: preserve generation kwargs in relation extraction

* fix: include generation params in extraction cache keys

* fix: cover provider-specific generation params in extraction cache key

_GENERATION_CACHE_KEYS only covered the common OpenAI-shaped generation
params, so calls that differed only in Anthropic's system/stop_sequences,
Gemini's candidate_count, or Ollama's repeat_penalty/num_ctx/context_window
could still return a stale cached result generated under different settings.

Add these provider-specific keys to the cache key and add regression tests
covering system prompt, stop_sequences, and repeat_penalty.

---------

Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2026-08-25 12:46:43 +05:30
pravit-ampandPravit Ampapathini 4217f23df2 fix(seed): report real cause of API failures in load_from_api (#972)
``requests.exceptions.RequestException`` subclasses ``OSError``, so the
``except (ImportError, OSError)`` handler in ``load_from_api`` swallowed
genuine network failures (connection errors, timeouts, HTTP errors) and
reported them as "requests library not available", hiding the real cause.

Remove the obsolete handler so those failures fall through to the generic
handler, which reports "Failed to load from API: ..." and chains the real
exception as ``__cause__``. Update the docstring's ``Raises`` section to
match the actual behavior.

Fixes #949

Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
2026-08-24 22:49:45 +05:00
2f63896fb4 Remove unreachable dead code (#1176)
* Remove unreachable dead code

Delete symbols with no callers anywhere in the codebase, tests, or docs,
confirmed by a repo-wide search. These are internal/private or app-layer
(explorer) symbols, not part of the importable library's public API
(no __all__ / package re-export), so there is no user-facing change.

Removed:
- poc_runner.py: parse_import_csv_row (unused nested helper)
- change_management/version_storage.py: create_graph_snapshot_record
- context/graph_schema.py: drop_decision_schema
- explorer/dependencies.py: get_ws_manager (+ now-unused ConnectionManager import)
- explorer/routes/graph.py: _extract_node_embeddings (+ stale cross-ref comment)
- explorer/routes/ontology.py: ProposalState
- explorer/schemas.py: ErrorResponse, TemporalSnapshotResponse, ExportResponse,
  StandardMessageResponse
- semantic_extract/methods.py: _parse_entity_result, _parse_triplet_result
- triplet_store/methods.py: _get_query_engine (+ now-unused _global_query_engine)

Co-Authored-By: Vinv-AI <309466812+Vinv-AI@users.noreply.github.com>

* Address review: drop now-orphaned helper and fix stale docstring

- Remove _coerce_embedding_vector from explorer/routes/graph.py: its only
  non-recursive caller was _extract_node_embeddings (removed in this PR), so
  it is now dead. The live coercion logic lives in
  GraphSession._coerce_embedding_vector.
- Update explorer/dependencies.py module docstring: it no longer injects
  ConnectionManager (get_ws_manager was removed); note that websocket manager
  access is via app.state.ws_manager.

Co-Authored-By: Vinv-AI <309466812+Vinv-AI@users.noreply.github.com>

* Keep public helpers with a DeprecationWarning instead of removing them

create_graph_snapshot_record() and drop_decision_schema() are not
underscore-prefixed, so downstream users can import them directly from
their modules even though they are not re-exported from the package
__init__.py. A repo search only proves there are no in-tree callers.

Restore both unchanged and emit a DeprecationWarning on call, with a
matching ".. deprecated::" note in each docstring pointing at the
replacement. This keeps the PR non-breaking; the actual removal can
happen in a future major version.

The underscore-prefixed helper removals are unaffected.

---------

Co-authored-by: noQbot <noQbot@users.noreply.github.com>
Co-authored-by: Vinv-AI <309466812+Vinv-AI@users.noreply.github.com>
Co-authored-by: noQbot <anshul@vinv.ai>
2026-08-24 22:19:03 +05:00
Sameer Kadam 58aad80d56 fix: guard Agno and OpenClaw integration requests against SSRF (#1212)
* fix: guard integration HTTP requests against SSRF

* fix(openclaw): complete fallback validation and base URL handling

Address the remaining review findings in the OpenClaw integration.

- Strengthen fallback base_url validation to require a non-empty string, valid HTTP(S) scheme, netloc, and hostname.
- Strip leading and trailing whitespace from base_url before storing it.
- Replace the flaky endpoint-construction test that made a real network connection with mocked session assertions.
- Add coverage for _get and _post endpoint construction and timeout forwarding.
- Add regression tests for whitespace-padded base URLs and the fallback validation path.

These changes complete the Qodo review fixes and harden OpenClaw URL handling without changing the intended localhost/private deployment behavior.
2026-08-24 21:08:46 +05:30
Sameer Kadam 1452dab5fa Merge branch 'main' into fix/mcp-decision-persistence-and-graph-tools 2026-08-24 18:01:07 +05:30
Sameer6305 f454c48929 fix: harden decision persistence and MCP graph tools 2026-08-24 17:56:03 +05:30
dependabot[bot] b06a4f0748 security(deps): bump pypickle from 2.0.1 to 2.0.2
Bumps [pypickle](https://github.com/erdogant/pypickle) from 2.0.1 to 2.0.2.
- [Release notes](https://github.com/erdogant/pypickle/releases)
- [Commits](https://github.com/erdogant/pypickle/compare/2.0.1...2.0.2)

---
updated-dependencies:
- dependency-name: pypickle
  dependency-version: 2.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 11:53:08 +00:00
Mohd Kaif 7da3519ca7 Merge pull request #1201 from semantica-agi/dependabot/pip/charset-normalizer-3.5.1
security(deps): bump charset-normalizer from 3.5.0 to 3.5.1
2026-08-24 17:20:52 +05:30
dependabot[bot] b388e936fd security(deps): bump charset-normalizer from 3.5.0 to 3.5.1
Bumps [charset-normalizer](https://github.com/jawah/charset_normalizer) from 3.5.0 to 3.5.1.
- [Release notes](https://github.com/jawah/charset_normalizer/releases)
- [Changelog](https://github.com/jawah/charset_normalizer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jawah/charset_normalizer/compare/3.5.0...3.5.1)

---
updated-dependencies:
- dependency-name: charset-normalizer
  dependency-version: 3.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 11:44:55 +00:00
Mohd Kaif 93281859c8 Merge pull request #1197 from semantica-agi/dependabot/pip/lxml-6.1.2
security(deps): bump lxml from 6.1.1 to 6.1.2
2026-08-24 17:12:36 +05:30
Mohd Kaif 45ce682e6b Merge branch 'main' into dependabot/pip/lxml-6.1.2 2026-08-24 17:05:58 +05:30
Mohd Kaif c415d57d16 Merge pull request #1210 from semantica-agi/citation-and-org-cleanup
docs: add citation section and fix stale org references
2026-08-24 16:20:08 +05:30
Sameer Kadam b6c8563cb0 Merge branch 'main' into fix/mcp-decision-persistence-and-graph-tools 2026-08-24 14:52:45 +05:30
Sameer Kadam 6dad69cdb4 Merge branch 'main' into fix/mcp-export-graph 2026-08-24 14:45:37 +05:30
Sameer6305 9b30c8af94 fix(mcp): repair standalone export_graph 2026-08-24 14:00:07 +05:30
Mohd Kaif 703b40a116 Merge pull request #1165 from fabio-rovai/metadata-passthrough
Carry metadata through every RDF serialization (#1154)
2026-08-24 13:56:00 +05:30
Sameer Kadam 08d6390521 Merge branch 'main' into fix/mcp-export-graph 2026-08-24 13:53:10 +05:30
Mohd Kaif 7109040984 Merge branch 'main' into metadata-passthrough 2026-08-24 13:44:03 +05:30
KaifAhmad1 220fb10e5c fix(export): escape IRI-valued metadata to close a Turtle/N-Triples injection gap
_turtle_object() wrote an IRI-valued metadata value (currently only
sem:sourceUri, from the "uri" metadata key) straight into `<{value}>`
with no escaping. Turtle/N-Triples IRIREFs exclude control
characters, space, and <>"{}|^`\ unescaped, so a value shaped like
`<goodIRI> . <injected> <p> <o>` closed the reference early and let
the rest of the string be parsed as an attacker-chosen extra triple:

    metadata={"uri": "https://x> . <https://injected> <https://p> <https://o"}

produced a well-formed Turtle/N-Triples document containing a triple
the caller never asked for.

RDF/XML was already safe (_rdfxml_metadata_lines runs the value
through _escape_xml before putting it in an rdf:resource attribute),
and JSON-LD is safe by construction (json.dumps makes structural
injection impossible) — only the Turtle/N-Triples "iri" literal path
in _turtle_object was unguarded.

Adds _safe_iri_ref(), a narrow percent-encoder for exactly the
characters an IRIREF may not contain unescaped. It's deliberately not
_as_turtle_iri: that also resolves registered prefixes, which a
metadata value never needs, so a dedicated guard stays simpler than
threading namespaces into a module-level helper that has no `self`.

Two regression tests, parametrised over turtle/ntriples: the `>`
delimiter-breaking payload from the report, and a control-character
(newline/tab) variant covering the other half of the excluded set.
2026-08-24 13:38:07 +05:30
KaifAhmad1 fb02c868f8 Merge branch 'main' into metadata-passthrough
Resolves the conflict in semantica/export/rdf_exporter.py between this
branch's metadata clauses (entity/graph metadata statements) and
main's IRI-normalization and XML-escaping hardening
(_as_turtle_iri / xml_escape, landed after this branch's last sync).

Kept both: entity/relationship/graph subjects and objects now go
through _as_turtle_iri (Turtle) or _as_turtle_iri + xml_escape
(RDF/XML), same as every other identifier in these serializers,
while the metadata-clause list building and graph_uri handling from
this branch are preserved unchanged. graph_uri is now normalized the
same way for consistency with the rest of the file.

Verified: tests/export + tests/ontology (411 tests) and the existing
Turtle-IRI regression suite (test_rdf_exporter_turtle_iris.py, 9
tests) all pass against the merged code.
2026-08-24 13:27:45 +05:30
Mohd Kaif 58ec7639fb Merge pull request #1057 from OctoBored/fix/star-history-chart
docs: fix broken star history chart in README
2026-08-24 13:13:13 +05:30
Mohd Kaif ac16042f67 Merge branch 'main' into fix/star-history-chart 2026-08-24 13:07:40 +05:30
Sameer Kadam 346f98bdbf Merge branch 'main' into fix/mcp-export-graph 2026-08-24 13:02:27 +05:30
KaifAhmad1andOctoBored 595f08ee30 docs: escape & as &amp; in Star History HTML attributes
Matches the README's existing convention for query params inside
HTML attribute URLs (e.g. the Trendshift badge), per review feedback
from Zohaib Hassan and Qodo on this PR.

Co-authored-by: OctoBored <212877535+OctoBored@users.noreply.github.com>
2026-08-24 12:49:31 +05:30
Mohd Kaif 0468a603ae Merge pull request #1193 from ALDRIN121/fix/1185-non-tty-progress
fix(utils): write console progress only to an interactive stdout
2026-08-24 12:36:34 +05:30
Mohd Kaif b9cb524514 Merge branch 'main' into fix/1185-non-tty-progress 2026-08-24 12:25:14 +05:30
Mohd Kaif f4c6be158f Merge pull request #1192 from Freakz2z/fix/rdf4j-repository-id
fix(triplet_store): honor RDF4J repository id
2026-08-24 12:21:50 +05:30
Sameer Kadam cf4750ebf0 Merge branch 'main' into fix/mcp-export-graph 2026-08-24 12:06:57 +05:30
dependabot[bot] 95b6d952e6 security(deps): bump lxml from 6.1.1 to 6.1.2
Bumps [lxml](https://github.com/lxml/lxml) from 6.1.1 to 6.1.2.
- [Release notes](https://github.com/lxml/lxml/releases)
- [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt)
- [Commits](https://github.com/lxml/lxml/compare/lxml-6.1.1...lxml-6.1.2)

---
updated-dependencies:
- dependency-name: lxml
  dependency-version: 6.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 03:34:43 +00:00
Freakz2z 49db007691 Merge remote-tracking branch 'upstream/main' into fix/rdf4j-repository-id 2026-08-24 09:02:42 +08:00
Freakz2z 4c997b5017 fix(triplet_store): encode RDF4J repository paths 2026-08-24 09:02:42 +08:00
Aldrin Joseph de31b43663 fix(utils): write console progress only to an interactive stdout
ProgressTracker attached ConsoleProgressDisplay unconditionally, so any
script or CI job that piped or redirected stdout had one progress bar per
stage written into its output, escape sequences included. A plain
`python demo.py > out.txt` captured 173 bytes of progress-bar noise around
10 bytes of the program's own output.

Console progress is now attached only when stdout is an interactive
terminal, when running under Jupyter, or when SEMANTICA_FORCE_PROGRESS is
set. FileProgressDisplay is untouched, so progress logging still works in
pipelines, and SEMANTICA_DISABLE_PROGRESS keeps its existing meaning and
still takes precedence.

Both progress environment variables are now documented in the README and
the utils reference; SEMANTICA_DISABLE_PROGRESS previously existed only in
the reference page.

Deviations from the issue: the issue suggested disabling the tracker on
non-TTY stdout. This gates the display instead, because disabling the
tracker would short-circuit before FileProgressDisplay and take file
progress logging down with it, and the ~20 modules that set
`progress_tracker.enabled = True` in __init__ would need the property
setter taught about TTY state to avoid undoing it. Gating the display
leaves both alone.

Design note: the claim comment on the issue proposed an
`enabled: Optional[bool] = None` constructor opt-in; during implementation
the opt-in became SEMANTICA_FORCE_PROGRESS, which needs no signature change
and follows the NO_COLOR/FORCE_COLOR convention. Known limitation: TTY
detection runs once at tracker construction (the tracker is a process-wide
singleton), so a process that redirects stdout after first use needs the
env vars to change behaviour.

Fixes #1185
2026-08-23 22:17:31 +05:30
Freakz2z e41993a6bd fix(triplet_store): honor RDF4J repository id 2026-08-23 23:02:15 +08:00
yzxcj797 92ad7bc2df Address review: string-only application id resolution
Per the Qodo review: only string application ids are recorded in (and
resolved through) _app_node_id_map. Internal ids are commonly integers,
so an integer application id could collide with — and silently remap —
a caller-supplied internal id of the same value. Also pass a labels list
to create_node in the regression test, matching the API signature.
2026-08-22 02:07:55 +08:00
yzxcj797 db81136b0a fix(graph_store): resolve application ids to internal ids when creating relationships
GraphStore.add_edges reads application-level string ids from
source_id/target_id and passed them straight to the backend, while
Neo4jStore.create_relationship matches on internal integer ids (id(n)).
Nothing resolved one to the other, so persisting a graph created every
node and zero relationships — each edge failed with 'nodes not found'
as a logger.warning and the call appeared to succeed (#1136).

add_nodes already receives the application-id/internal-id pair from
create_nodes (the app id is preserved in properties['id']) and discarded
it one statement before add_edges needed it. Keep the map on the store,
populate it from both add_nodes and create_node, and resolve known
application ids in create_relationship. Unknown ids pass through
unchanged, so direct internal-id callers and backends whose ids are the
application ids keep their existing behavior.
2026-08-22 01:23:46 +08:00
Fabio Rovai 1a220da477 fix(export): address the review findings on the metadata pass-through 2026-08-21 14:43:02 +01:00
Fabio Rovai d06434ae31 Merge upstream/main into metadata-passthrough
#1123 through #1127 landed while this was open, and #1125 rewrote the same
four entity loops this branch extends. Confidence is now normalised through
normalize_confidence, which returns None for a value that has no xsd:decimal
form, so the clause can be absent.

Resolved by folding that into the clause list this branch already builds:
the Turtle path assembles its predicate-object clauses and then terminates
the last one, which is what makes a variable-length list work at all, and
an omitted confidence is simply one clause fewer. RDF/XML and JSON-LD take
the upstream conditional as written, with the metadata call after it.
2026-08-21 14:40:54 +01:00
FABIOTESS eb7427d12c fix(export): carry metadata through every RDF serialization (#1154)
convert_kg_to_rdf copies metadata into the RDF-ready dictionary at
rdf_exporter.py:302 and no serializer has ever read it back out. Turtle,
N-Triples, RDF/XML and RDFExporter's JSON-LD each write an entity's id,
type, text and confidence and nothing else, so an entity keeps its
confidence score and loses what produced it. JSONExporter's json-ld path
keeps the same fields, which is how one knowledge graph exported two ways
carried the user's data through one exporter and none through the other.

Measured on e3405ebc with an entity carrying four metadata keys: 3 triples
per format, 0 of them metadata. With this change: 7 triples per format,
4 of them metadata, and the same four in all four formats.

The keys Semantica itself writes are mapped to declared terms in
DEFAULT_METADATA_TERMS and declared in semantica-ns.ttl. A key the caller
supplied is not: which namespace an arbitrary key belongs in is #1146, and
that issue is open on the maintainer's modelling call, so the exporter
warns and skips rather than inventing an IRI. Callers who already know the
answer pass metadata_terms={key: iri}.

Two keys cannot keep their own name. sem:source is already the
ObjectProperty holding the subject of a reified relationship, so the Neo4j
loader's "source" is written as sem:sourceSystem and its "uri" as
sem:sourceUri, the one term whose value is a node rather than a literal.

sem:builtAt and sem:snapshotAt have range xsd:string, not xsd:dateTime.
GraphBuilder stamps with a timezone-naive datetime.now(), and #1114 is the
demonstration of what typing such a value as xsd:dateTime costs: a
timezone-qualified SPARQL filter over it silently drops the row. #1121
swept export and provenance and deliberately left kg/ alone.

Graph-level metadata is written only when the caller names the graph with
graph_uri, because this serializer has never minted a document node and
#1147 is where that default belongs once it lands.

The lexical form and datatype of a value are chosen once, in
_typed_literal_parts, so the four serializers cannot come to disagree
about them the way they disagreed about confidence in #1100. The JSON-LD
path writes explicit @value/@type rather than JSON's native numbers,
which would have made an integer xsd:double there and xsd:integer
everywhere else.

21 tests, asserting on the parsed graph in all four formats. Output is
unchanged when no metadata is present. Full-suite failure set is identical
to the parent commit: 512 = 512.
2026-08-21 13:01:40 +01:00
13g4d0 241ff8e481 fix(ingest): read JSON-LD named graphs in OntologyIngestor (#1129)
A JSON-LD document with a top-level `@id` *and* `@graph` places its terms in a named
graph. `rdflib.Graph.parse()` loads only the default graph and discards the rest
without raising, so every class and property in such a document was dropped while
the load reported success.

`OntologyIngestor.ingest_ontology` now parses into a `Dataset` and flattens the
quads into the working `Graph`, keeping both the default and the named graphs. This
is the same `Graph` -> `Dataset` migration #757 made for `JenaStore` (#756); the
ingest path was not covered by it.

Measured on the 12-line reproduction from the issue:

    before   classes=0  properties=0
    after    classes=2  properties=0

On a real ontology the gap is larger: 25 triples / 1 subject against 719 / 88 for
the document that surfaced this.

Tests: `tests/ingest/test_ontology_named_graph.py` covers the named-graph document,
keeps a canary on the default-graph document so the fix cannot trade one blind spot
for another, and asserts that the reported result matches the terms returned.
Reverting `Dataset()` to `Graph()` turns all four red.

`tests/ontology`, `tests/export` and `tests/ingest` pass apart from six failures in
web/feed/database/API ingestion, unrelated to this change and failing the same way
on an unmodified checkout.

Not included, and happy to add here or as a follow-up: making a load that yields
zero classes stop returning `status: "success"`. That value is what made this take
an afternoon to find, but it is a behaviour change on a different layer and seemed
worth reviewing on its own.
2026-08-20 12:55:45 -04:00
Sameer Kadam 988ff609cf Merge branch 'main' into cookbook/prov-o-provenance 2026-08-20 17:39:32 +05:30
Rafal Araszkiewicz cd2d11a2e7 fix(mcp): export_graph failed on every format — convert kg dict, disable progress
The MCP server's export_graph tool was broken on all formats in 0.6.5/0.6.6:

- json: JSONExporter().export(graph) was called without the required
  file_path argument -> TypeError surfaced as {"error": ...}.
- RDF branches: RDFExporter().export_to_rdf(graph, ...) received the
  ContextGraph object instead of the canonical kg dict -> AttributeError
  (ContextGraph has no 'get').
- All branches: the RDF path printed a rich progress bar to stdout,
  corrupting the stdio JSON-RPC framing and hanging the client (observed:
  300s timeout over MCP while the same call returns in <1s directly).

Fix: convert via ContextGraph.to_kg_dict() before exporting, serialize the
json branch to a string, and force SEMANTICA_DISABLE_PROGRESS=1 for the
server process — stdout is the protocol channel, not a console.

Tests: tests/test_mcp_server_export_graph.py covers every format, the json
payload shape (entities/relationships), and the progress-disable env var.
2026-08-20 13:21:19 +02:00
江俊杰 1c27a0ae7e fix(export): escape RDF literals and use URI-aware id fallback
Address Qodo review on #1113:
- Escape entity text for Turtle, RDF/XML and N-Triples so names containing
  quotes, XML markup, backslashes or control chars cannot break out of the
  literal or inject RDF/XML (High/Security).
- Replace colon-only id split with URI-aware local-name extraction so an id
  like https://example.org/acme yields 'acme', not '//example.org/acme'
  (Medium/Correctness).
- Add regression tests: escaping (quotes/XML/backslash/CR/LF), parseability
  via rdflib, and exact id local-name assertions.
2026-08-20 10:19:52 +08:00
江俊杰 9e2f349221 fix(export): normalize entity name to label on all RDF paths
convert_kg_to_rdf() maps an entity's 'name' to 'label'/'text' but was
never invoked from export_to_rdf(), so graphs produced by GraphBuilder
(which emit 'name') exported with an empty semantica:text on every RDF
format (turtle, ntriples, rdfxml, jsonld). Call convert_kg_to_rdf() at
the export boundary before validation/serialization so all formats
benefit from a single normalization step.

Add regression tests asserting a name-only entity exports a non-empty
label across all four serializers and the file-writing entry point,
plus that an explicit 'text' is not clobbered and an id tail is used
as a fallback label.

Closes #1097
2026-08-20 10:19:52 +08:00
OctoBored 4a451f410d docs: fix broken star history chart in README
The star history chart was broken due to GitHub stargazer API restrictions, so it could no longer be rendered. Update the README to point to a working alternative that uses a different data source requiring no API token.
2026-08-17 08:16:29 +00:00
LeonSGP43 aee6e5ad9c docs(cookbook): address review - use sequence_id in lineage walk, demonstrate verify_chain in tamper-evidence step
Signed-off-by: LeonSGP43 <leonsgp43@users.noreply.github.com>
2026-08-16 11:52:43 +08:00
LeonSGP43 21edb700b2 docs(cookbook): add provenance tracking tutorial (PROV-O lineage, invalidation, checksums)
Add cookbook/introduction/22_Provenance_Tracking.ipynb covering the
provenance module end to end:

- tracking entities/relationships with audit-grade source details
  (DOI + location + verbatim quote + confidence)
- lineage walks (get_lineage / trace_lineage)
- revision history and multi-source audits
- prov:Invalidation (correct-without-delete) and storage statistics
- tamper-evidence via chained SHA-256 checksums

All API calls verified against semantica/provenance/manager.py.

Signed-off-by: LeonSGP43 <cine.dreamer.one@gmail.com>
2026-08-15 11:56:17 +08:00
修宴andClaude 0e40639930 feat(mcp): fix decision persistence/query, add NER model params and graph tools
Bug fixes:

- _get_graph: call load_from_file (graph.load does not exist; SEMANTICA_KG_PATH was silently ignored and the graph started empty).

- query_decisions: read category from metadata.category (top-level category was always empty, so category filtering returned nothing).

- find_precedents / query: lower default similarity threshold to 0.05 so short CJK queries can match.

- extract_entities/extract_relations: return the entity text field (previously returned the spaCy type label as 'label' and dropped the actual text); expose model/language/method params so non-English (e.g. zh_core_web_sm) NER works.

New tools:

- query_graph: node detail / bidirectional neighbours (up to 5 hops, in-edges included) / keyword search.

- update_node: update node properties (e.g. action status todo/doing/done) and persist to SEMANTICA_KG_PATH.

- delete_node: soft-archive a node (status=archived) and persist.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 18:04:08 +08:00
修宴andClaude 778ff51162 fix(explorer): coerce decision timestamp to str in response
DecisionResponse.timestamp is typed str, but decision nodes store a float epoch. Coerce non-str timestamps so GET /api/decisions stops returning 422 Unprocessable Content.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 18:03:03 +08:00
修宴andClaude b2d54a6683 fix(context): CJK decision similarity and rebuild decision indexes on load
Add a character-bigram overlap-coefficient fallback to _calculate_decision_content_similarity so CJK scenarios (no whitespace tokenization) can match recorded decisions; the previous whitespace Jaccard was always 0 for CJK.

Rebuild _decisions/_decision_index/_entity_index/_temporal_index from persisted decision nodes at the end of load_from_file, otherwise find_precedents_by_scenario and decision_count break after a reload since save_to_file does not serialize the internal decision indexes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 18:03:03 +08:00
修宴andClaude ea7790a5bf fix(docker): pin runtime to python:3.13-slim
gensim (core dependency) has no prebuilt cp314 wheel, and the slim base image lacks gcc to build from source, so 'pip install .[explorer]' fails on python:3.14-slim. Pin to python:3.13-slim (still satisfies requires-python>=3.8) until gensim ships a cp314 wheel.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 18:03:03 +08:00
147 changed files with 22325 additions and 1618 deletions
+14
View File
@@ -33,15 +33,29 @@ jobs:
- name: Install Explorer frontend dependencies
working-directory: explorer
run: npm ci
- name: Install Playwright Chromium
working-directory: explorer
run: npx playwright install --with-deps chromium
- name: Test Explorer frontend
working-directory: explorer
run: |
npm run test:graph-store
npm run test:graph-workspace
npm run test:plugin-registry
npm run test:deterministic-e2e
- name: Build Explorer frontend
working-directory: explorer
run: npm run build
- name: Install Explorer backend test dependencies
run: |
# Run the deterministic backend path before the all-extras CI
# environment is installed. The Explorer extra supplies the
# production API dependencies without importing optional vector
# providers such as Pinecone during test collection.
pip install -e ".[explorer]" pytest==9.1.1
- name: Test deterministic Explorer backend path
run: |
pytest -q tests/explorer/test_explorer_deterministic_rendering_e2e.py
- name: Install pinned Python dependencies
run: |
pip install -r requirements-ci.txt
+6 -6
View File
@@ -32,7 +32,7 @@ jobs:
# meaningful state carried over from a failed attempt.
- name: Initialize CodeQL (attempt 1)
id: codeql-init-1
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
continue-on-error: true
with:
languages: python
@@ -42,7 +42,7 @@ jobs:
- name: Initialize CodeQL (attempt 2)
id: codeql-init-2
if: steps.codeql-init-1.outcome == 'failure'
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
continue-on-error: true
with:
languages: python
@@ -52,17 +52,17 @@ jobs:
- name: Initialize CodeQL (attempt 3)
id: codeql-init-3
if: steps.codeql-init-2.outcome == 'failure'
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
with:
languages: python
queries: security-and-quality
config-file: .github/codeql/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
with:
category: "/language:python"
upload: false
@@ -72,7 +72,7 @@ jobs:
# Uploads results only when Default Setup is not active.
# If Default Setup is still enabled, this step skips gracefully
# instead of failing the workflow with HTTP 409.
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
with:
sarif_file: ${{ steps.codeql.outputs.sarif-output }}
category: "/language:python"
+2 -2
View File
@@ -57,7 +57,7 @@ jobs:
# avoiding the guardian.cmd/checkov exit-code bug in the MSDO wrapper.
tools: eslint,templateanalyzer,terrascan
- name: Upload results to Security tab
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
with:
sarif_file: ${{ steps.msdo.outputs.sarifFile }}
@@ -82,7 +82,7 @@ jobs:
}
- name: Upload Checkov results to Security tab
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
if: always()
with:
sarif_file: reports/checkov.sarif
+115
View File
@@ -9,6 +9,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.7] - 2026-08-28
### Added
- **First-class LangChain integration** (closes #963; recreates #969)
- New `pip install semantica[langchain]` extra (`langchain-core>=0.3.0`), included in the `all` bundle
- `integrations/langchain/SemanticaRetriever` — LangChain `BaseRetriever` that seeds from `HybridSearch` then walks graph edges (`hops=2` default) for GraphRAG-style retrieval; falls back to `ContextGraph.query` when hybrid search is unavailable
- `integrations/langchain/SemanticaVectorStore` — LangChain `VectorStore` adapter over `HybridSearch` (`add_texts`, `similarity_search`, `similarity_search_with_score`, `from_texts`)
- `integrations/langchain/SemanticaKGTool` / `SemanticaDecisionTool``BaseTool` subclasses with Pydantic `args_schema` (`semantica_query_graph`, `semantica_query_decisions`); `build()` returns the tool, or `None` when langchain-core is absent
- Retriever and VectorStore read HybridSearch nested `metadata` (`content`, `node_id`, `node_type`) rather than top-level fields that HybridSearch does not set
- All adapters remain importable without langchain-core (`LANGCHAIN_AVAILABLE` flag)
- Docs: `docs/integrations/langchain.md`, README native-integration matrix, and `docs.json` nav entry
- **SAP OData ingestor** (#1234, closes #1228) by @pkupt
- New `SAPODataEntity` / `SAPODataConnector` / `SAPIngestor` (`semantica.ingest`, lazy exports), following the three-layer connector pattern already used for Snowflake/Databricks, to pull master/transactional data (Business Partners, Sales Orders) from SAP OData v2/v4 services into the Context Graph
- Dual auth (OAuth2 client-credentials for BTP/S4HANA Cloud, Basic for on-prem NetWeaver); every outbound request, including the token exchange, routes through `request_with_ssrf_guard`
- `$metadata` (CSDL XML) is parsed with a hand-rolled `xml.etree` reader rather than pulling in `pyodata`; pagination follows OData v2 `__next`/`__deferred` and v4 `@odata.nextLink`
- New `pip install semantica[ingest-sap]` extra (`requests>=2.28.0`)
- **Known phase-1 limits** (documented in docstrings): the OAuth2 token is cached but never refreshed, and pagination has no `max_pages` fuse (`top` bounds it when supplied)
- New `tests/ingest/test_sap_ingestor.py`: 22 tests (auth, EDMX parsing, v2/v4 pagination, SSRF routing, error paths, service-root normalization)
- **`ContextGraph` gains deterministic, human-editable Markdown round-trip persistence** (#852) by @SaurabhScripts
- `save_to_markdown()`/`load_from_markdown()` write one file per node plus a graph manifest, so a graph can be reviewed and hand-edited outside the application without giving up the existing JSON API or its default behavior
- An existing destination is validated as a complete, canonical managed export before atomic replacement, so the loader can't silently clobber an unrelated or manually-extended directory
- Import/export paths and their ancestors reject symlinks, Windows junctions, and other reparse points, with pre-open and post-open validation — the same hardening applied to `AgentMemory`'s existing Markdown import in the companion fix below
- Dangling edge endpoints import as JSON-compatible entity stubs rather than being rejected outright (matching what the JSON loader already accepts); node/edge indexes, adjacency, and analytics/retraction/tombstone state are rebuilt after a Markdown load, and granular node/edge events are still emitted so temporal audit history stays useful
- New `tests/context/test_context_graph_markdown.py`: 29 passed, 1 skipped (the skipped case creates a real Windows junction and runs on Windows CI); full `tests/context/` suite: 614 passed, 1 skipped
- **Explorer graph inspector gains a read-only Markdown content viewer** (#1078, closes #900) by @sakshi04-ui — Preview (rendered GFM) and Source (exact, whitespace-preserving) tabs for node content, with a copy-to-clipboard action. A URL allowlist restricts links to `http:`/`https:`/`mailto:`/in-document anchors, raw HTML execution is disabled, and external links carry `rel="noopener noreferrer"`. A first, focused step toward human-editable memory (#765); no write path yet. New `explorer/tests/markdownContentViewer.test.ts`: 8 tests
- **Follow-up (perf)** (#1195, addresses #1118) by @pravit-amp: `remarkPlugins` and the ~20-entry renderer `components` map were inline literals, so every unrelated re-render (e.g. clicking Copy) re-ran the full remark parse and remounted the whole subtree — up to 1.1s of main-thread block on a 2000-row GFM table. Both are now hoisted to module scope and the rendered element is memoized on content, cutting re-render cost from as much as 1121ms to ~0.1ms across all measured fixtures with no change to rendered output. A separate, upstream `remark-gfm` table-parse cost (~O(n^1.9), not fixed here) is left open on the issue as a product decision
- **Follow-up (cleanup)** (#1194, closes #1119) by @pravit-amp: the pure `isSafeUrl` URL-safety helper is extracted out of `MarkdownContentViewer.tsx` into its own `markdownUrlSafety.ts` module (behavior-preserving — moved verbatim), so the component module exports only components and stops tripping `react-refresh/only-export-components`
- **`reasoning` gains a structured Action layer — rule-driven side effects with optional provenance** (#1096, closes #1095) by @cxzg007`AssertAction`/`RetractAction`/`CallAction`/`EmitEventAction` let a matched rule write facts back to a `KnowledgeGraph`, retract facts, call a structured handler (replacing the previously-unused `Rule.handler`), or emit to a sink registered via `Reasoner.on_event`, turning the reasoner from a pure inference engine into a production-rule system. With `provenance=True`, fired actions are recorded to `Reasoner.action_log`. Fully additive — rules without `actions` are unaffected, and the legacy `handler` field still fires (now wrapped internally as a `CallAction`). Also fixes a latent dangling import in `reasoning_provenance.py` (`ReasoningEngine`/`infer``Reasoner`/`infer_facts`). New `tests/reasoning/test_rule_actions.py`: 9 tests; full `tests/reasoning/` suite: 54 passed
- **`run_shacl_validation` is now a public, documented entry point** (#1189, closes #1186) by @mikemikimike — the SHACL guide had documented the private `_run_pyshacl` helper as the canonical API; it's now exposed through `semantica.ontology`, with `_run_pyshacl` kept as a compatibility alias over the same implementation. `tests/ontology/test_ontology_advanced.py`: 33 passed (also fixes a flaky comparison against pySHACL's non-deterministic blank-node shape identifiers by comparing stable report fields instead)
- **`docs/storage-backends.md`: adapter inventory and RDF/LPG feature matrix** (#899, addresses #888) by @yulinlina — which graph storage backends are built-in vs. bring-your-own, and where provenance/context support is partial
- **`docs/guides/shacl-validation.md`: documented that `rdfs:range` + RDFS entailment makes `sh:class` unfalsifiable** (#1182, fixes #1130) by @ALDRIN121 — with entailment on, pyshacl infers the declared range class onto every object, so a `sh:class` constraint can never fail and reports `conforms: True` on non-conforming data; added to Common Pitfalls with the `inference="none"` vs `inference="rdfs"` contrast and guidance to re-run `sh:class` shape sets with entailment off before trusting a pass
- **Cookbook: four new module notebooks**`22_Provenance_Tracking.ipynb` (#989, lineage walks, revision history, invalidation, checksums), `23_Reasoning.ipynb` (#990, `Reasoner`/`DatalogReasoner`/`ExplanationGenerator`), `24_Change_Management.ipynb` (#991, versioned snapshots, named tags, checksum tamper-detection), and `25_Seed_Data.ipynb` (#992, bootstrapping a foundation graph from a trusted CSV source) — all by @LeonSGP43, filling gaps where the corresponding module shipped a usage doc but no runnable tutorial; every cell verified against current module source. `docs/cookbook.md` index entries for all four added in #1225
- **README "Cite Us" section and `docs/citation.md` cross-link** (#1210) by @KaifAhmad1 — BibTeX/APA/MLA/Chicago/IEEE citation forms; also corrects the copyright holder in `LICENSE`/`docs/project-license.md` from the stale "Hawksight AI" to "Semantica" and replaces the retired `Hawksight-AI` GitHub org slug with `semantica-agi` across ~40 files (READMEs, issue templates, plugin manifests, cookbook notebooks, docs)
### Changed
- **A registered custom method can now refuse, instead of being silently overridden by the default implementation** (#1127, closes #1108) by @fabio-rovai — every module supporting custom methods wrapped the registered callable in a `try`/`except` that logged a warning and ran the built-in default on *any* exception, including one a validator or policy gate raised on purpose to say "do not produce this output." That made every registered gate advisory rather than authoritative. `semantica/utils/custom_methods.py` now centralizes the policy: an exception from a registered method propagates to the caller by default; `fallback_on_custom_error=True` restores the previous warn-and-continue behavior per call. Applied mechanically across all 58 call sites in `export/`, `ingest/`, `normalize/`, `parse/`, `embeddings/`, and `kg/` methods modules. New `tests/utils/test_custom_method_can_refuse.py`: 13 tests, including the reported gate-deletes-and-raises scenario and a guard that no call site still swallows
- **Removed 13 confirmed-dead symbols across 9 files** (#1176, closes #1174) by @Vinv-AI — private helpers and Explorer app-layer code with zero callers in code, tests, or docs, none part of the public API or a FastAPI `response_model`; 289 deletions, no behavior change
- **Consolidated the two duplicate Turtle/N-Triples literal escapers in `rdf_exporter.py`** (#1221, closes #1218) by @pkupt`_escape_turtle_literal` (added in #1148) escaped the same five characters in the same order as the older module-level `_escape_literal`; the redundant one is dropped and all four call sites route through the original. Behavior no-op, verified against the full export suite (301 passed, 1 skipped)
- **Removed the unreachable `_extract_with_spacy()` method and the unused `self.nlp` attribute from `NERExtractor`** (#1220, fixes #1058) by @yunaremaia — the ML dispatch path has always gone through `methods.py`'s process-level model cache instead; `__init__` still validates the spaCy runtime up front but no longer eagerly loads a model nothing on the instance reads
- **Cleaned up an unused `sys` import and import ordering in `semantica/worker.py`** (#1061) by @aoright
- **Test-only contributions**: isolated `sys.modules` mock leakage between `tests/visualization/` files so the suite passes in any collection order (#897, closes #859, by @luantaraschi); added coverage for 4 previously-untested `ConflictResolver` strategies and 3 `ConflictDetector` conflict types (#902, fixes #865, by @Devansh070); added a regression test tracking relationship provenance through `ProvenanceManager` (#1071, closes #1055, by @dex0shubham); added `max_tokens`-propagation regression coverage for LLM extraction methods, later folded into the cache-key fix below (#925, by @saiganesh47)
### Fixed
- **`SPARQLReasoner.execute_query()` claimed to run a query but always returned an empty result** (#1087, fixes #1083) by @ALDRIN121 — both the store-configured and unconfigured branches returned an empty `SPARQLQueryResult` with no real execution behind it, so a caller trusting "no matches" (e.g. a compliance check) could draw a false-negative conclusion from a method that never actually queried anything. Until a real triplet-store execution path lands, it now raises `NotImplementedError` explaining why, and the dead cache/inference scaffolding after the unreachable execution point is removed. 3 new regression tests
- **`DuplicateDetector` merged entities that share no identifier, type, or name** (#1149, fixes #1137) by @pkupt`_create_duplicate_candidate()` only ever boosted confidence for matching types and never penalized a mismatch, so two sparse, differently-typed entities (e.g. a `Person` and an `Organization`) could land above the merge threshold and collapse into one node, silently dropping the second. Two non-empty, differing types are now never a duplicate candidate. `tests/deduplication/`: 92 passed
- **`TemporalGraphQuery.analyze_evolution()`'s `stability` metric was a hardcoded placeholder** (#1143, closes #1142) by @cxzg007 — every bounded relationship contributed a constant `1`, so `stability` was always `1.0` or `0` regardless of how long relationships actually stayed valid. Now computes the mean valid-time duration in seconds across relationships with both `valid_from`/`valid_until` set; unbounded/half-open intervals are skipped and negative intervals clamp to zero. 3 new tests in `tests/kg/test_kg.py`
- **CodeQL false-positive on a JSON-LD test's URL check** (#1183) by @KaifAhmad1`"https://schema.org/" in flattened` pattern-matched CodeQL's substring-sanitization heuristic even though `flattened` is always a `list` (exact membership, no sanitization or SSRF path involved); rewritten as an explicit `any(entry == ... for entry in flattened)` with identical behavior
- **HuggingFace NER extraction crashed on `huggingface_model` being forwarded as an unexpected pipeline loader kwarg** (#1188, fixes #1063) by @shahzaib-ahmadcs — while preserving genuinely supported pipeline kwargs like `aggregation_strategy`. 5 tests pass
- **JSON-LD document/graph `@id` was minted from the wall clock, so re-exporting an unchanged graph produced a new subject every time** (#1181, closes #1147) by @reddynitish — merging repeated exports duplicated graph identity instead of recognizing them as the same graph. The `@id` is now content-derived, with optional `graph_uri`/`document_uri` overrides for callers with a stable graph name; `semantica:exportedAt` still records export time separately. Applies to both JSON-LD export paths
- **`ContextGraph.get_causal_chain()` only matched the canonical uppercase causal-edge spellings, silently missing edges recorded in `CausalChainAnalyzer`'s present-tense vocabulary** (#1187, fixes #1184) by @ALDRIN121`causes`/`influences`/`precedes` differ from `CAUSED`/`INFLUENCED`/`PRECEDENT_FOR` in word form, not just case, so an edge recorded with the analyzer's spelling produced an empty audit chain — silent, and in the dangerous direction for a compliance trace. `add_causal_relationship()` now normalizes through an alias map before storing the canonical form; traversal accepts the union vocabulary. 2 new regression tests, full `tests/context/` suite: 587 passed
- **`semantica embed generate` corrupted its own output and could recurse into a stack overflow** (#996/#1004/#1005, closes #994) by @varunsahni18, @yzxcj797 — three compounding defects in one pipeline. (1) `generate_embeddings`/`embed_text`/`calculate_similarity`/`pool_embeddings` all registered themselves as their own custom-method-registry default, so an unqualified call (exactly what the CLI does) re-entered the same wrapper until Python's recursion limit; each of the four dispatch sites now guards on registry identity before recursing (#996, #1005). A second self-recursion in `EmbeddingGeneratorWithProvenance.__getattr__` (re-entering itself when `_generator` is unset, e.g. during a `deepcopy` probe) now raises a normal `AttributeError` for private names instead (#1005). (2) `--output embeddings.parquet` wrote `json.dumps(result, default=str)` regardless of extension, turning a numpy array into its plain-text `repr()` — a file `embed index` then failed to open as Parquet; the writer now detects `.parquet`/`.json`/`.jsonl` and produces real Parquet/JSON, rejecting any other extension with a clear message (#996, #1004). (3) `pyarrow` was only in optional extras despite being required by the documented quick-start flow; promoted to a core dependency (#996)
- **`AgentMemory`'s existing Markdown import accepted symbolic links, NTFS junctions, and other Windows reparse points** (#851) by @SaurabhScripts — a direct linked import path is now rejected with an actionable error, and a linked entry found inside an otherwise-valid directory is skipped rather than aborting the whole import; hardened with pre-open/post-open checks, `O_NOFOLLOW` where available, and `fstat`-based regular-file validation. `tests/context/`: 595 passed, 1 skipped (Windows-junction test, runs on Windows CI)
- **A caught vector-similarity scoring exception left stale partial state behind, risking a misleading match on the next call** (#885, fixes #875) by @ArmanGrewal007 — the exception is now logged at debug level and `vector_score`/`vector_idx` reset to neutral values before the remaining matching stages continue
- **`RDFExporter` could write invalid or unintended relative IRIs for `GraphBuilder`-default entity/relationship identifiers** (#1112, closes #1099) by @mikemikimike — normalization is now applied at the RDF export boundary across Turtle (including temporal Turtle), RDF/XML, and N-Triples: bare/relative identifiers are minted under the Semantica namespace with safe percent-encoding, absolute IRIs pass through unchanged, and configured/input-context prefixes expand through the effective namespace mapping. 38 focused regression tests; 175 export tests plus 46 subtests pass
- **`RDF4JStore`'s `repository_id` constructor argument had no effect** (#1192, closes #1191) by @Freakz2z — the explicit id is now honored when selecting the repository; stale documentation caveats claiming otherwise are removed. 65 tests pass across the affected triplet-store suites
- **Non-interactive stdout (piped/redirected output, CI logs) was flooded with progress-bar escape sequences** (#1193, fixes #1185) by @ALDRIN121 — a plain `python demo.py > out.txt` captured 173 bytes of progress noise around 10 bytes of real output. `ProgressTracker` now attaches its console display only for an interactive terminal, Jupyter, or the new `SEMANTICA_FORCE_PROGRESS` opt-in (following the `NO_COLOR`/`FORCE_COLOR` convention); file-based progress logging is untouched. Both switches are now documented in the README and `docs/reference/utils.md`. 11 tests pass (6 new)
- **Entity `metadata` was dropped by every RDF serializer except the JSON-LD path**, so an entity kept its confidence but lost its source document, page, extractor, and reviewer on Turtle/N-Triples/RDF/XML/`RDFExporter`'s own JSON-LD (#1165, closes #1154) by @fabio-rovai — Semantica's own metadata keys (`num_entities`, `snapshot_time`, Neo4j loader fields, etc.) are now mapped to declared vocabulary terms and carried through on every path; a caller-supplied key with no mapped term is skipped with an explicit warning (rather than silently vanishing) naming the override needed, pending the caller-key namespace decision tracked in #1146. 21 new tests in `tests/export/test_metadata_passthrough.py`; `tests/export`+`tests/ontology`: 274 pass
- **`extract_relations_llm` silently dropped caller-supplied generation parameters** (`max_tokens`, `top_p`, `seed`, etc.), and the extraction cache didn't distinguish calls made with different generation settings (#1213, with test coverage from #925) by @Sameer6305 — a small hardcoded allowlist forwarded only `temperature`/`verbose` to `generate_typed`, discarding the rest; fixed by forwarding all caller kwargs. Once forwarded, those parameters also needed to enter the cache key, since two calls differing only in `max_tokens` previously shared one cache entry and the second could silently reuse a result generated under the first's settings — now applied consistently across entity, relation, and triplet LLM extraction. New regression tests for cache bypass/reuse under differing `max_tokens`/`temperature`
- **`OxigraphStore` silently ignored the `storage_path` constructor argument and never flushed writes before a reopen**, both causing silent on-disk data loss (#970) by @logan-jl-cc — `__init__`'s parameter is named `path`, so the project-conventional `storage_path` landed in `**config` and was ignored, degrading a supposedly-persistent store to in-memory with no error; `storage_path` is now accepted as an alias. Separately, pyoxigraph's background flush can lag behind a write, so a reopen immediately after `add_triplets` could observe fewer triples than were written; writes to an on-disk store now call `flush()` explicitly. 2 new regression tests, full suite: 9 passed
- **MCP server's `export_graph` tool was broken on every output format** (#1151) by @Arasz — the `json` branch called `JSONExporter().export()` without the `file_path` it requires, and every RDF branch passed a `ContextGraph` object where the exporters expect the canonical kg dict, both surfacing as a raw exception string. A third bug compounded both: the RDF export path's progress bar wrote to stdout, which over stdio MCP *is* the JSON-RPC framing, corrupting the protocol and hanging the client (a 300s timeout on an empty graph). Fixed by converting through `ContextGraph.to_kg_dict()`, serializing the JSON branch to match the RDF branches' string contract, and forcing `SEMANTICA_DISABLE_PROGRESS=1` for the server process. 5 new tests, verified failing against 0.6.6 beforehand
- **`OntologyIngestor` dropped every class and property from a JSON-LD document using a named graph** (#1156, fixes #1129) by @13g4d0 — a top-level `@id` beside `@graph` names the graph, and `rdflib.Graph.parse()` silently loads only the default graph, discarding the rest; `POST /api/ontology/load` returned `status: "success"` with `class_count: 0`. Now parses into a `Dataset` and flattens all quads into the working graph (the same `Graph``Dataset` migration #757 made for `JenaStore`, extended to the ingest path). On the PR's real-world reproduction: 25 triples/1 subject before, 719 triples/45 classes/40 object properties after. 4 new tests including a default-graph canary so the fix can't trade one blind spot for another
- **Turtle and N-Triples RDF export interpolated entity `text` into string literals with no escaping**, so a `"`, backslash, newline, CR, or tab in the source text emitted invalid RDF other parsers rejected (#1148, closes #1098) by @pkupt — a shared `_escape_turtle_literal()` (later consolidated in #1221) now escapes per the RDF 1.1 Turtle grammar and is reused for the N-Triples path, which previously escaped only quotes and newlines. `tests/export/`: 161 passed, 1 skipped
- **`PipelineSerializer` round trips dropped step dependencies and delta-processing metadata, and could rehydrate a legacy stringified handler as a non-callable string** (#1217, fixes #1216) by @cxzg007 — step dependencies, delta mode, and base/target version IDs are now restored from the serialized schema; runtime handler callables are treated as process-local state and excluded from serialized business configuration rather than (mis)serialized. 52 tests pass
- **`PipelineBuilder` never actually dispatched to a handler registered by `step_type`**, and a serialize/deserialize round trip could leak `handler`/`dependencies` into a step's business config (#1215, fixes #1214) by @cxzg007 — a registered handler is now resolved by `step_type` when no explicit `handler=` is supplied (explicit handlers still take precedence), and the two builder-control fields are kept out of `PipelineStep.config` so a strict handler signature can't receive them as unexpected kwargs. `tests/core`+`tests/pipeline`: 50 passed
- **`PipelineBuilder.set_parallelism()` was accepted and stored but never read — pipeline steps always ran strictly sequentially**, and the setting didn't survive a serialize/deserialize round trip (#1226, fixes #1223) by @cxzg007 — wired through builder → serializer → execution engine, plus a new opt-in `PipelineStep.parallel_safe` flag. A dependency layer now runs in parallel only when every step in it is marked `parallel_safe`, the layer has more than one step, the input is dict-typed, and no step is in delta mode; otherwise it falls back to sequential execution. Each parallel step's input is deep-copied for isolation, execution is bounded by `ThreadPoolExecutor(max_workers=min(configured parallelism, max_workers))`, a failure cancels pending futures in the layer, and layer results merge back in declaration order (a same-key conflict raises `ProcessingError`). 22 new tests in `tests/pipeline/test_pipeline_parallel.py`
- **`Config.get()` silently dropped boolean environment-variable overrides** (#1038, fixes #1035) by @Kyou12138 — the type dispatch checked `isinstance(default, int)` before `isinstance(default, bool)`, and since `bool` subclasses `int` in Python, the bool branch was unreachable: `CONFLICT_ZZTESTFLAG=true` with a `False` default returned `False`, and `=1` returned the int `1` rather than `True`. Bool is now checked first (with whitespace stripped before parsing truthy/falsy spellings), fixed across all ten affected config modules (`conflicts`, `deduplication`, `split`, `embeddings`, `export`, `ingest`, `kg`, `parse`, `ontology`, `normalize`). 12 new tests plus 6 existing conflicts tests and 131 related module tests pass
- **Scanned (image-only) PDFs parsed with no error and no warning, returning empty text with a "completed" status** (#1021, closes #1020) by @shanyu910`PDFParser._parse_page` swallowed a missing text layer via `page.extract_text() or ""`, so the failure only surfaced far downstream as zero extracted entities. A warning now fires when every parsed page yields no text with `extract_text` enabled, pointing at `parse_pdf(..., method="docling", enable_ocr=True)`. Also fixes a separate `import semantica.parse` failure on a fresh interpreter (`email_parser.py` used `email.message.Message` without importing `email.message`) that was blocking the parse test suite from even collecting. 25 tests pass in `tests/parse/`
- **`GET /api/decisions` returned HTTP 422 for any graph containing real decisions**, breaking the Explorer Decisions workspace entirely (#937) by @logan-jl-cc — `record_decision()` stores the timestamp as a POSIX float, but `DecisionResponse.timestamp` is typed `Optional[str]` and Pydantic's strict mode rejected the coercion. Fixed by coercing to `str` (preserving `None`) at the response-adapter boundary
- **Decision persistence/query bugs, CJK text handling, and three missing MCP graph tools** (#967) by @toratto`mcp_server`'s `_get_graph` called a non-existent `graph.load` instead of `load_from_file`, so `SEMANTICA_KG_PATH` was silently ignored and the server always started with an empty graph; `query_decisions` read `category` from the wrong field, always returning nothing for a category filter; `find_precedents`/`query_decisions(query=)`'s similarity threshold was too high for short CJK queries, which also failed outright because `_calculate_decision_content_similarity`'s whitespace-Jaccard fallback is always zero for languages with no whitespace tokenization (now falls back further to a character-bigram overlap coefficient); `load_from_file` didn't rebuild the in-memory decision/entity/temporal indexes after loading, breaking `find_precedents_by_scenario` and decision counts post-reload; `extract_entities`/`extract_relations` returned the spaCy type label as `text` and dropped the actual entity text, and had no way to select a non-English NER model. Also adds three new MCP tools (`query_graph`, `update_node`, `delete_node`, the latter two persisting back to `SEMANTICA_KG_PATH`)
- **`sqlalchemy.text` was used but never imported in two `DBIngestor`/`DataExporter` methods**, raising `NameError` on every call before any query reached the database (#1017, closes #1015) by @pravit-amp — `connect()`/`test_connection()` imported `text` function-locally, so the binding never reached `export_table_data()` or `execute_query()`, which called it anyway; both raised immediately, re-wrapped by an `except Exception` into a `ProcessingError` that read like a database fault rather than a missing import. `docs/guides/ontology.md` documents `DBIngestor().execute_query()` as a supported entry point, so documented usage walked straight into it. 5 new tests against a temporary SQLite database, also repairing a previously-failing `tests/ingest/test_notebook_02.py` case
- **Ontology generation resolved relationship endpoint types incorrectly, producing wrong object-property domains/ranges** (#1170, closes #1168) by @T1mn — endpoint types are now resolved from the canonical `source_id`/`target_id` fields and supported aliases instead of defaulting to the first entity when a field was missing, preventing e.g. a `Person -> Organization` relationship from generating a `Person -> Person` property. 80 tests pass, 1 skipped
- **Ontology property generation dropped data properties when a raw entity type was normalized into a class name** (#1171, closes #1169) by @T1mn — e.g. `software engineer``SoftwareEngineer` lost its `email` property; attributes are now grouped by matching raw, normalized, and recorded class names, so the normalized class stays each property's domain. 79 tests pass, 1 skipped
- **`flatten_dict()` silently dropped data when a top-level key already containing the separator collided with a key produced by flattening a nested dict** (#1012, fixes #1010) by @yzxcj797`{"a.b": 1, "a": {"b": 2}}` flattened to `{"a.b": 2}` with no error, the `1` simply gone; collisions are now detected (unique-key count vs. item count) and raise `ValueError` naming the colliding key before data is lost. 6 new tests
- **Creating relationships after `GraphStore.add_edges`/`build_from_entities_and_relationships` silently produced zero edges against ID-minting backends** (#1173, fixes #1136) by @yzxcj797 — an id-space mismatch across three layers: `add_edges` reads application-level string ids and passes them to `create_relationship`, which is a pure passthrough into `Neo4jStore.create_relationship`'s `MATCH ... WHERE id(a) = $start_id` — a Neo4j-internal integer id. Every node was created and every relationship silently failed with one easily-missed warning per edge. `GraphStore` now keeps an application-id→internal-id map, populated by `add_nodes`/`create_node` from the backend's own creation results and consulted by `create_relationship`; unknown ids and identity-mapped backends are unaffected. `tests/graph_store/`: 100 passed
- **RDF export left `semantica:text`/`rdfs:label` empty for entities that only carry a `name` field**, across all four RDF formats (#1113, fixes #1097) by @cxzg007`RDFSerializer.convert_kg_to_rdf()` already implemented the `name``label`/`text` normalization, but `export_to_rdf()` never called it. Now called once at the export boundary (idempotent, non-destructive, falls back to a label derived from the id suffix). 7 new tests, `tests/export/test_rdf_exporter.py`: 17 passed
- **Docker Explorer image failed to build on Python 3.14**`gensim` has no prebuilt wheel for it and the slim base has no `gcc` to build from source (#1172, closes #1025) by @DwitiThaker — runtime pinned to `python:3.13-slim`, where `gensim` installs from a prebuilt wheel
- **Unit normalization rejected common aliases before conversion**`kg`, `g`, and other abbreviated/plural unit spellings failed category validation and the conversion-factor lookup ahead of it (#939) by @Mr-Neutr0n — aliases now normalize first; canonical aliases added for feet, yards, miles, and gallons. 7 tests pass
- **An oversized, caller-controlled mapping key could blow up a `ValidationError` message to megabyte scale**, and equally inflate application logs on repeated malformed input (#1088, fixes #1001) by @ALDRIN121 — follow-up to the graph-payload validation added in #958. The displayed key is now truncated at 64 characters with an ellipsis; the underlying input and validation decisions are unchanged. 4 new tests
- **`SeedDataManager.load_from_api()` mislabeled genuine connection failures as a missing `requests` dependency** (#972, closes #949) by @pravit-amp — `requests.exceptions.RequestException` (connection errors, timeouts, `raise_for_status()` failures) subclasses `OSError`, so an `except (ImportError, OSError)` block written to guard a lazy import that no longer existed (`requests` is a core dependency) caught real failures too and told users to reinstall an already-installed library while dropping the original exception chain. The block is removed; genuine failures now surface through the existing `Failed to load from API: {e}` path with `from e` intact. 5 new regression tests
- **`SHACLGenerator` produced shapes that matched nothing, and pySHACL reported `conforms: True` on data that plainly violated them** (#1124, closes #1104, closes #1105) by @fabio-rovai — `base_uri` was used both as where shape resources live and to expand every `sh:targetClass`/`sh:path`, so with the default shapes namespace, generated shapes targeted classes no data graph in the package actually uses; a shape with zero matching focus nodes is vacuously satisfied, so validation silently passed regardless of real violations. The target namespace now resolves independently (explicit argument → ontology's declared namespace → an existing absolute class/property IRI → ontology `uri` → the vocabulary namespace), never the shapes namespace. Separately, `_attach_property_shapes` attached a domain-less property's constraint to *every* shape ("no domain declared, attach to all"), asserting a constraint the ontology never stated; a domain-less property is now left unattached by default, with `attach_domainless_properties=True` to restore the old behavior. 17 new tests validate real data through pySHACL rather than reading shape text; `tests/ontology`+`tests/export`: 239 passed
- **OWL export dropped every generated property and collapsed distinct classes onto one node** (#1123, closes #1103) by @fabio-rovai — `OWLExporter` reads `object_properties`/`data_properties`, but `OntologyGenerator` emits one combined `properties` list, so every property was silently discarded; separately, a class built without a namespace manager gets `"uri": None`, which a `"uri" not in cls"` guard never catches (the key is present), so the exporter wrote a relative `<>` IRI for it — resolved by rdflib against the current working directory, meaning two classes could collapse onto one subject and that subject's identity changed with the export's working directory. Both dict shapes are now merged and classified correctly, and a class/property IRI resolves through `uri``iri``id`→a name joined onto the ontology base, skipping (with a warning) a term with none of those instead of minting `<>`. 10 new regression tests parse the real output with rdflib and Oxigraph; `tests/export`+`tests/ontology`: 231 passed
- **Confidence scores serialized as four different, mutually-disagreeing RDF terms depending on export format, and one non-numeric confidence value could break an entire Turtle export** (#1125, closes #1100, closes #1102) by @fabio-rovai — Turtle wrote a bare `xsd:decimal`, N-Triples an explicit `xsd:float`, RDF/XML an untyped plain literal, and JSON-LD's native number expanded to `xsd:double`; loading a Turtle and an N-Triples export of the same graph into one store gave the same entity two different confidence values. Separately, an unparseable confidence (e.g. the string `"high"`) was interpolated into Turtle with no validation, producing a syntax error that dropped every entity from the export. All four paths now write one canonical `xsd:decimal` lexical form (matching the pre-existing Turtle behavior and the only exact representation of the four); an unusable value is omitted with a warning instead of corrupting the document. The vocabulary's `sem:confidence` now declares `xsd:decimal` (previously left undeclared to avoid contradicting the disagreeing exporters). 20 new tests compare parsed graphs across all four formats; `tests/export`+`tests/ontology`: 240 passed
- **An OWL-Time validity interval was reified onto a relationship IRI the graph never actually referenced**, making it unreachable from the edge it described (#1126, closes #1106) by @fabio-rovai — a relationship serializes as a single triple with no node of its own, so `include_temporal=True` minted a well-formed `time:Interval` with zero inbound arcs to its subject. Turtle now also emits the `sem:Relationship`/`sem:source`/`sem:target`/`sem:type` reification the JSON-LD path already produced, but only when there's temporal data to attach — default and `include_temporal=False` output are byte-for-byte unchanged. 7 new tests include a SPARQL walk from the edge to its interval, the path the dangling node made impossible; `tests/export`+`tests/ontology`: 228 passed
- **JSON-LD exports were unreadable by Semantica's own default parser** (#1145, fixes #1144) by @fabio-rovai — every export was written as a named graph (a top-level `@id` beside `@graph`), which a plain `rdflib.Graph.parse()` silently discards in favor of the (empty) default graph; a two-entity graph parsed as 2 triples instead of 20. Compounded by `export_knowledge_graph` converting its payload to JSON-LD and then handing the *already-converted* document to `export()`, which converted it again, producing two `@context` blocks and two document nodes. Metadata now attaches beside `@graph` rather than naming it, and a payload that already declares `@context` is merged rather than re-wrapped. 9 new tests parse with both `Graph()` and `Dataset()` and assert identical counts; full-suite failure set unchanged before/after (539/539)
- **`GraphBuilder` didn't propagate entity-resolution's merged ids into the `source_id`/`target_id` relationship aliases**, only `source`/`target` (#1115, closes #1110) by @T1mn — a relationship's alias fields could still point at a pre-merge id after resolution. Both alias pairs are now kept in sync. 9 tests pass
- **`GraphValidator` indexed entities only by `id`, rejecting graphs that use the `entity_id` alias as invalid even when their relationships were fine** (#1116, closes #1111) by @T1mn — validation and endpoint checks now go through the shared `get_entity_id()` helper, accepting both fields consistently. 5 tests pass
- **Broken star history chart in README** (#1057) by @OctoBored — the embedded chart used the GitHub stargazer API, now access-restricted; switched to a token-free alternative data source
### Security
- **Agno's `AgnoKnowledgeGraph.load_urls()` made outbound requests with no SSRF protection beyond a scheme check** (#1212) by @Sameer6305 — caller-supplied URLs went straight to `urllib.request.urlopen()`, unguarded against loopback/private addresses, cloud metadata endpoints (`169.254.169.254`), IPv6-internal addresses, hostnames resolving to private space, or redirects into any of the above. Found during a project-wide SSRF audit following #936/#959. Now routed through the shared `request_with_ssrf_guard()`; an unsafe URL is skipped rather than aborting the rest of the ingestion batch. `OpenClawKGTool` (operator-configured, intentionally allowed to target `localhost` for local deployments) gains scheme/malformed-URL validation as defense in depth, without restricting its legitimate private-network use case. 29 new Agno tests, 26 new OpenClaw tests, all passing alongside the 15 pre-existing Agno integration tests
### Dependencies
- Routine version bumps with no application-facing behavior change: `anthropic` 0.121.0→0.122.0 (#1045), `botocore` 1.43.69→1.43.73 (#1047), `agno` 2.8.7→2.9.0 (#1050), `google-genai` 2.17.0→2.18.1→2.19.0 (#1163, #1205), `lxml` 6.1.1→6.1.2 (#1197), `charset-normalizer` 3.5.0→3.5.1 (#1201), `pypickle` 2.0.1→2.0.2 (#1203)
## [0.6.6] - 2026-08-20
### Added
@@ -108,6 +213,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **KG provenance tests asserted on generated ID strings instead of stored records, and `kg_provenance.py` was missed by the `utcnow` sweep** (closes #946) by @pravit-amp
- The KG workflow and integration suites checked that a tracker call returned an ID matching a prefix (`assert cent_id.startswith("centrality_")`) without ever reading the record back, so an ID generator that returned a well-formed string and wrote nothing would have passed. Worse, some of those calls named tracker methods that do not exist anywhere in `semantica/` (`track_layer_analysis`, `track_centrality_score`), so the assertions were satisfied with no real interaction behind them
- Those tests now read provenance back through `get_provenance()` and assert on algorithm metadata, and call the methods that actually persist records. Verified by mutation rather than by a green run alone: neutering the manager's storage write (`self.storage.store(...)` → no-op) fails 10 tests
- `GraphBuilderWithProvenance` in `semantica/kg/kg_provenance.py` still stamped `activity_started_at_time`/`activity_ended_at_time` with the deprecated `datetime.utcnow()`; it was outside the `export/`+`provenance/` scope of the #1114 sweep below and now uses the same `utc_now_iso()` helper. `docs/guides/provenance.md` and `docs/reference/provenance.md` were still documenting `utcnow()` and a naive timestamp example, and now show the helper and the offset-bearing form
- 16 tests across the affected suites ended in `return <value>` instead of asserting, which pytest reports as `PytestReturnNotNoneWarning`; now zero
- **The temporal-evolution `stability` metric was a hardcoded placeholder, not a duration**
- `TemporalGraphQuery.analyze_evolution()` documents `stability` as a "relationship duration/stability measure", but the implementation appended a constant `1` for every relationship with both `valid_from` and `valid_until` set (`durations.append(1) # Placeholder`). The reported stability was therefore always `1.0` when any bounded relationship existed and `0` otherwise — it never reflected how long relationships actually stayed valid, so it could not distinguish a graph of decade-long relationships from one of one-second relationships
- `stability` now computes the mean valid-time duration in seconds (`(valid_until - valid_from).total_seconds()`) across relationships that have both bounds set. Relationships with a missing or open `valid_from`/`valid_until` are skipped (their duration is unbounded), and non-positive intervals are clamped to `0`; an empty set still reports `0`
@@ -121,6 +232,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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
- **`SHACLGenerator` mangles `#`-terminated namespaces into `#/`, so generated shapes target nothing** (#1082) by @changshenhan
- `__init__` normalized `base_uri` with `rstrip("/") + "/"`, which turns `http://example.org/manufacturing#` into `...manufacturing#/` — the most common RDF namespace convention. Every generated URI (`sh:targetClass`, `sh:path`, shape URIs) then landed in a different namespace than the instance data, and SHACL validation silently passed because the shapes targeted nothing
- `__init__` now preserves a namespace already ending in `/` or `#`, matching the `#`-aware normalization `generate()` already applies; `shapes_uri` inherits the fix
- New `test_hash_namespace_base_uri_is_not_mangled` in `tests/ontology/test_ontology_advanced.py` fails on the pre-fix normalization and passes with it; full ontology suite (76 tests) green
- **`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
+24 -26
View File
@@ -87,7 +87,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, CrewAI, and LangChain support, a full-featured MCP server, a comprehensive CLI, a REST API, and plugins across major editors
---
@@ -142,11 +142,13 @@ 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.7 pass
# faiss vector store pass
# Config file pass ~/.semantica/config.yaml
```
**Running in a script or CI?** Progress bars are written only when stdout is an interactive terminal (or a Jupyter notebook), so piping and redirecting stay clean by default. Override with `SEMANTICA_DISABLE_PROGRESS=1` to silence progress everywhere, or `SEMANTICA_FORCE_PROGRESS=1` to keep it when stdout is redirected. `SEMANTICA_DISABLE_PROGRESS` takes precedence.
<div align="center">
If Semantica solves a real problem for you, a star helps others find it.
@@ -1186,7 +1188,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, CrewAI, and LangChain 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.
MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
@@ -1305,17 +1307,17 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
<strong>CrewAI</strong><br/>
<sub>First-class · <code>pip install semantica[crewai]</code></sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/langchain-ai/langchain"><img src="https://github.com/langchain-ai.png?size=120" alt="LangChain" width="48" height="48" /></a><br/>
<strong>LangChain</strong><br/>
<sub>First-class · <code>pip install semantica[langchain]</code></sub>
</td>
</tr>
<tr>
<th colspan="8" align="left">Already Supported via REST API &amp; MCP</th>
</tr>
<tr>
<td align="center" width="12.5%">
<a href="https://github.com/langchain-ai/langchain"><img src="https://github.com/langchain-ai.png?size=120" alt="LangChain" width="48" height="48" /></a><br/>
<strong>LangChain</strong><br/>
<sub>REST API · MCP</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/langchain-ai/langgraph"><img src="https://github.com/langchain-ai.png?size=120" alt="LangGraph" width="48" height="48" /></a><br/>
<strong>LangGraph</strong><br/>
<sub>REST API · MCP</sub>
@@ -1346,11 +1348,6 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
</tr>
<tr>
<td align="center" width="12.5%">
<a href="https://github.com/langchain-ai/langchain"><img src="https://github.com/langchain-ai.png?size=120" alt="LangChain" width="48" height="48" /></a><br/>
<strong>LangChain</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 +1463,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.7
**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:
**Feature release**, plus one SSRF hardening fix and a large batch of correctness fixes across the RDF/ontology export pipeline:
- **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
- **First-class LangChain integration** (`semantica[langchain]`): a `BaseRetriever` and `VectorStore` over `HybridSearch`, plus graph/decision-query tools
- **SAP OData ingestor** (`semantica[ingest-sap]`): OAuth2/Basic-auth, SSRF-guarded ingestion for Business Partners and Sales Orders, following the existing Snowflake/Databricks connector pattern
- **`ContextGraph` gains deterministic, human-editable Markdown round-trip persistence** alongside the existing JSON API, and the Explorer graph inspector gains a read-only Markdown content viewer
- **`reasoning` gains a structured Action layer**: rule-driven `Assert`/`Retract`/`Call`/`EmitEvent` actions with optional provenance, turning the reasoner into a production-rule system
- **`run_shacl_validation` is now a public, documented API**, and a dozen ontology/RDF export correctness fixes land: OWL property/class export, SHACL target-namespace resolution, one canonical confidence datatype across all four RDF formats, reachable OWL-Time reification, JSON-LD default-graph and content-derived document identity, and full metadata passthrough on every RDF serializer
- **Security**: Agno's `AgnoKnowledgeGraph.load_urls()` and OpenClaw's MCP tool now route outbound requests through the shared SSRF guard
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 fixes: `PipelineBuilder.set_parallelism()` now actually parallelizes independent pipeline steps, `flatten_dict()` no longer silently drops data on a key collision, `Config.get()` honors boolean environment overrides, and the MCP server's `export_graph` tool works again on every format.
→ [Full release notes](RELEASE_NOTES.md) · [Changelog](CHANGELOG.md)
@@ -1509,6 +1506,7 @@ pip install semantica[all] # everything
```bash
pip install semantica[agno] # Agno multi-agent integration
pip install semantica[crewai] # CrewAI integration
pip install semantica[langchain] # LangChain / LangGraph integration
pip install semantica[llm-litellm] # OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Bedrock, Ollama, DeepSeek, and more
pip install semantica[graph-neo4j] # Neo4j graph store (LPG)
pip install semantica[graph-falkordb] # FalkorDB graph store (LPG)
@@ -1561,11 +1559,11 @@ On-premises deployment · Private cloud · Custom domain implementations · SLA-
## Star History
<a href="https://www.star-history.com/?repos=semantica-agi%2Fsemantica&type=date&legend=top-left">
<a href="https://star-history.dera.page/#semantica-agi/semantica&amp;type=date&amp;legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=semantica-agi/semantica&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=semantica-agi/semantica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=semantica-agi/semantica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=semantica-agi/semantica&amp;type=date&amp;theme=dark&amp;legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=semantica-agi/semantica&amp;type=date&amp;legend=top-left" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=semantica-agi/semantica&amp;type=date&amp;legend=top-left" />
</picture>
</a>
@@ -0,0 +1,253 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Provenance Tracking (W3C PROV-O)\n",
"\n",
"## Overview\n",
"\n",
"In high-stakes domains — healthcare, legal, finance, research — a Knowledge Graph is only as trustworthy as its ability to answer **\"where did this fact come from?\"**. Semantica's `provenance` module provides audit-grade, W3C PROV-O-aligned tracking for every entity, relationship and chunk that flows through your pipeline.\n",
"\n",
"In this cookbook you will learn how to:\n",
"\n",
"- Track entities and relationships with **source details** (DOI, page, verbatim quote, confidence)\n",
"- Walk the full **lineage** of a fact (document → chunk → entity → KG)\n",
"- Audit **revision history** and **all sources** behind an entity\n",
"- **Invalidate** a fact without deleting it (prov:Invalidation) — corrections stay provable\n",
"- Verify **tamper-evidence** with chained SHA-256 checksums\n",
"\n",
"**The Scenario:** a research team ingests findings from two scientific papers (with DOIs) into a Knowledge Graph. A regulator later asks: *\"Which paper, which figure, and which exact sentence supports the claim that fish biomass increased by 463%? And was that fact ever corrected?\"*"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!pip install -q semantica"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import json\n",
"from semantica.provenance import (\n",
" ProvenanceManager,\n",
" compute_checksum,\n",
" verify_checksum,\n",
")\n",
"\n",
"# In-memory storage for this demo; pass storage_path=\"provenance.db\"\n",
"# (or a config with provenance.storage_path) for a persistent SQLite backend.\n",
"prov = ProvenanceManager()\n",
"print(\"ProvenanceManager ready (in-memory storage)\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 1: Track Entities with Audit-Grade Source Details\n",
"\n",
"Every fact we ingest carries its evidence with it: the **source identifier** (a DOI here), the **location** inside the source (a figure), the **verbatim quote**, and the extractor's **confidence**."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Finding from paper #1\n",
"entry_biomass = prov.track_entity(\n",
" entity_id=\"claim_biomass_increase\",\n",
" source=\"DOI:10.1371/journal.pone.0023601\",\n",
" confidence=0.92,\n",
" source_location=\"Figure 2\",\n",
" source_quote=\"Total fish biomass increased by 463% ...\",\n",
")\n",
"\n",
"# Supporting entity from paper #2\n",
"entry_reserve = prov.track_entity(\n",
" entity_id=\"marine_reserve_1\",\n",
" source=\"DOI:10.1126/science.1088121\",\n",
" confidence=0.88,\n",
" source_location=\"Table 1\",\n",
" source_quote=\"... no-take marine reserve at Cabo Pulmo ...\",\n",
")\n",
"\n",
"print(\"Tracked:\", entry_biomass.entity_id, \"|\", entry_reserve.entity_id)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 2: Track the Relationship Between Facts\n",
"\n",
"Facts rarely stand alone. The claim about biomass increase is *about* the marine reserve — that relationship is a first-class provenance-tracked object too.\n",
"\n",
"`track_relationship()` has no dedicated subject/object fields, so by convention we record which two entities it connects inside `metadata`."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"rel = prov.track_relationship(\n",
" relationship_id=\"rel_biomass_about_reserve\",\n",
" source=\"DOI:10.1371/journal.pone.0023601\",\n",
" metadata={\n",
" \"type\": \"measured_at\",\n",
" # No dedicated endpoint fields on track_relationship() yet -- record\n",
" # which entities this relationship connects here by convention.\n",
" \"subject_entity_id\": \"claim_biomass_increase\",\n",
" \"object_entity_id\": \"marine_reserve_1\",\n",
" },\n",
")\n",
"\n",
"print(\"Relationship tracked:\", rel.entity_id, \"|\", rel.metadata[\"subject_entity_id\"], \"->\", rel.metadata[\"object_entity_id\"])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 3: Walk the Lineage\n",
"\n",
"`get_lineage` reconstructs everything known about a fact; `trace_lineage` returns the ordered chain of `ProvenanceEntry` records — every version, every activity, every agent that touched it."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"lineage = prov.get_lineage(\"claim_biomass_increase\")\n",
"print(json.dumps(lineage, indent=2, default=str)[:800])\n",
"\n",
"print(\"\\n--- ordered chain ---\")\n",
"for e in prov.trace_lineage(\"claim_biomass_increase\"):\n",
" print(f\"{e.entity_id} | seq#{e.sequence_id} | {e.activity_id}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 4: Audit Sources and Revision History\n",
"\n",
"When the regulator asks *\"has this fact ever been corrected?\"*, `revision_history` answers with the full version chain, and `get_all_sources` lists every source document that ever supported the entity."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"revisions = prov.revision_history(\"claim_biomass_increase\")\n",
"print(f\"{len(revisions)} revision(s) on record\")\n",
"\n",
"for s in prov.get_all_sources(\"claim_biomass_increase\"):\n",
" print(\"source:\", s)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 5: Invalidate — Correct Without Deleting\n",
"\n",
"Suppose paper #1 is retracted in part. An audit trail must **not** silently delete the fact: `invalidate` archives the pre-invalidation state and appends a fresh `prov:Invalidation` entry naming **who** retracted it and **why**."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"invalidated = prov.invalidate(\n",
" entity_id=\"claim_biomass_increase\",\n",
" agent_id=\"reviewer_dr_chen\",\n",
" reason=\"Partial retraction: Figure 2 statistics corrected by publisher (see erratum).\",\n",
")\n",
"print(\"Invalidated:\", invalidated.entity_id, \"| invalidated flag:\", getattr(invalidated, \"invalidated\", True))\n",
"\n",
"stats = prov.get_statistics()\n",
"print(\"\\nStorage statistics:\", json.dumps(stats, indent=2, default=str))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 6: Verify Tamper-Evidence\n",
"\n",
"Each entry carries a deterministic SHA-256 checksum chained to the previous entry. Recompute and compare to detect any after-the-fact corruption of the provenance record."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# entry_biomass was returned by track_entity in Step 1\n",
"ok = verify_checksum(entry_biomass)\n",
"print(\"Checksum verified:\", ok)\n",
"\n",
"print(\"Computed:\", compute_checksum(entry_biomass)[:16], \"...\")\n",
"print(\"Stored: \", entry_biomass.checksum[:16] if getattr(entry_biomass, 'checksum', None) else \"(see entry fields)\")\n",
"chain = prov.verify_chain()\n",
"print(\"Chain verification:\", json.dumps(chain, default=str)[:200])\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Summary\n",
"\n",
"| Need | Call |\n",
"|---|---|\n",
"| Record a fact's evidence | `prov.track_entity(entity_id, source, confidence=..., source_location=..., source_quote=...)` |\n",
"| Record a relationship | `prov.track_relationship(relationship_id, source, metadata=...)` |\n",
"| Full lineage of a fact | `prov.get_lineage(entity_id)` / `prov.trace_lineage(entity_id)` |\n",
"| \"Was it ever corrected?\" | `prov.revision_history(entity_id)` |\n",
"| \"Which sources support it?\" | `prov.get_all_sources(entity_id)` |\n",
"| Retract without deleting | `prov.invalidate(entity_id, agent_id, reason=...)` |\n",
"| Tamper check | `verify_checksum(entry)` |\n",
"\n",
"### Where to go next\n",
"\n",
"- **Conflict Detection and Resolution** (notebook 17) — what happens when two sources disagree.\n",
"- **Your First Knowledge Graph** (notebook 08) — plug `provenance=True` into extractors so tracking happens automatically during ingestion.\n",
"- The module docstring (`help(semantica.provenance)`) documents opt-in integration with `kg`, `split` and `conflicts` trackers."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+383
View File
@@ -0,0 +1,383 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "b76a5997",
"metadata": {},
"source": [
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/semantica-agi/semantica/blob/main/cookbook/introduction/23_Reasoning.ipynb)\n",
"\n",
"# Reasoning Module — Practical Guide\n",
"\n",
"Semantica's `reasoning` module derives new knowledge from existing facts and knowledge graphs. It ships several strategies behind one facade:\n",
"\n",
"- **`Reasoner`** — unified facade with forward chaining, backward chaining, and one-shot `infer_facts`\n",
"- **`DatalogReasoner`** — semi-naive Datalog fixpoint evaluation with variable queries\n",
"- **`ExplanationGenerator`** — human-readable explanations and reasoning paths for inferred conclusions\n",
"- Plus lower-level engines: `ReteEngine`, `SPARQLReasoner`, `GraphReasoner`, temporal reasoning\n",
"\n",
"This notebook walks through the facade, the Datalog engine, and explanations. All APIs are verified against `semantica/reasoning/`."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "52073af7",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:45:55.427457Z",
"iopub.status.busy": "2026-08-26T18:45:55.427247Z",
"iopub.status.idle": "2026-08-26T18:45:57.266607Z",
"shell.execute_reply": "2026-08-26T18:45:57.264783Z"
}
},
"outputs": [],
"source": [
"!pip install -q semantica"
]
},
{
"cell_type": "markdown",
"id": "06deb916",
"metadata": {},
"source": [
"## 1) Forward chaining with the `Reasoner` facade\n",
"\n",
"Facts are simple `Predicate(args)` strings. Rules use `IF <conditions> THEN <conclusion>` with `?x`-style variables. `forward_chain()` derives everything possible and returns a list of `InferenceResult` objects."
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "519ca92d",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:45:57.270791Z",
"iopub.status.busy": "2026-08-26T18:45:57.270352Z",
"iopub.status.idle": "2026-08-26T18:45:59.991941Z",
"shell.execute_reply": "2026-08-26T18:45:59.990678Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"<div style='font-family: monospace;'><h4>🧠 Semantica - 📊 Current Progress</h4><table style='width: 100%; border-collapse: collapse;'><tr><th>Status</th><th>Action</th><th>Module</th><th>Submodule</th><th>Progress</th><th>ETA</th><th>Rate</th><th>Time</th><th>Extracted</th></tr><tr><td>✅</td><td>Semantica is reasoning</td><td>🤔 reasoning</td><td>Reasoner</td><td>100.0%</td><td>-</td><td>-</td><td>0.00s</td><td>-</td></tr><tr><td>✅</td><td>Semantica is reasoning</td><td>🤔 reasoning</td><td>DatalogReasoner</td><td>100.0%</td><td>-</td><td>-</td><td>0.00s</td><td>-</td></tr><tr><td>✅</td><td>Semantica is reasoning</td><td>🤔 reasoning</td><td>ExplanationGenerator</td><td>100.0%</td><td>-</td><td>-</td><td>0.00s</td><td>-</td></tr></table></div>"
],
"text/plain": [
"<IPython.core.display.HTML object>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"🔄 Semantica is reasoning: Performing forward chaining 🤔 reasoning Reasoner |░░░░░░░░░░░░░░░| 0.0% ETA: - Rate: - Time: 0.00s Extracted: -"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"Inferred 2 new facts\n",
" Human(Jane) (rule: Rule 1, confidence: 1.0)\n",
" Human(John) (rule: Rule 1, confidence: 1.0)\n"
]
}
],
"source": [
"from semantica.reasoning import Reasoner\n",
"\n",
"reasoner = Reasoner()\n",
"\n",
"reasoner.add_fact(\"Person(John)\")\n",
"reasoner.add_fact(\"Person(Jane)\")\n",
"reasoner.add_rule(\"IF Person(?x) THEN Human(?x)\")\n",
"\n",
"results = reasoner.forward_chain()\n",
"print(f\"Inferred {len(results)} new facts\")\n",
"for res in results:\n",
" print(f\" {res.conclusion} (rule: {res.rule_used.name}, confidence: {res.confidence})\")"
]
},
{
"cell_type": "markdown",
"id": "c1131c45",
"metadata": {},
"source": [
"## 2) One-shot inference with `infer_facts`\n",
"\n",
"`infer_facts(facts, rules)` **adds** the given facts and rules to this `Reasoner` instance, runs forward chaining to fixpoint, and returns the derived facts as strings. It does not reset the instance's existing state — create a fresh `Reasoner()` first if you need isolation between runs."
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "26249990",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:45:59.995447Z",
"iopub.status.busy": "2026-08-26T18:45:59.995069Z",
"iopub.status.idle": "2026-08-26T18:46:00.004107Z",
"shell.execute_reply": "2026-08-26T18:46:00.002873Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"['Employee(Jane, Acme)', 'Employee(John, Acme)']"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from semantica.reasoning import Reasoner\n",
"\n",
"derived = Reasoner().infer_facts(\n",
" facts=[\"WorksFor(John, Acme)\", \"WorksFor(Jane, Acme)\"],\n",
" rules=[\"IF WorksFor(?x, ?y) THEN Employee(?x, ?y)\"],\n",
")\n",
"derived"
]
},
{
"cell_type": "markdown",
"id": "d5504a38",
"metadata": {},
"source": [
"## 3) Backward chaining: proving a goal\n",
"\n",
"`backward_chain(goal)` works backwards from a conclusion through the rules. It returns the `InferenceResult` that proves the goal, or `None`."
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "c4ef85dd",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:46:00.007740Z",
"iopub.status.busy": "2026-08-26T18:46:00.007346Z",
"iopub.status.idle": "2026-08-26T18:46:00.015561Z",
"shell.execute_reply": "2026-08-26T18:46:00.014145Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Human(John)\n",
"premises: ['Person(John)']\n"
]
}
],
"source": [
"from semantica.reasoning import Reasoner\n",
"\n",
"reasoner = Reasoner()\n",
"reasoner.add_fact(\"Person(John)\")\n",
"reasoner.add_rule(\"IF Person(?x) THEN Human(?x)\")\n",
"\n",
"proof = reasoner.backward_chain(\"Human(John)\")\n",
"print(proof.conclusion if proof else \"not provable\")\n",
"print(\"premises:\", proof.premises if proof else None)"
]
},
{
"cell_type": "markdown",
"id": "b245581d",
"metadata": {},
"source": [
"## 4) Re-run safety\n",
"\n",
"`add_rule` deduplicates rules with identical conditions and conclusion, so re-executing a setup cell (the common Jupyter re-run) does not duplicate rules — see issue #732."
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "fb2aeb39",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:46:00.019091Z",
"iopub.status.busy": "2026-08-26T18:46:00.018881Z",
"iopub.status.idle": "2026-08-26T18:46:00.024042Z",
"shell.execute_reply": "2026-08-26T18:46:00.022836Z"
}
},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"Skipping duplicate rule (same conditions/conclusion as 'rule_1'): IF Person(?x) THEN Human(?x)\n"
]
},
{
"data": {
"text/plain": [
"1"
]
},
"execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from semantica.reasoning import Reasoner\n",
"\n",
"reasoner = Reasoner()\n",
"reasoner.add_fact(\"Person(John)\")\n",
"\n",
"# Simulate a Jupyter cell re-run: add the same rule twice\n",
"r1 = reasoner.add_rule(\"IF Person(?x) THEN Human(?x)\")\n",
"r2 = reasoner.add_rule(\"IF Person(?x) THEN Human(?x)\")\n",
"\n",
"len(reasoner.rules)"
]
},
{
"cell_type": "markdown",
"id": "ba2e5c4a",
"metadata": {},
"source": [
"## 5) Datalog reasoning\n",
"\n",
"`DatalogReasoner` uses classic Datalog syntax (`head :- body.`) and semi-naive fixpoint evaluation. Queries return variable bindings as a list of dicts — use uppercase variables to ask *which* facts hold."
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "9ec5c0c4",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:46:00.026769Z",
"iopub.status.busy": "2026-08-26T18:46:00.026588Z",
"iopub.status.idle": "2026-08-26T18:46:00.034963Z",
"shell.execute_reply": "2026-08-26T18:46:00.032672Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"[{'X': 'tom', 'Z': 'ann'}]"
]
},
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from semantica.reasoning import DatalogReasoner\n",
"\n",
"datalog = DatalogReasoner()\n",
"datalog.add_fact(\"parent(tom, mary)\")\n",
"datalog.add_fact(\"parent(mary, ann)\")\n",
"datalog.add_rule(\"grandparent(X, Z) :- parent(X, Y), parent(Y, Z)\")\n",
"\n",
"datalog.derive_all()\n",
"datalog.query(\"grandparent(X, Z)\")"
]
},
{
"cell_type": "markdown",
"id": "d4f0689b",
"metadata": {},
"source": [
"## 6) Explanations for inferred conclusions\n",
"\n",
"`ExplanationGenerator` turns `InferenceResult` objects into structured `Explanation` and `ReasoningPath` records, so agents can show *why* they believe a derived fact."
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "19dcd3a7",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:46:00.038649Z",
"iopub.status.busy": "2026-08-26T18:46:00.038396Z",
"iopub.status.idle": "2026-08-26T18:46:00.059188Z",
"shell.execute_reply": "2026-08-26T18:46:00.057805Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"('Explanation', 'ReasoningPath')"
]
},
"execution_count": 7,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from semantica.reasoning import Reasoner, ExplanationGenerator\n",
"\n",
"reasoner = Reasoner()\n",
"reasoner.add_fact(\"Person(John)\")\n",
"reasoner.add_rule(\"IF Person(?x) THEN Human(?x)\")\n",
"results = reasoner.forward_chain()\n",
"\n",
"gen = ExplanationGenerator()\n",
"explanation = gen.generate_explanation(results[0])\n",
"path = gen.show_reasoning_path(results[0])\n",
"\n",
"type(explanation).__name__, type(path).__name__"
]
},
{
"cell_type": "markdown",
"id": "fb882ee4",
"metadata": {},
"source": [
"## Summary\n",
"\n",
"| Task | API |\n",
"|---|---|\n",
"| Derive all new facts | `Reasoner.forward_chain()` |\n",
"| One-shot inference | `Reasoner.infer_facts(facts, rules)` |\n",
"| Prove a goal | `Reasoner.backward_chain(goal)` |\n",
"| Datalog fixpoint | `DatalogReasoner.derive_all()` + `query(\"p(X, Y)\")` |\n",
"| Explain a conclusion | `ExplanationGenerator.generate_explanation(result)` |\n",
"\n",
"See also `semantica/reasoning/reasoning_usage.md` and the module docstrings for `ReteEngine`, `SPARQLReasoner`, and temporal reasoning."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.12"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
@@ -0,0 +1,299 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "8d7096ea",
"metadata": {},
"source": [
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/semantica-agi/semantica/blob/main/cookbook/introduction/24_Change_Management.ipynb)\n",
"\n",
"# Change Management — Practical Guide\n",
"\n",
"Semantica's `change_management` module provides versioning, audit trails, and data-integrity checks for knowledge graphs and ontologies:\n",
"\n",
"- **`ChangeLogEntry`** — standardized change metadata (validated timestamp/author)\n",
"- **`InMemoryVersionStorage` / `SQLiteVersionStorage`** — version snapshot storage with named tags\n",
"- **`compute_checksum` / `verify_checksum`** — SHA-256 integrity verification\n",
"\n",
"This notebook runs a complete save → tag → verify → tamper-detect cycle. All outputs are real executed results verified against the repository's `semantica/change_management/` source at the time of writing (the `pip install` cell may fetch a newer release with slightly different behavior)."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "7bdffec1",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:46:37.171333Z",
"iopub.status.busy": "2026-08-26T18:46:37.171183Z",
"iopub.status.idle": "2026-08-26T18:46:39.060860Z",
"shell.execute_reply": "2026-08-26T18:46:39.059594Z"
}
},
"outputs": [],
"source": [
"!pip install -q semantica"
]
},
{
"cell_type": "markdown",
"id": "169efee1",
"metadata": {},
"source": [
"## 1) A `ChangeLogEntry` records *who* changed *what*, *when*\n",
"\n",
"`author` must be a valid email — the dataclass validates on construction (`ValidationError` otherwise), which keeps audit trails clean."
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "5b17acdb",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:46:39.064077Z",
"iopub.status.busy": "2026-08-26T18:46:39.063818Z",
"iopub.status.idle": "2026-08-26T18:46:39.321881Z",
"shell.execute_reply": "2026-08-26T18:46:39.321036Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"ChangeLogEntry(timestamp='2026-08-15T09:00:00Z', author='demo@example.com', description='initial version', change_id=None, related_changes=[])"
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from semantica.change_management import ChangeLogEntry\n",
"\n",
"entry = ChangeLogEntry(\n",
" timestamp=\"2026-08-15T09:00:00Z\",\n",
" author=\"demo@example.com\",\n",
" description=\"initial version\",\n",
")\n",
"entry"
]
},
{
"cell_type": "markdown",
"id": "53d8df5c",
"metadata": {},
"source": [
"## 2) Save a versioned snapshot\n",
"\n",
"A snapshot is a dict with a required `label` plus your payload. Here we attach the KG data, the change log, and a SHA-256 `checksum` computed over everything except the checksum field itself."
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "fec16f24",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:46:39.325528Z",
"iopub.status.busy": "2026-08-26T18:46:39.325140Z",
"iopub.status.idle": "2026-08-26T18:46:39.331480Z",
"shell.execute_reply": "2026-08-26T18:46:39.330586Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"True"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from semantica.change_management import InMemoryVersionStorage, compute_checksum\n",
"\n",
"storage = InMemoryVersionStorage()\n",
"\n",
"snapshot = {\n",
" \"label\": \"v1.0.0\",\n",
" \"data\": {\"entities\": {\"acme\": {\"type\": \"Company\"}}},\n",
" \"change_log\": {\n",
" \"timestamp\": entry.timestamp,\n",
" \"author\": entry.author,\n",
" \"description\": entry.description,\n",
" },\n",
"}\n",
"snapshot[\"checksum\"] = compute_checksum({k: v for k, v in snapshot.items() if k != \"checksum\"})\n",
"\n",
"storage.save(snapshot)\n",
"storage.exists(\"v1.0.0\")"
]
},
{
"cell_type": "markdown",
"id": "0f1c603b",
"metadata": {},
"source": [
"## 3) Named tags pin a version for releases\n",
"\n",
"`save_tag` / `get_tag` map stable names (e.g. `release`) to version labels, decoupling consumers from label churn."
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "62f7643e",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:46:39.335182Z",
"iopub.status.busy": "2026-08-26T18:46:39.334886Z",
"iopub.status.idle": "2026-08-26T18:46:39.339586Z",
"shell.execute_reply": "2026-08-26T18:46:39.338568Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"('v1.0.0', ['v1.0.0'])"
]
},
"execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"storage.save_tag(\"release\", \"v1.0.0\")\n",
"\n",
"storage.get_tag(\"release\"), [s[\"label\"] for s in storage.list_all()]"
]
},
{
"cell_type": "markdown",
"id": "96df12da",
"metadata": {},
"source": [
"## 4) Verify integrity — and catch tampering\n",
"\n",
"`verify_checksum(snapshot)` recomputes the SHA-256 over the snapshot (minus its `checksum` field) and compares. A single mutated character in the data flips the result to `False`."
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "26d0de85",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:46:39.342653Z",
"iopub.status.busy": "2026-08-26T18:46:39.342466Z",
"iopub.status.idle": "2026-08-26T18:46:39.346714Z",
"shell.execute_reply": "2026-08-26T18:46:39.345623Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"intact: True\n",
"tampered: False\n"
]
}
],
"source": [
"from semantica.change_management import verify_checksum\n",
"\n",
"stored = storage.get(\"v1.0.0\")\n",
"print(\"intact:\", verify_checksum(stored))\n",
"\n",
"tampered = storage.get(\"v1.0.0\")\n",
"tampered[\"data\"][\"entities\"][\"acme\"][\"note\"] = \"mutated after the fact\"\n",
"print(\"tampered:\", verify_checksum(tampered))"
]
},
{
"cell_type": "markdown",
"id": "bd14c3e4",
"metadata": {},
"source": [
"## 5) Retiring a version\n",
"\n",
"`delete(label)` removes a snapshot; tags pointing at it are your responsibility to update."
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "de9fe3e5",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:46:39.349814Z",
"iopub.status.busy": "2026-08-26T18:46:39.349513Z",
"iopub.status.idle": "2026-08-26T18:46:39.354710Z",
"shell.execute_reply": "2026-08-26T18:46:39.353669Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"False"
]
},
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"storage.delete(\"v1.0.0\")\n",
"storage.exists(\"v1.0.0\")"
]
},
{
"cell_type": "markdown",
"id": "ab667b32",
"metadata": {},
"source": [
"## Summary\n",
"\n",
"| Task | API |\n",
"|---|---|\n",
"| Record audit metadata | `ChangeLogEntry(timestamp, author=email, description)` |\n",
"| Persist a version | `InMemoryVersionStorage().save({\"label\": ..., ...})` |\n",
"| Pin a release name | `save_tag(\"release\", \"v1.0.0\")` / `get_tag(\"release\")` |\n",
"| Integrity check | `compute_checksum(snap)` / `verify_checksum(snap)` |\n",
"| Persistent backend | `SQLiteVersionStorage(path)` — same interface |\n",
"\n",
"See also `semantica/change_management/change_management_usage.md` for the manager classes (`TemporalVersionManager`, `OntologyVersionManager`)."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.12"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+314
View File
@@ -0,0 +1,314 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "6eb4dfba",
"metadata": {},
"source": [
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/semantica-agi/semantica/blob/main/cookbook/introduction/25_Seed_Data.ipynb)\n",
"\n",
"# Seed Data — Practical Guide\n",
"\n",
"The `seed` module bootstraps a knowledge graph from **trusted, pre-known data** (CSV/JSON/database/API sources) before any extraction runs. This gives extraction a foundation to link against instead of starting from an empty graph.\n",
"\n",
"Key pieces:\n",
"\n",
"- **`SeedDataManager`** — registers data sources and builds foundation graphs\n",
"- **`create_foundation_graph()`** — turns registered sources into `entities` + `relationships` + `metadata`\n",
"- **`validate_quality()`** — checks a foundation graph before you commit it\n",
"\n",
"All examples below were executed against `semantica/seed/seed_manager.py`."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "32f80cc6",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:51:18.716466Z",
"iopub.status.busy": "2026-08-26T18:51:18.716264Z",
"iopub.status.idle": "2026-08-26T18:51:20.533828Z",
"shell.execute_reply": "2026-08-26T18:51:20.531402Z"
}
},
"outputs": [],
"source": [
"!pip install -q semantica"
]
},
{
"cell_type": "markdown",
"id": "75136e5f",
"metadata": {},
"source": [
"## 1) Prepare a seed CSV and register the source\n",
"\n",
"`register_source(name, format, location, entity_type=...)` records where trusted data lives. `verified=True` (the default) marks the source as pre-validated."
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "a8089e1f",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:51:20.538772Z",
"iopub.status.busy": "2026-08-26T18:51:20.538323Z",
"iopub.status.idle": "2026-08-26T18:51:20.675403Z",
"shell.execute_reply": "2026-08-26T18:51:20.674060Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"True"
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import csv\n",
"import tempfile\n",
"from pathlib import Path\n",
"from semantica.seed import SeedDataManager\n",
"\n",
"# Write the sample CSV into a session-scoped temp directory so we never\n",
"# clobber a companies.csv that might exist in the user's working directory.\n",
"seed_csv = Path(tempfile.mkdtemp(prefix=\"semantica-seed-\")) / \"companies.csv\"\n",
"with open(seed_csv, \"w\", newline=\"\") as f:\n",
" writer = csv.DictWriter(f, fieldnames=[\"id\", \"name\", \"type\", \"industry\"])\n",
" writer.writeheader()\n",
" writer.writerow({\"id\": \"c1\", \"name\": \"Acme\", \"type\": \"Company\", \"industry\": \"robotics\"})\n",
" writer.writerow({\"id\": \"c2\", \"name\": \"Globex\", \"type\": \"Company\", \"industry\": \"energy\"})\n",
"\n",
"manager = SeedDataManager()\n",
"manager.register_source(\"companies\", format=\"csv\", location=str(seed_csv), entity_type=\"Company\")\n"
]
},
{
"cell_type": "markdown",
"id": "e87221ba",
"metadata": {},
"source": [
"## 2) Load records from a registered source\n",
"\n",
"`load_source(name)` reads the source and enriches each record with `entity_type` and `source` provenance keys."
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "f932e550",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:51:20.679424Z",
"iopub.status.busy": "2026-08-26T18:51:20.679156Z",
"iopub.status.idle": "2026-08-26T18:51:20.690659Z",
"shell.execute_reply": "2026-08-26T18:51:20.688812Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"<div style='font-family: monospace;'><h4>🧠 Semantica - 📊 Current Progress</h4><table style='width: 100%; border-collapse: collapse;'><tr><th>Status</th><th>Action</th><th>Module</th><th>Submodule</th><th>Progress</th><th>ETA</th><th>Rate</th><th>Time</th><th>Extracted</th></tr><tr><td>✅</td><td>Semantica is seeding</td><td>🌱 seed</td><td>SeedDataManager</td><td>100.0%</td><td>-</td><td>-</td><td>0.00s</td><td>-</td></tr></table></div>"
],
"text/plain": [
"<IPython.core.display.HTML object>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"🔄 Semantica is seeding: Loading seed data from CSV: /var/folders/7s/bvvstgs10y963tz6_4bbnklr0000gn/T/semantica-seed-eu9__ep1/companies.csv 🌱 seed SeedDataManager |░░░░░░░░░░░░░░░| 0.0% ETA: - Rate: - Time: 0.00s Extracted: -"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"loaded 2 records\n"
]
},
{
"data": {
"text/plain": [
"{'id': 'c1',\n",
" 'name': 'Acme',\n",
" 'type': 'Company',\n",
" 'industry': 'robotics',\n",
" 'entity_type': 'Company',\n",
" 'source': 'companies'}"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"records = manager.load_source(\"companies\")\n",
"print(f\"loaded {len(records)} records\")\n",
"records[0]"
]
},
{
"cell_type": "markdown",
"id": "f2ebce64",
"metadata": {},
"source": [
"## 3) Build the foundation graph\n",
"\n",
"`create_foundation_graph()` converts every registered source into graph-ready entities and relationships. Entities carry `confidence: 1.0` — seed data is trusted by definition."
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "09388c31",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:51:20.695259Z",
"iopub.status.busy": "2026-08-26T18:51:20.694928Z",
"iopub.status.idle": "2026-08-26T18:51:20.708595Z",
"shell.execute_reply": "2026-08-26T18:51:20.707072Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"['entities', 'metadata', 'relationships']"
]
},
"execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"foundation = manager.create_foundation_graph()\n",
"sorted(foundation.keys())"
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "4610a59f",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:51:20.713136Z",
"iopub.status.busy": "2026-08-26T18:51:20.712795Z",
"iopub.status.idle": "2026-08-26T18:51:20.718637Z",
"shell.execute_reply": "2026-08-26T18:51:20.716835Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"{'id': 'c1',\n",
" 'text': 'Acme',\n",
" 'type': 'Company',\n",
" 'confidence': 1.0,\n",
" 'metadata': {'industry': 'robotics', 'source': 'companies'}}"
]
},
"execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"foundation[\"entities\"][0]"
]
},
{
"cell_type": "markdown",
"id": "f3a52dc7",
"metadata": {},
"source": [
"## 4) Validate quality before committing\n",
"\n",
"`validate_quality(foundation_graph)` returns `valid`, `errors`, `warnings`, and `metrics` so you can gate bad seed data before it pollutes the graph."
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "4eb7e664",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-26T18:51:20.722674Z",
"iopub.status.busy": "2026-08-26T18:51:20.722118Z",
"iopub.status.idle": "2026-08-26T18:51:20.732003Z",
"shell.execute_reply": "2026-08-26T18:51:20.730170Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"True"
]
},
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"quality = manager.validate_quality(foundation)\n",
"quality[\"valid\"]"
]
},
{
"cell_type": "markdown",
"id": "b534be89",
"metadata": {},
"source": [
"## Summary\n",
"\n",
"| Task | API |\n",
"|---|---|\n",
"| Register a trusted source | `register_source(name, format, location, entity_type=...)` |\n",
"| Load records | `load_source(name)` — adds `entity_type` / `source` keys |\n",
"| Direct file load | `load_from_csv(path)` / `load_from_json(path)` |\n",
"| Build the graph | `create_foundation_graph()` → `entities` / `relationships` / `metadata` |\n",
"| Gate bad data | `validate_quality(graph)` → `valid` / `errors` / `warnings` / `metrics` |\n",
"\n",
"See also `semantica/seed/seed_usage.md` for `load_from_database`, `load_from_api`, and `integrate_with_extracted`."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.12"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+4
View File
@@ -35,6 +35,7 @@ Essential guides to master the Semantica framework.
- **[Vector Store](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/13_Vector_Store.ipynb)** — Setting up vector stores for similarity search and retrieval. *Intermediate*
- **[Graph Store](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/09_Graph_Store.ipynb)** — Persisting knowledge graphs in Neo4j or FalkorDB. Topics: Neo4j, Cypher, Persistence · *Intermediate*
- **[Ontology](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/14_Ontology.ipynb)** — Defining domain schemas and ontologies to structure your data. Topics: OWL, RDF, Schema Design · *Intermediate*
- **[Seed Data](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/25_Seed_Data.ipynb)** — Bootstrapping a knowledge graph from trusted CSV, JSON, database, and API sources before extraction runs. Topics: SeedDataManager, Foundation Graphs · *Intermediate*
## Advanced Concepts
@@ -50,6 +51,9 @@ Deep dive into advanced features, customization, and complex workflows.
- **[Multi-Source Integration](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/06_Multi_Source_Data_Integration.ipynb)** — Merging data from disparate sources into a unified graph. Topics: Entity Resolution, Merging, Fusion · *Advanced*
- **[Reasoning and Inference](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/08_Reasoning_and_Inference.ipynb)** — Using logical reasoning to infer new knowledge from existing facts. Topics: Logic Rules, Inference Engines · *Advanced*
- **[Temporal Knowledge Graphs](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/10_Temporal_Knowledge_Graphs.ipynb)** — Modeling and querying data that changes over time. Topics: Time Series, Temporal Logic, Allen Algebra · *Advanced*
- **[Provenance Tracking](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/22_Provenance_Tracking.ipynb)** — Audit-grade, W3C PROV-O-aligned tracking of where every entity, relationship, and chunk came from. Topics: PROV-O, Lineage, Checksums, Invalidation · *Advanced*
- **[Reasoning Module](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/23_Reasoning.ipynb)** — Deriving new knowledge from existing facts with forward chaining, backward chaining, and Datalog strategies. Topics: Reasoner, Datalog, Explanations · *Advanced*
- **[Change Management](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/24_Change_Management.ipynb)** — Versioning, audit trails, and data-integrity checks for knowledge graphs and ontologies. Topics: ChangeLogEntry, Version Storage, Data Integrity · *Advanced*
## How to Run
+3 -1
View File
@@ -103,9 +103,11 @@
"pages": [
"integrations/agno",
"integrations/crewai",
"integrations/langchain",
"integrations/docling",
"integrations/snowflake",
"integrations/databricks"
"integrations/databricks",
"integrations/salesforce"
]
},
{
+1 -1
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.7** (August 2026) |
| Local LLMs? | Yes: Ollama via LiteLLM, HuggingFaceLLM for air-gapped |
+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.7
```
</Check>
</Step>
+39
View File
@@ -382,6 +382,45 @@ For authentication details (PAT vs. OAuth M2M for Databricks; password vs. key-p
> **Security Note:** Never hardcode credentials (`token`, `password`, `private_key`) in production code; pass them via environment variables (e.g., `DATABRICKS_TOKEN`, `SNOWFLAKE_PASSWORD`) or a secrets manager.
## Source 7 — SAP OData
`SAPIngestor` ingests an Entity Set from a SAP OData service (S/4HANA Cloud, SuccessFactors, or an on-prem NetWeaver Gateway over its REST surface). It speaks OData v2 and v4, follows server-driven pagination automatically, and flattens each record into a document dict via `export_as_documents()` — the same structured "transform to text, then store" pattern as the other sources.
```python
from semantica.ingest import SAPIngestor
ing = SAPIngestor(
base_url="https://my-sap.example.com/sap/opu/odata/sap/API_BUSINESS_PARTNER",
client_id="...", client_secret="...",
token_url="https://my-sap.example.com/oauth/token", # OAuth2 client-credentials (BTP/S/4HANA Cloud)
# On-prem NetWeaver often uses Basic auth instead — swap the block above for:
# username="erp_user", password="...",
)
# 1. Discover an unfamiliar service: entity sets + field types from $metadata
sets = ing.discover_service() # [{"name": "A_BusinessPartnerSet", "fields": [...]}, ...]
# 2. Pull a page-walked Entity Set (v2/v4 next links handled for you)
partners = ing.ingest_entity_set(
entity_set="A_BusinessPartnerSet",
select="BusinessPartner,BusinessPartnerFullName", # $select
top=1000, # cap on total rows
)
# 3. Flatten to document dicts, then build text for the graph
docs = ing.export_as_documents(partners)
partner_texts = [
f"Business Partner {d['BusinessPartner']}: {d['BusinessPartnerFullName']}"
for d in docs
]
```
- Use `expand="to_Item"` (e.g. on a sales-order header set) to pull nested line items in one request — handy for modeling order → line-item → material relationships.
- Every outbound request, including the OAuth2 token exchange, is routed through the SSRF guard, so a user-supplied SAP URL can never reach private/loopback/link-local address space.
- Install with `pip install 'semantica[ingest-sap]'`.
> **Security Note:** Never hardcode credentials (`client_secret`, `password`) in code; pass them via environment variables (e.g., `SAP_CLIENT_SECRET`, `SAP_PASSWORD`) or a secrets manager.
## Combining All Five Sources
Once you have text from each source, `AgentContext.store()` accepts a flat list of strings. Semantica embeds and indexes them together — the context graph has no concept of which string came from which source unless you add metadata explicitly.
+16 -5
View File
@@ -222,10 +222,10 @@ builder.register_step_handler("ner_extract", run_ner)
builder.register_step_handler("triplet_extract", run_triplets)
builder.register_step_handler("kg_merge", merge_into_graph)
builder.add_step("ingest", "file_ingest", handler=ingest_stix_bundles, path="./stix_bundles/")
builder.add_step("ner", "ner_extract", handler=run_ner, confidence_threshold=0.75)
builder.add_step("triplets", "triplet_extract", handler=run_triplets, include_temporal=True)
builder.add_step("store", "kg_merge", handler=merge_into_graph, output_path="./cti_output/")
builder.add_step("ingest", "file_ingest", path="./stix_bundles/")
builder.add_step("ner", "ner_extract", confidence_threshold=0.75)
builder.add_step("triplets", "triplet_extract", include_temporal=True)
builder.add_step("store", "kg_merge", output_path="./cti_output/")
# ingest feeds both ner and triplets in parallel
builder.connect_steps("ingest", "ner")
@@ -241,7 +241,18 @@ engine = ExecutionEngine(max_workers=2, retry_on_failure=True)
result = engine.execute_pipeline(pipeline)
```
`set_parallelism(n)` tells the engine how many steps it may run simultaneously. The topological sort guarantees that only steps whose dependencies are all completed are eligible for concurrent execution — you cannot accidentally run a step before its inputs are ready.
`set_parallelism(n)` tells the engine how many steps it may run simultaneously; `n` must be a positive integer. The topological sort guarantees that only steps whose dependencies are all completed are eligible for concurrent execution — you cannot accidentally run a step before its inputs are ready. The effective concurrency is capped at `min(n, max_workers)`, so the engine's `max_workers` setting remains a hard resource ceiling.
Concurrency is opt-in per step. A dependency layer only runs in parallel when every step in that layer is marked `parallel_safe`, the layer has more than one step, and the data flowing into the layer is a dict:
```python
builder.add_step("ner", "ner_extract", parallel_safe=True, confidence_threshold=0.75)
builder.add_step("triplets", "triplet_extract", parallel_safe=True, include_temporal=True)
```
If any step in a layer is not marked `parallel_safe`, or if a step runs in delta mode, the entire layer falls back to sequential execution — parallelism never silently bypasses a step that was not declared safe. `parallel_safe` is a control field: like `dependencies`, it is consumed by the builder and never reaches your handler's config.
Parallel-safe handlers must return a dict. Each step in a parallel layer receives an isolated deep copy of the layer's input, so steps cannot see each other's mutations. The per-step results are merged key by key in step declaration order: a key written by one step is added to the merged output, a key written by several steps with equal values is kept, and two steps writing different values for the same key fail the pipeline with a `ProcessingError` naming the conflicting key and both steps. Handlers that touch shared mutable resources — database connections, in-memory stores, global caches — should not be marked `parallel_safe`.
## Common Pitfalls
+1 -1
View File
@@ -639,7 +639,7 @@ Every `ProvenanceEntry` maps directly to W3C PROV-O terms. If your compliance te
| — | `previous_version_id` | This entry corrects/replaces a prior version of the *same* fact |
| `prov:wasDerivedFrom` | `derived_from_id` | This entry was derived from a *different* source entity |
| `prov:used` | `used_entities` | Entity IDs consumed to produce this one |
| `prov:generatedAtTime` | `timestamp` | ISO datetime, auto-set to `datetime.utcnow()` at write time |
| `prov:generatedAtTime` | `timestamp` | ISO datetime, auto-set to `utc_now_iso()` at write time |
| `prov:qualifiedInvalidation` | `invalidated`, `invalidated_at_time`, `invalidated_by`, `invalidation_reason` | A retraction/correction recorded as a tombstone via `ProvenanceManager.invalidate()`, never a hard delete |
| `prov:startedAtTime` / `prov:endedAtTime` | `activity_started_at_time`, `activity_ended_at_time` | Typed Activity timing — pass an `ActivityRecord` via the `activity=` kwarg to set these together with `activity_id` |
| `prov:qualifiedGeneration`/`Generation`, `qualifiedUsage`/`Usage`, `qualifiedDerivation`/`Derivation` | (derived from the fields above) | Additive qualified forms of `wasGeneratedBy`/`used`/`wasDerivedFrom`, emitted automatically alongside the plain triples |
+13
View File
@@ -150,6 +150,12 @@ HighRiskSupplier(DELTA-3) conf=100% rule=Rule 3
DELTA-3 is flagged even though no document described it that way — the system traced: DELTA-3 supplied GAMMA-7, and GAMMA-7 exploits critical CVEs. For rules that need priority ordering or graded confidence, use the `Rule` dataclass:
If a rule has side-effecting actions, one concrete activation runs those
actions at most once on a Reasoner instance. Re-running `forward_chain()` is
therefore safe: already-attempted actions are not repeated. Use
`reasoner.reset_action_history()` when you intentionally want to replay them;
`reasoner.clear()` and `reasoner.reset()` also clear the history.
```python
# Higher priority rules fire first; confidence propagates into InferenceResult.confidence
reasoner.add_rule(Rule(
@@ -360,6 +366,13 @@ engine.reset()
The rule network is compiled once by `build_network()`. Each subsequent `add_fact()` call propagates incrementally through only the nodes whose conditions it satisfies — not the full rule set — which keeps evaluation cost proportional to the number of new activations rather than the total rule count.
With a Reasoner bound, Rete action side effects are attempted once per rule,
bindings, and matched fact identity. Passing the same match to
`execute_matches()` again still returns the same conclusion, but does not repeat
its actions. Call `engine.reset_action_history()` to replay actions without
clearing working memory. `engine.reset()` and `engine.build_network()` also
clear the action history.
## Step 7 — Temporal interval reasoning
`TemporalReasoningEngine` computes Allen interval relations between time windows, letting you identify whether two events overlap, one contains the other, they meet at a boundary, and so on across your graph:
+81
View File
@@ -0,0 +1,81 @@
---
title: "LangChain Integration"
description: "Drop Semantica into LangChain / LangGraph pipelines via a GraphRAG retriever, VectorStore adapter, and agent tools."
icon: "link"
---
> Three drop-in adapters that bring Semantica's context graph and hybrid search into LangChain chains and LangGraph agents.
## Installation
```bash
pip install "semantica[langchain]"
```
Requires `langchain-core >= 0.3`. If langchain-core is not installed, the integration still imports — every class carries the full Semantica API and degrades gracefully (`build()` returns `None`; branch on `LANGCHAIN_AVAILABLE`).
## Components at a Glance
- **SemanticaRetriever** — `BaseRetriever`: hybrid-search seeds retrieval, then graph edges are walked `hops` steps (default 2) for GraphRAG-style results.
- **SemanticaVectorStore** — `VectorStore`: `add_texts` / `similarity_search` / `similarity_search_with_score` / `from_texts` over `HybridSearch`.
- **SemanticaKGTool** / **SemanticaDecisionTool**`BaseTool` subclasses: `semantica_query_graph` and `semantica_query_decisions` for LangGraph / tool-calling agents.
## Component Details
<Tabs>
<Tab title="SemanticaRetriever">
Hybrid search seeds retrieval; then graph edges are walked `hops` steps so results go beyond flat vector similarity. If hybrid search is omitted or fails, the retriever falls back to a `ContextGraph.query` keyword scan.
```python
from integrations.langchain import SemanticaRetriever
from semantica.context import ContextGraph
from semantica.vector_store import HybridSearch
graph = ContextGraph()
hybrid = HybridSearch()
retriever = SemanticaRetriever(graph=graph, hybrid=hybrid, hops=2, top_k=10)
from langchain.chains import RetrievalQA
qa = RetrievalQA.from_chain_type(llm=llm, retriever=retriever)
```
</Tab>
<Tab title="SemanticaVectorStore">
Drop-in `VectorStore` for RetrievalQA / LCEL chains. `from_texts` requires a pre-configured `hybrid` instance.
```python
from integrations.langchain import SemanticaVectorStore
store = SemanticaVectorStore(hybrid=hybrid)
store.add_texts(
["document one", "document two"],
metadatas=[{"source": "a"}, {"source": "b"}],
)
docs = store.similarity_search("document", k=2)
docs, scores = store.similarity_search_with_score("document", k=2)
```
`add_texts` delegates to a Semantica vector store with `add_documents` (pass `vector_store=` to `HybridSearch` or to `SemanticaVectorStore`).
</Tab>
<Tab title="Agent tools">
Instances are LangChain `BaseTool`s and can be passed to an agent directly.
`.build()` returns the tool, or `None` when langchain-core is absent.
```python
from integrations.langchain import SemanticaKGTool, SemanticaDecisionTool
from langgraph.prebuilt import create_react_agent
tools = [
SemanticaKGTool(graph),
SemanticaDecisionTool(graph),
]
agent = create_react_agent(model, tools)
```
| Tool | Description |
| :------ | :------------- |
| `semantica_query_graph` | Keyword / NL query over the shared context graph |
| `semantica_query_decisions` | Search the recorded decision log |
</Tab>
</Tabs>
+376
View File
@@ -0,0 +1,376 @@
---
title: "Salesforce Integration"
description: "Ingest CRM records from Salesforce sObjects and SOQL queries into Semantica's KG pipeline."
icon: "cloud"
---
> Extract Accounts, Contacts, Opportunities, and custom objects from Salesforce into Semantica with username/password/security-token, JWT bearer, or session-based authentication.
## Installation
```bash
# Install with Salesforce support
pip install "semantica[db-salesforce]"
# Or install the connector separately
pip install simple-salesforce>=1.12.0
```
## Basic Usage
```python
from semantica.ingest import SalesforceIngestor
import os
ingestor = SalesforceIngestor(
username=os.getenv("SALESFORCE_USERNAME"),
password=os.getenv("SALESFORCE_PASSWORD"),
security_token=os.getenv("SALESFORCE_SECURITY_TOKEN"),
domain=os.getenv("SALESFORCE_DOMAIN", "login"), # "test" for sandbox
)
data = ingestor.ingest_sobject("Account", fields=["Id", "Name", "Industry"], limit=1000)
print(f"Retrieved {data.row_count} of {data.total_size} matching records")
print(f"Columns: {data.columns}")
```
<Tip>
Use environment variables (or a `.env` file with `python-dotenv`) to keep credentials out of source code. `SalesforceIngestor()` with no arguments reads from `SALESFORCE_*` environment variables automatically.
</Tip>
## Authentication Methods
<Tabs>
<Tab title="Username / Password / Security Token">
```python
import os
from semantica.ingest import SalesforceIngestor
ingestor = SalesforceIngestor(
username=os.getenv("SALESFORCE_USERNAME"),
password=os.getenv("SALESFORCE_PASSWORD"),
security_token=os.getenv("SALESFORCE_SECURITY_TOKEN"),
domain="login", # production; use "test" for sandbox
)
```
Set the required environment variables before running:
```bash
export SALESFORCE_USERNAME="your-username@example.com"
export SALESFORCE_PASSWORD="your-password"
export SALESFORCE_SECURITY_TOKEN="your-security-token"
```
The standard server-side flow. The security token is appended to the
password during Salesforce SOAP login. Generate or reset it under
**Settings → My Personal Information → Reset My Security Token**.
</Tab>
<Tab title="JWT Bearer (Recommended for CI/CD)">
```python
import os
from semantica.ingest import SalesforceIngestor
ingestor = SalesforceIngestor(
username=os.getenv("SALESFORCE_USERNAME"),
consumer_key=os.getenv("SALESFORCE_CONSUMER_KEY"),
privatekey_file=os.getenv("SALESFORCE_PRIVATE_KEY_FILE"),
domain="login", # or "test" for sandbox
)
```
```bash
export SALESFORCE_USERNAME="your-username@example.com"
export SALESFORCE_CONSUMER_KEY="your-connected-app-consumer-key"
export SALESFORCE_PRIVATE_KEY_FILE="/path/to/server.key"
```
The JWT bearer flow authenticates with a signed token — no password
is transmitted. Ideal for server-to-server integrations and CI/CD
pipelines. Requires a Salesforce connected app configured with
**Use digital signatures** and the pre-authorised user listed under
**Manage → Profiles / Permission Sets**.
If you prefer to pass the key material as a string instead of a file
path, use `SALESFORCE_PRIVATE_KEY` (the PEM contents) in place of
`SALESFORCE_PRIVATE_KEY_FILE`.
</Tab>
<Tab title="Session ID + Instance URL">
```python
ingestor = SalesforceIngestor(
session_id=os.getenv("SALESFORCE_SESSION_ID"),
instance_url=os.getenv("SALESFORCE_INSTANCE_URL"),
)
```
Use this when your environment already manages the OAuth token
lifecycle (e.g. a connected app obtaining tokens via the web-server
or device flow). Pass the access token as `session_id` and the full
instance URL (e.g. `https://myorg.my.salesforce.com`) as
`instance_url`.
</Tab>
<Tab title="Sandbox">
```python
import os
from semantica.ingest import SalesforceIngestor
ingestor = SalesforceIngestor(
username=os.getenv("SALESFORCE_USERNAME"),
password=os.getenv("SALESFORCE_PASSWORD"),
security_token=os.getenv("SALESFORCE_SECURITY_TOKEN"),
domain="test", # routes to test.salesforce.com
)
```
```bash
export SALESFORCE_USERNAME="your-sandbox-username@example.com.sandbox"
export SALESFORCE_PASSWORD="your-password"
export SALESFORCE_SECURITY_TOKEN="your-security-token"
export SALESFORCE_DOMAIN="test"
```
Replace `domain="login"` with `domain="test"` (or set
`SALESFORCE_DOMAIN=test` in your environment) to connect to a
developer or full sandbox.
</Tab>
</Tabs>
### Environment variables
All constructor parameters have environment-variable fallbacks:
| Variable | Parameter | Default |
|---|---|---|
| `SALESFORCE_USERNAME` | `username` | — |
| `SALESFORCE_PASSWORD` | `password` | — |
| `SALESFORCE_SECURITY_TOKEN` | `security_token` | — |
| `SALESFORCE_DOMAIN` | `domain` | `"login"` |
| `SALESFORCE_INSTANCE_URL` | `instance_url` | — |
| `SALESFORCE_SESSION_ID` | `session_id` | — |
| `SALESFORCE_CONSUMER_KEY` | `consumer_key` | — |
| `SALESFORCE_PRIVATE_KEY_FILE` | `privatekey_file` | — |
| `SALESFORCE_PRIVATE_KEY` | `privatekey` | — |
| `SALESFORCE_API_VERSION` | `api_version` | library default (`59.0`) |
## Object Ingestion
### Ingest a standard object
```python
data = ingestor.ingest_sobject(
"Account",
fields=["Id", "Name", "Industry", "AnnualRevenue", "BillingCity"],
where="Type = 'Customer' AND AnnualRevenue > 1000000",
order_by="Name ASC",
limit=5000,
)
print(f"Retrieved {data.row_count} of {data.total_size} matching records")
```
<Note>
`data.row_count` is the number of records in `data.data` (i.e. what was actually returned after any `limit`). `data.total_size` is Salesforce's `totalSize` — the number of records matching the query *before* the limit. Compare them to know whether you got all results.
</Note>
### Ingest a custom object
Custom objects end with `__c` in their API name:
```python
data = ingestor.ingest_sobject(
"My_Custom_Object__c",
fields=["Id", "Name", "Custom_Field__c"],
)
```
Relationship traversal fields (`Owner.Name`) are also supported:
```python
data = ingestor.ingest_sobject(
"Contact",
fields=["Id", "Name", "Email", "Account.Name", "Owner.Name"],
limit=10000,
)
```
### Let Semantica choose the fields
When `fields` is omitted, all selectable fields are fetched via `describe()`
(one extra API call). Compound address and geolocation fields (`type=address`,
`type=location`) are automatically excluded — select their components
(`BillingStreet`, `BillingCity`, `Location__Latitude__s`, …) individually if
you need them.
```python
data = ingestor.ingest_sobject("Opportunity")
```
## Raw SOQL Ingestion
Pass any valid SOQL query verbatim — pagination is handled automatically:
```python
data = ingestor.ingest_query("""
SELECT Id, Name, StageName, Amount, CloseDate,
Account.Name, Owner.Name
FROM Opportunity
WHERE IsClosed = false
ORDER BY CloseDate ASC
""")
print(f"Open opportunities: {data.row_count}")
```
The query is passed to the Salesforce REST API unchanged. The caller is
responsible for SOQL correctness and safety.
<Warning>
`ingest_query` does not validate or sanitise the SOQL string. Use
`ingest_sobject` (which validates sObject names, field names, and WHERE/ORDER
BY fragments) when building queries from application-controlled inputs.
</Warning>
## Document Export
Convert ingested records to the Semantica document format for use with
`GraphBuilder`:
```python
documents = ingestor.export_as_documents(
data,
id_field="Id", # default; Salesforce 18-char record Id
text_fields=["Name", "Description"], # omit to join all string fields
)
print(f"Created {len(documents)} documents")
# Each document:
# {
# "id": "001xx000003GYk2AAG",
# "text": "Acme Corp Enterprise software company",
# "metadata": {
# "source": "salesforce",
# "sobject": "Account",
# "instance_url": "https://myorg.my.salesforce.com",
# "row_data": { ... full cleaned record ... }
# }
# }
```
Feed the documents directly into `GraphBuilder`:
```python
from semantica.kg import GraphBuilder
builder = GraphBuilder()
kg = builder.build(documents)
```
## Object and Schema Discovery
```python
# List all accessible sObjects
sobject_names = ingestor.list_sobjects()
print(sobject_names[:10]) # ["Account", "Case", "Contact", ...]
# Inspect fields for a specific sObject
schema = ingestor.get_sobject_schema("Account")
for field in schema["fields"]:
print(f"{field['name']}: {field['type']} (nillable={field['nillable']})")
```
## Context Manager
Prefer the context manager for long-running jobs — it opens one connection on
entry and closes it on exit, so every ingestion call inside the `with` block
reuses the same authenticated session:
```python
with SalesforceIngestor(
username=os.getenv("SALESFORCE_USERNAME"),
password=os.getenv("SALESFORCE_PASSWORD"),
security_token=os.getenv("SALESFORCE_SECURITY_TOKEN"),
) as sf:
accounts = sf.ingest_sobject("Account", limit=10000)
contacts = sf.ingest_sobject("Contact", limit=10000)
sobjects = sf.list_sobjects()
```
## Convenience Function
Use `ingest_salesforce()` for one-liner ingestion:
```python
from semantica.ingest import ingest_salesforce
# Fetch records
data = ingest_salesforce(
method="sobject",
sobject_name="Account",
fields=["Id", "Name", "Industry"],
limit=500,
)
# Execute raw SOQL (credentials from environment variables)
data = ingest_salesforce(
method="query",
soql="SELECT Id, Name FROM Contact WHERE IsActive = true",
)
# Ingest + export to documents in one step
docs = ingest_salesforce(
method="documents",
sobject_name="Account",
text_fields=["Name", "Description"],
limit=1000,
)
# List accessible sObjects
sobject_names = ingest_salesforce(method="list_sobjects")
```
Or use the unified `ingest()` dispatcher:
```python
from semantica.ingest import ingest
result = ingest(
None,
source_type="salesforce",
method="sobject",
sobject_name="Account",
fields=["Id", "Name"],
limit=500,
)
data = result["data"] # SalesforceData
```
## Troubleshooting
```python
import os
from semantica.ingest import SalesforceConnector
connector = SalesforceConnector(
username=os.getenv("SALESFORCE_USERNAME"),
password=os.getenv("SALESFORCE_PASSWORD"),
security_token=os.getenv("SALESFORCE_SECURITY_TOKEN"),
)
if not connector.test_connection():
print("Connection failed: check username, password, security token, and domain")
```
Common causes of authentication failures:
- **Wrong domain**: production orgs use `domain="login"`; sandboxes use `domain="test"`.
- **Stale security token**: reset it under **Settings → Reset My Security Token**. The new token is emailed to you.
- **IP restriction**: your org's trusted IP ranges may block the originating IP. Check **Setup → Network Access**.
- **API access disabled**: ensure the connected profile has the **API Enabled** permission.
## See Also
- [Ingest Module](../reference/ingest) — Full `SalesforceIngestor` API and all other ingestors.
- [Snowflake Integration](snowflake) — Relational warehouse connector with a similar design.
- [Databricks Integration](databricks) — Lakehouse connector.
- [Installation](../installation) — All optional dependency extras.
- [Knowledge Graph](../reference/kg) — Build a KG from ingested Salesforce data.
+1
View File
@@ -28,6 +28,7 @@ icon: "database"
| `DBIngestor` | SQL databases via SQLAlchemy: tables, views, and custom queries |
| `SnowflakeIngestor` | Snowflake data warehouse queries and table exports |
| `DatabricksIngestor` | Databricks Unity Catalog metadata, Delta table queries, and lineage |
| `SAPIngestor` | SAP OData services (S/4HANA Cloud, SuccessFactors, NetWeaver Gateway): entity-set discovery and ingestion with v2/v4 pagination |
| `ParquetIngestor` | Apache Parquet files and partitioned datasets with column selection |
| `ArrowIngestor` | Apache Arrow IPC and Feather file processing |
| `XMLIngestor` | XXE-safe XML parsing with optional XSD schema validation |
+1 -1
View File
@@ -250,7 +250,7 @@ entry = ProvenanceEntry(
source_document="report.pdf", # str: default ""
source_location="Page 4", # Optional[str]: default None
source_quote="Relevant text...", # Optional[str]: default None
timestamp="2024-01-01T12:00:00", # str: auto-set to utcnow()
timestamp="2024-01-01T12:00:00+00:00", # str: auto-set to utc_now_iso()
first_seen=None, # Optional[str]: ISO timestamp
last_updated=None, # Optional[str]: ISO timestamp
confidence=0.9, # float: default 1.0
+19 -2
View File
@@ -127,9 +127,19 @@ conclusions = reasoner.infer_facts(
| `forward_chain()` | `List[InferenceResult]` | Derive all possible conclusions iteratively until fixpoint |
| `backward_chain(goal, max_depth)` | `InferenceResult \| None` | Prove a specific goal string, returns `None` if unprovable |
| `infer_facts(facts, rules)` | `List[str]` | Load facts and rules then run `forward_chain()`, returns conclusion strings |
| `clear()` | `None` | Clear all facts and rules |
| `reset_action_history()` | `None` | Allow actions for previously fired activations to run again |
| `clear()` | `None` | Clear all facts, rules, and action activation history |
| `reset()` | `None` | Alias for `clear()` |
Rules with actions use at-most-once attempt semantics per concrete activation
(rule ID, bindings, and matched facts). Calling `forward_chain()` again on the
same instance does not repeat side effects for an activation that was already
attempted, even when an action raised an exception. Call
`reset_action_history()` to deliberately retry without clearing facts or rules;
`clear()` and `reset()` also clear this history. Replacing a rule's actions in
place does not invalidate an existing activation; reset the history explicitly
when the replacement should be replayed.
### Rule and Fact dataclass fields
```python
@@ -230,9 +240,16 @@ engine.reset()
| `add_fact(fact)` | `None` | Add a `Fact` to working memory and propagate through the network |
| `match_patterns(facts)` | `List[Match]` | Match all patterns; optionally add facts before matching |
| `execute_matches(matches)` | `List[Any]` | Execute matched rules and return their conclusion values |
| `reset()` | `None` | Clear facts and all node activation state |
| `reset_action_history()` | `None` | Allow actions for previously executed activations to run again |
| `reset()` | `None` | Clear facts, node activation state, and action activation history |
| `get_network_stats()` | `dict` | Return counts of alpha, beta, terminal nodes and facts |
When a Reasoner is bound, `execute_matches()` deduplicates action side effects
by rule ID, bindings, and matched fact identity. Re-executing a match still
returns its conclusion for compatibility, but its actions are skipped after the
first attempt. `reset_action_history()`, `reset()`, and `build_network()` allow
those actions to run again.
## SPARQLReasoner
+1 -1
View File
@@ -182,7 +182,7 @@ for row in result.bindings:
store = TripletStore(
backend="rdf4j",
endpoint="http://localhost:8080/rdf4j-server",
repository_id="semantica", # passed through **config
repository_id="semantica", # selects the remote repository
)
```
+10
View File
@@ -77,7 +77,17 @@ Most users won't call utils directly: it's the **shared foundation** for all mod
export SEMANTICA_LOG_LEVEL=DEBUG
export SEMANTICA_LOG_FORMAT=json # "json" | "text"
export SEMANTICA_DISABLE_PROGRESS=true
export SEMANTICA_FORCE_PROGRESS=true
```
<Tip>
**Progress bars follow your terminal.** Console progress is written only when
stdout is an interactive terminal (or a Jupyter notebook), so piping or
redirecting output no longer fills logs with progress bars and escape
sequences. Set `SEMANTICA_DISABLE_PROGRESS` to silence progress even in a
terminal, or `SEMANTICA_FORCE_PROGRESS` to keep it when stdout is redirected.
`SEMANTICA_DISABLE_PROGRESS` wins if both are set.
</Tip>
</Step>
</Steps>
+2 -2
View File
@@ -35,7 +35,7 @@ This page is intentionally conservative: it distinguishes between an adapter exi
| FalkorDB | LPG | Yes | Yes | Partial | Partial | Redis-based; provenance depends on node/edge properties, and multi-graph isolation depends on the selected graph name. |
| Amazon Neptune | LPG | Yes | Yes | Partial | Partial | Use the property-graph endpoint; AWS auth, VPC, and endpoint configuration can affect local tests. Provenance depends on node/edge properties. |
| Apache AGE | LPG | Yes | Yes | Partial | Partial | Runs through PostgreSQL/AGE; Cypher compatibility and property handling can differ from standalone LPG engines. |
| RDF4J | RDF | Yes | Partial | Partial | Partial | Context separation relies on named graphs; triple-level provenance may require reification or graph-level metadata. `RDF4JStore(repository_id=...)` currently has no effect — the constructor always connects to the `"default"` repository regardless of the value passed; track a fix separately. |
| RDF4J | RDF | Yes | Partial | Partial | Partial | Context separation relies on named graphs; triple-level provenance may require reification or graph-level metadata. |
| Apache Jena | RDF | Yes | Partial | Partial | Partial | Named graphs are needed for context separation; backend configuration and transaction behavior matter. |
| Blazegraph | RDF | Yes | Partial | Partial | Partial | Use quads/named graphs for context; IRI stability and graph naming matter for provenance. |
| Anzo | RDF | Yes | Partial | Partial | Partial | Anzo deployments are environment-specific; validate `dataset_uri`/graphmart naming, named-graph support, and provenance mapping. |
@@ -107,7 +107,7 @@ from semantica.triplet_store import RDF4JStore
store = RDF4JStore(
endpoint='http://localhost:8080/rdf4j-server',
repository_id='semantica' # currently has no effect; connects to "default" (see Known limitations)
repository_id='semantica'
)
```
@@ -0,0 +1,195 @@
"""
Deterministic Explorer Rendering E2E Example.
Demonstrates building, serializing, and reloading a deterministic 4-node,
3-edge knowledge graph baseline for visual inspection in Semantica Explorer (#1037).
Graph topology:
Alice (Person, #63E6FF) --WORKS_AT--> Acme (Organization, #A78BFA)
Bob (Person, #63E6FF) --KNOWS--> Alice (Person, #63E6FF)
Acme (Organization, #A78BFA) --LOCATED_IN--> New York (Location, #34D399)
Clean Checkout Prerequisites:
1. Python backend dependencies:
pip install -e ".[explorer]"
2. Frontend workspace dependencies:
cd explorer && npm install && cd ..
Usage:
# 1. Generate the deterministic graph baseline:
python examples/explorer_deterministic_rendering_example.py
# 2. Launch Explorer with local dev authentication (Option A - Dev mode):
# Terminal 1 (Backend API):
SEMANTICA_ALLOW_ANONYMOUS=true python -m semantica.explorer --graph explorer_e2e_test_graph.json --port 8000 --no-browser
# Terminal 2 (Frontend UI):
cd explorer && npm run dev
# Open http://localhost:5173
# 2. Launch Explorer (Option B - Standalone CLI server):
SEMANTICA_ALLOW_ANONYMOUS=true python -m semantica.explorer --graph explorer_e2e_test_graph.json --port 8000
# Open http://localhost:8000
# Secure authentication alternative:
export SEMANTICA_API_KEY="your-secret-api-key"
python -m semantica.explorer --graph explorer_e2e_test_graph.json --port 8000
# Send HTTP header: X-API-Key: your-secret-api-key
Verification Checklist:
- Exactly 4 nodes visible on canvas:
* Alice (Person, #63E6FF)
* Bob (Person, #63E6FF)
* Acme (Organization, #A78BFA)
* New York (Location, #34D399)
- Exactly 3 directed edges with canonical relationship labels:
* Alice -> Acme (WORKS_AT)
* Bob -> Alice (KNOWS)
* Acme -> New York (LOCATED_IN)
- Zoom behavior:
* Zoom in to Inspection tier (ratio <= 0.5): directional arrows and node labels scale clearly.
* Zoom out to Overview tier (ratio > 1.2): layout remains stable and non-colliding.
- Hover & Selection interactions:
* Hover over 'Alice': node halo triggers; incident edges (WORKS_AT, KNOWS) highlight in local context.
* Click an edge: Inspector panel confirms edgeType ('WORKS_AT', 'KNOWS', or 'LOCATED_IN').
"""
from __future__ import annotations
import json
from pathlib import Path
from semantica.context.context_graph import ContextGraph
from semantica.explorer.session import GraphSession
def build_deterministic_graph() -> ContextGraph:
"""Build the exact 4-node, 3-edge graph specified in #1037."""
graph = ContextGraph(advanced_analytics=False)
# 1. Add exactly 4 nodes
graph.add_node(
"alice",
node_type="Person",
content="Alice",
color="#63E6FF",
)
graph.add_node(
"bob",
node_type="Person",
content="Bob",
color="#63E6FF",
)
graph.add_node(
"acme",
node_type="Organization",
content="Acme",
color="#A78BFA",
)
graph.add_node(
"new_york",
node_type="Location",
content="New York",
color="#34D399",
)
# 2. Add exactly 3 directed edges
graph.add_edge("alice", "acme", edge_type="WORKS_AT", weight=1.0)
graph.add_edge("bob", "alice", edge_type="KNOWS", weight=1.0)
graph.add_edge("acme", "new_york", edge_type="LOCATED_IN", weight=1.0)
return graph
def main() -> None:
print("=" * 75)
print("Semantica Explorer Deterministic Graph Generator (#1037)")
print("=" * 75)
print("1. Building deterministic ContextGraph...")
graph = build_deterministic_graph()
print(
f" ✓ Graph built with {len(graph.nodes)} nodes "
f"and {len(graph.edges)} edges."
)
output_path = Path("explorer_e2e_test_graph.json").resolve()
print(f"2. Persisting graph to '{output_path.name}'...")
graph.save_to_file(str(output_path))
print(f" ✓ Graph saved to {output_path}")
# Verify JSON format
with open(output_path, "r", encoding="utf-8") as f:
data = json.load(f)
assert len(data.get("nodes", [])) == 4
assert len(data.get("edges", [])) == 3
print("3. Verifying reload via GraphSession.from_file()...")
session = GraphSession.from_file(str(output_path))
stats = session.get_stats()
nodes, total_nodes = session.get_nodes()
edges, total_edges = session.get_edges()
assert stats["node_count"] == 4
assert stats["edge_count"] == 3
assert total_nodes == 4
assert total_edges == 3
print(
f" ✓ Graph reloaded successfully without mutation "
f"(nodes: {total_nodes}, edges: {total_edges}).\n"
)
print("=" * 75)
print("Clean Checkout Prerequisites:")
print("=" * 75)
print(" pip install -e '.[explorer]'")
print(" cd explorer && npm install && cd ..\n")
print("=" * 75)
print("Reproduction instructions to view in Semantica Explorer:")
print("=" * 75)
print("Option A (Frontend dev server + API backend — recommended for development):")
print(
f" 1. Backend: SEMANTICA_ALLOW_ANONYMOUS=true python -m semantica.explorer "
f"--graph {output_path} --port 8000 --no-browser"
)
print(" 2. Frontend: cd explorer && npm run dev")
print(" 3. Open http://localhost:5173 to inspect the graph canvas.\n")
print("Option B (Standalone Explorer CLI server):")
print(
f" SEMANTICA_ALLOW_ANONYMOUS=true python -m semantica.explorer "
f"--graph {output_path} --port 8000"
)
print(" Open http://localhost:8000\n")
print("Secure Authentication Alternative:")
print(" export SEMANTICA_API_KEY='your-secret-api-key'")
print(
f" python -m semantica.explorer --graph {output_path} --port 8000"
)
print(" Send header: 'X-API-Key: your-secret-api-key'\n")
print("=" * 75)
print("Verification Checklist:")
print("=" * 75)
print(" 1. Nodes (4 total):")
print(" - Alice (Person, #63E6FF)")
print(" - Bob (Person, #63E6FF)")
print(" - Acme (Organization, #A78BFA)")
print(" - New York (Location, #34D399)")
print(" 2. Directed Edges & Canonical Labels (3 total):")
print(" - Alice -> Acme [WORKS_AT]")
print(" - Bob -> Alice [KNOWS]")
print(" - Acme -> New York [LOCATED_IN]")
print(" 3. Zoom Interactions:")
print(" - Inspection tier (zoom in): directional arrows & labels remain legible.")
print(" - Overview tier (zoom out): nodes and edges maintain layout integrity.")
print(" 4. Hover & Selection Interactions:")
print(" - Hover Alice: node halo triggers and incident edges (WORKS_AT, KNOWS) highlight.")
print(" - Click edge: Inspector panel displays edgeType label ('WORKS_AT', 'KNOWS', 'LOCATED_IN').")
print("=" * 75)
if __name__ == "__main__":
main()
+2 -1
View File
@@ -9,7 +9,8 @@
"lint": "eslint .",
"preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts",
"test:deterministic-e2e": "node --import tsx --test tests/deterministicExplorerRendering.e2e.ts",
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
},
"dependencies": {
@@ -41,6 +41,7 @@ import {
} from "./plugins";
import { explorationEffectsShouldLoad, neighborhoodPanelShouldLoad, temporalOverlayShouldLoad } from "./pluginRegistryPredicates";
import { shouldFetchTemporalBounds, shouldFetchTemporalSnapshot } from "./temporalLifecyclePredicates";
import { createTemporalSnapshotGuards, type TemporalSnapshotResponse } from "./temporalSnapshotGuards";
import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
import type {
@@ -1479,6 +1480,23 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
summary?.edgeCount,
]);
// Guards the snapshot lifecycle: at most one in-flight request per scrubber
// position (identical-`at` polls are deduplicated, breaking the idle/play
// polling loop), applied snapshots are cached and re-applied on revisit, and
// a response applies only while the scrubber is still on its position
// (out-of-order responses cannot clobber the active-node count).
const temporalSnapshotGuardsRef = useRef<ReturnType<typeof createTemporalSnapshotGuards> | null>(null);
if (temporalSnapshotGuardsRef.current === null) {
temporalSnapshotGuardsRef.current = createTemporalSnapshotGuards();
}
const temporalSnapshotGuards = temporalSnapshotGuardsRef.current;
// A new graph summary means the graph data was replaced (reload/retry);
// snapshots cached against the previous graph are stale, so reset all state.
useEffect(() => {
temporalSnapshotGuards.reset();
}, [summary]);
useEffect(() => {
if (!canFetchTemporalSnapshot) {
return;
@@ -1488,37 +1506,67 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
return;
}
const atMs = debouncedTime.getTime();
const { seq, cached } = temporalSnapshotGuards.begin(atMs);
if (seq === null) {
// An identical request is already in flight: one request per position.
return;
}
let cancelled = false;
const applyData = (data: TemporalSnapshotResponse) => {
const nextActiveIds = new Set(data.active_node_ids);
requestAnimationFrame(() => {
if (cancelled) return;
if (!temporalSnapshotGuards.shouldApply(atMs, seq)) {
// The scrubber moved on (or this request was superseded): release the
// position so a return to it refetches instead of stalling.
temporalSnapshotGuards.finish(atMs, seq);
return;
}
const previous = prevActiveIdsRef.current;
previous.forEach((id) => {
if (!nextActiveIds.has(id) && graph.hasNode(id)) {
graph.setNodeAttribute(id, "hidden", true);
}
});
nextActiveIds.forEach((id) => {
if (graph.hasNode(id)) {
graph.setNodeAttribute(id, "hidden", false);
}
});
prevActiveIdsRef.current = nextActiveIds;
setActiveNodeCount(data.active_node_count);
setGraphVersion((current) => current + 1);
sceneRef.current?.getRuntime()?.requestRender();
temporalSnapshotGuards.apply(atMs, seq, data);
});
};
if (cached) {
// Returning to a position whose snapshot was already applied: re-apply
// the cached result without a network request.
applyData(cached);
return;
}
const applySnapshot = async () => {
try {
const at = debouncedTime.toISOString();
const response = await fetch(`/api/temporal/snapshot?at=${encodeURIComponent(at)}`);
if (!response.ok || cancelled) return;
const data: { active_node_ids: string[]; active_node_count: number } = await response.json();
if (!response.ok) {
// A failed request must be retryable if the scrubber returns.
if (!cancelled) temporalSnapshotGuards.finish(atMs, seq);
return;
}
if (cancelled) return;
const nextActiveIds = new Set(data.active_node_ids);
requestAnimationFrame(() => {
if (cancelled) return;
const previous = prevActiveIdsRef.current;
previous.forEach((id) => {
if (!nextActiveIds.has(id) && graph.hasNode(id)) {
graph.setNodeAttribute(id, "hidden", true);
}
});
nextActiveIds.forEach((id) => {
if (graph.hasNode(id)) {
graph.setNodeAttribute(id, "hidden", false);
}
});
prevActiveIdsRef.current = nextActiveIds;
setActiveNodeCount(data.active_node_count);
setGraphVersion((current) => current + 1);
sceneRef.current?.getRuntime()?.requestRender();
});
const data: TemporalSnapshotResponse = await response.json();
if (cancelled) return;
applyData(data);
} catch (fetchError) {
temporalSnapshotGuards.finish(atMs, seq);
if (!cancelled) {
console.error("[Temporal] Snapshot fetch failed", fetchError);
}
@@ -1528,6 +1576,8 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
applySnapshot();
return () => {
cancelled = true;
// A cancelled request must be retryable when its position is revisited.
temporalSnapshotGuards.finish(atMs, seq);
};
}, [
canFetchTemporalSnapshot,
@@ -1,8 +1,9 @@
import { useState, useRef, useEffect, type CSSProperties } from "react";
import ReactMarkdown from "react-markdown";
import { useState, useRef, useEffect, useMemo, type CSSProperties } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } from "lucide-react";
import { GRAPH_THEME } from "./graphTheme";
import { isSafeUrl } from "./markdownUrlSafety";
export interface MarkdownContentViewerProps {
content?: string | null;
@@ -10,25 +11,6 @@ export interface MarkdownContentViewerProps {
defaultMode?: "preview" | "source";
}
export function isSafeUrl(url?: string): boolean {
if (!url) return false;
const trimmed = url.trim();
// Reject whitespace-only strings — new URL("", base) would resolve to the base
// protocol and produce a false positive. This guards direct callers of the exported
// function; markdown parsers normalise whitespace-only destinations to "" which
// already fails the !url check above.
if (!trimmed) return false;
if (trimmed.startsWith("//")) return false;
if (trimmed.startsWith("#")) return true;
if (trimmed.startsWith("/")) return true;
try {
const parsed = new URL(trimmed, "http://localhost");
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
} catch {
return false;
}
}
export function MarkdownContentViewer({
content,
className,
@@ -65,6 +47,20 @@ export function MarkdownContentViewer({
const rawContent = typeof content === "string" ? content : "";
const hasContent = rawContent.trim().length > 0;
// react-markdown runs the whole remark pipeline synchronously inside its own
// render, so without this memo every unrelated re-render of this component --
// clicking Copy, toggling Preview/Source -- re-parses the entire document.
// Measured at ~364ms per re-render for a 1000-row GFM table (issue #1118).
// Keyed on rawContent so a genuine node change still re-parses exactly once.
const renderedMarkdown = useMemo(
() => (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{rawContent}
</ReactMarkdown>
),
[rawContent],
);
const handleCopy = async () => {
if (!hasContent) return;
try {
@@ -130,98 +126,103 @@ export function MarkdownContentViewer({
<code style={sourceCodeStyle}>{rawContent}</code>
</pre>
) : (
<div style={previewStyle}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// C-1: react-markdown passes a HAST `node` prop (the raw AST
// Element) to every custom component override via passNode:true.
// In React 19 any unknown prop spreads onto a native element are
// serialised as HTML attributes, producing node="[object Object]"
// on every rendered link. Fix: destructure `node` by name so it
// is explicitly discarded, then spread `...rest` to preserve all
// other legitimate HAST/remark-gfm attributes — e.g. the `id`,
// `aria-describedby`, `aria-label`, `data-footnote-ref`,
// `data-footnote-backref`, and `class` attrs that GFM footnotes
// require for correct in-page navigation and accessibility.
//
// C-2: fragment links (#anchor, GFM footnote backlinks) must
// navigate within the current document. External links continue
// to use target="_blank" with noopener noreferrer.
//
// eslint-disable-next-line @typescript-eslint/no-unused-vars
a: ({ href, children, title, node: _node, ...rest }) => {
if (!isSafeUrl(href)) {
return <span style={{ color: GRAPH_THEME.ui.text.muted, textDecoration: "line-through" }}>{children}</span>;
}
// isSafeUrl returning true guarantees href is a non-empty string.
const safeHref = href ?? "";
// Fragment links (#section, footnote backlinks like
// #user-content-fnref-1) are in-document anchors. Opening them
// in a new tab would break GFM footnote back-navigation.
const isFragment = safeHref.startsWith("#");
if (isFragment) {
return (
<a href={safeHref} title={title} style={linkStyle} {...rest}>
{children}
</a>
);
}
return (
<a href={safeHref} title={title} target="_blank" rel="noopener noreferrer" style={linkStyle} {...rest}>
{children}
<ExternalLink size={10} style={{ marginLeft: 3, verticalAlign: "middle", display: "inline" }} />
</a>
);
},
img: ({ src, alt }) => (
<span style={imageBadgeStyle} title={src || "Image"}>
<ImageIcon size={12} style={{ marginRight: 5 }} />
<span>Image: {alt || src || "unlabeled"}</span>
</span>
),
h1: ({ children }) => <h1 style={h1Style}>{children}</h1>,
h2: ({ children }) => <h2 style={h2Style}>{children}</h2>,
h3: ({ children }) => <h3 style={h3Style}>{children}</h3>,
h4: ({ children }) => <h4 style={h4Style}>{children}</h4>,
p: ({ children }) => <p style={{ margin: "0 0 8px 0" }}>{children}</p>,
ul: ({ children }) => <ul style={{ margin: "0 0 8px 0", paddingLeft: 18 }}>{children}</ul>,
ol: ({ children }) => <ol style={{ margin: "0 0 8px 0", paddingLeft: 18 }}>{children}</ol>,
li: ({ children }) => <li style={{ marginBottom: 3 }}>{children}</li>,
blockquote: ({ children }) => <blockquote style={blockquoteStyle}>{children}</blockquote>,
hr: () => <hr style={{ border: "none", borderTop: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, margin: "10px 0" }} />,
table: ({ children }) => (
<div style={{ width: "100%", overflowX: "auto", margin: "8px 0", borderRadius: 6, border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>{children}</table>
</div>
),
thead: ({ children }) => <thead style={{ background: "rgba(255, 255, 255, 0.04)" }}>{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</tr>,
th: ({ children }) => <th style={{ padding: "6px 8px", textAlign: "left", fontWeight: 700, color: GRAPH_THEME.ui.text.strong, borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</th>,
td: ({ children }) => <td style={{ padding: "6px 8px", color: GRAPH_THEME.ui.text.body, borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</td>,
pre: ({ children }) => <pre style={preBlockStyle}>{children}</pre>,
// C-1: discard `node` here too — code elements are custom components
// and would otherwise receive node="[object Object]" in the DOM.
code: ({ className: codeClass, children }) => {
const isInline = !codeClass && typeof children === "string" && !children.includes("\n");
return (
<code style={isInline ? inlineCodeStyle : blockCodeStyle}>
{children}
</code>
);
},
}}
>
{rawContent}
</ReactMarkdown>
</div>
<div style={previewStyle}>{renderedMarkdown}</div>
)}
</div>
</div>
);
}
/* ─── Markdown rendering config ───────────────────────────────────── */
// Both props are hoisted to module scope so they keep a stable identity across
// renders. As inline literals they allocated a fresh plugin array and ~20 fresh
// arrow components on every render, which made React treat every mapped tag as a
// new element type and remount the entire rendered subtree instead of updating
// it (issue #1118). The arrow bodies only read the style constants below at call
// time, so declaring the map before them is safe.
const REMARK_PLUGINS = [remarkGfm];
const MARKDOWN_COMPONENTS: Components = {
// C-1: react-markdown passes a HAST `node` prop (the raw AST
// Element) to every custom component override via passNode:true.
// In React 19 any unknown prop spreads onto a native element are
// serialised as HTML attributes, producing node="[object Object]"
// on every rendered link. Fix: destructure `node` by name so it
// is explicitly discarded, then spread `...rest` to preserve all
// other legitimate HAST/remark-gfm attributes — e.g. the `id`,
// `aria-describedby`, `aria-label`, `data-footnote-ref`,
// `data-footnote-backref`, and `class` attrs that GFM footnotes
// require for correct in-page navigation and accessibility.
//
// C-2: fragment links (#anchor, GFM footnote backlinks) must
// navigate within the current document. External links continue
// to use target="_blank" with noopener noreferrer.
//
// eslint-disable-next-line @typescript-eslint/no-unused-vars
a: ({ href, children, title, node: _node, ...rest }) => {
if (!isSafeUrl(href)) {
return <span style={{ color: GRAPH_THEME.ui.text.muted, textDecoration: "line-through" }}>{children}</span>;
}
// isSafeUrl returning true guarantees href is a non-empty string.
const safeHref = href ?? "";
// Fragment links (#section, footnote backlinks like
// #user-content-fnref-1) are in-document anchors. Opening them
// in a new tab would break GFM footnote back-navigation.
const isFragment = safeHref.startsWith("#");
if (isFragment) {
return (
<a href={safeHref} title={title} style={linkStyle} {...rest}>
{children}
</a>
);
}
return (
<a href={safeHref} title={title} target="_blank" rel="noopener noreferrer" style={linkStyle} {...rest}>
{children}
<ExternalLink size={10} style={{ marginLeft: 3, verticalAlign: "middle", display: "inline" }} />
</a>
);
},
img: ({ src, alt }) => (
<span style={imageBadgeStyle} title={src || "Image"}>
<ImageIcon size={12} style={{ marginRight: 5 }} />
<span>Image: {alt || src || "unlabeled"}</span>
</span>
),
h1: ({ children }) => <h1 style={h1Style}>{children}</h1>,
h2: ({ children }) => <h2 style={h2Style}>{children}</h2>,
h3: ({ children }) => <h3 style={h3Style}>{children}</h3>,
h4: ({ children }) => <h4 style={h4Style}>{children}</h4>,
p: ({ children }) => <p style={{ margin: "0 0 8px 0" }}>{children}</p>,
ul: ({ children }) => <ul style={{ margin: "0 0 8px 0", paddingLeft: 18 }}>{children}</ul>,
ol: ({ children }) => <ol style={{ margin: "0 0 8px 0", paddingLeft: 18 }}>{children}</ol>,
li: ({ children }) => <li style={{ marginBottom: 3 }}>{children}</li>,
blockquote: ({ children }) => <blockquote style={blockquoteStyle}>{children}</blockquote>,
hr: () => <hr style={{ border: "none", borderTop: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, margin: "10px 0" }} />,
table: ({ children }) => (
<div style={{ width: "100%", overflowX: "auto", margin: "8px 0", borderRadius: 6, border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>{children}</table>
</div>
),
thead: ({ children }) => <thead style={{ background: "rgba(255, 255, 255, 0.04)" }}>{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</tr>,
th: ({ children }) => <th style={{ padding: "6px 8px", textAlign: "left", fontWeight: 700, color: GRAPH_THEME.ui.text.strong, borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</th>,
td: ({ children }) => <td style={{ padding: "6px 8px", color: GRAPH_THEME.ui.text.body, borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</td>,
pre: ({ children }) => <pre style={preBlockStyle}>{children}</pre>,
// C-1: discard `node` here too — code elements are custom components
// and would otherwise receive node="[object Object]" in the DOM.
code: ({ className: codeClass, children }) => {
const isInline = !codeClass && typeof children === "string" && !children.includes("\n");
return (
<code style={isInline ? inlineCodeStyle : blockCodeStyle}>
{children}
</code>
);
},
};
/* ─── Styles ──────────────────────────────────────────────────────── */
const viewerContainerStyle: CSSProperties = {
@@ -0,0 +1,29 @@
/**
* URL-safety predicate for the Markdown content viewer.
*
* Extracted into a pure module so the check can be unit-tested without
* importing the MarkdownContentViewer React component, and so the component
* module exports only components (react-refresh/only-export-components,
* issue #1119). The behaviour is unchanged from the original in-component
* implementation: only http, https, mailto, in-document fragments, and
* root-relative paths are permitted.
*/
export function isSafeUrl(url?: string): boolean {
if (!url) return false;
const trimmed = url.trim();
// Reject whitespace-only strings — new URL("", base) would resolve to the base
// protocol and produce a false positive. This guards direct callers of the exported
// function; markdown parsers normalise whitespace-only destinations to "" which
// already fails the !url check above.
if (!trimmed) return false;
if (trimmed.startsWith("//")) return false;
if (trimmed.startsWith("#")) return true;
if (trimmed.startsWith("/")) return true;
try {
const parsed = new URL(trimmed, "http://localhost");
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
} catch {
return false;
}
}
@@ -0,0 +1,113 @@
/**
* Guards for the temporal snapshot fetch/apply lifecycle.
*
* The snapshot effect previously fetched /api/temporal/snapshot with no
* idempotency or ordering protection. Upstream churn (timeline recreation
* while bounds settle, play ticks resetting the playhead, drag events) could
* re-request the same `at` repeatedly, and responses could arrive after the
* scrubber had moved on.
*
* The guards enforce:
* - at most one in-flight request per scrubber position (identical `at`
* values are deduplicated while a request is pending, breaking the
* idle/play polling loop);
* - successful snapshots are cached per position and re-applied when the
* scrubber returns (play wrap-around, back-scrubbing) without a refetch;
* - a response is applied only while the scrubber is still on its position,
* so out-of-order responses cannot clobber a newer position's count;
* - failed, cancelled, or superseded requests release their position so it
* can be fetched again on the next visit;
* - `reset()` drops all state when the underlying graph data is replaced
* (reload/retry), because cached snapshots describe the previous graph.
*
* `createTemporalSnapshotGuards()` is stateful by design.
*/
export interface TemporalSnapshotResponse {
active_node_ids: string[];
active_node_count: number;
}
export interface TemporalSnapshotRequest {
/** null when the request was deduplicated because one is already in flight. */
seq: number | null;
/** The snapshot previously applied for this position, when revisiting it. */
cached: TemporalSnapshotResponse | null;
}
export interface TemporalSnapshotGuards {
/** Begin (or dedupe) a request for `atMs`; marks it as the current position. */
begin(atMs: number): TemporalSnapshotRequest;
/** True when the response for `atMs`/`seq` may be applied (scrubber still on `atMs`). */
shouldApply(atMs: number, seq: number): boolean;
/** Record a successful application and cache its snapshot for revisits. */
apply(atMs: number, seq: number, data: TemporalSnapshotResponse): void;
/** Release a position whose request failed, was cancelled, or was superseded. */
finish(atMs: number, seq: number): void;
/** Drop all state; call when the underlying graph data is replaced (reload). */
reset(): void;
}
interface SnapshotEntry {
seq: number;
/** null while the request is in flight (or before the first success). */
data: TemporalSnapshotResponse | null;
}
/** Upper bound on cached positions so long scrubbing sessions stay bounded. */
const MAX_CACHED_POSITIONS = 256;
export function createTemporalSnapshotGuards(): TemporalSnapshotGuards {
const entries = new Map<number, SnapshotEntry>();
let latestRequestSeq = 0;
let currentAtMs: number | null = null;
const evictOldest = () => {
while (entries.size > MAX_CACHED_POSITIONS) {
const oldestAtMs = entries.keys().next().value;
if (oldestAtMs === undefined) return;
entries.delete(oldestAtMs);
}
};
return {
begin(atMs) {
const existing = entries.get(atMs);
if (existing && existing.data === null) {
// Identical request already in flight: dedupe, but the scrubber is here now.
currentAtMs = atMs;
return { seq: null, cached: null };
}
latestRequestSeq += 1;
const seq = latestRequestSeq;
entries.set(atMs, { seq, data: existing?.data ?? null });
currentAtMs = atMs;
evictOldest();
return { seq, cached: existing?.data ?? null };
},
shouldApply(atMs, seq) {
return atMs === currentAtMs && entries.get(atMs)?.seq === seq;
},
apply(atMs, seq, data) {
const entry = entries.get(atMs);
if (entry && entry.seq === seq) {
entry.data = data;
}
},
finish(atMs, seq) {
const entry = entries.get(atMs);
if (entry && entry.seq === seq && entry.data === null) {
entries.delete(atMs);
}
},
reset() {
entries.clear();
latestRequestSeq = 0;
currentAtMs = null;
},
};
}
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { setTimeout as delay } from "node:timers/promises";
import test from "node:test";
import { chromium, type Page } from "playwright";
const PORT = 4173;
const BASE_URL = `http://127.0.0.1:${PORT}`;
const nodes = [
{ id: "alice", type: "Person", content: "Alice", properties: {} },
{ id: "bob", type: "Person", content: "Bob", properties: {} },
{ id: "acme", type: "Organization", content: "Acme", properties: {} },
{ id: "new_york", type: "Location", content: "New York", properties: {} },
];
const edges = [
{ id: "edge_alice_acme", familyId: "edge_alice_acme", source: "alice", target: "acme", type: "WORKS_AT", weight: 1, properties: {} },
{ id: "edge_bob_alice", familyId: "edge_bob_alice", source: "bob", target: "alice", type: "KNOWS", weight: 1, properties: {} },
{ id: "edge_acme_new_york", familyId: "edge_acme_new_york", source: "acme", target: "new_york", type: "LOCATED_IN", weight: 1, properties: {} },
];
let server: ChildProcess | undefined;
async function startVite(): Promise<void> {
server = spawn("npm", ["run", "dev", "--", "--host", "127.0.0.1", "--port", String(PORT)], {
cwd: process.cwd(),
stdio: "ignore",
});
for (let attempt = 0; attempt < 50; attempt += 1) {
try {
const response = await fetch(BASE_URL);
if (response.ok) return;
} catch {
// Vite is still starting.
}
await delay(100);
}
throw new Error("Vite did not become ready");
}
async function installApiFixture(page: Page): Promise<void> {
await page.route("**/api/graph/**", async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname === "/api/graph/stats") {
await route.fulfill({ json: { node_count: 4, edge_count: 3 } });
} else if (pathname === "/api/graph/nodes") {
await route.fulfill({ json: { nodes, total: nodes.length, skip: 0, limit: 1000, next_cursor: null } });
} else if (pathname === "/api/graph/edges") {
await route.fulfill({ json: { edges, total: edges.length, skip: 0, limit: 1000, next_cursor: null } });
} else {
await route.continue();
}
});
}
test("real Explorer loading path hydrates and renders API edge labels", async (t) => {
await startVite();
t.after(async () => {
server?.kill();
});
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || (existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : undefined),
});
t.after(() => browser.close());
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.addInitScript(() => {
const captured = (window as Window & { __capturedCanvasText?: string[] }).__capturedCanvasText = [];
const originalFillText = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function (text: string, ...args: [number, number, number?, number?]) {
captured.push(String(text));
return originalFillText.call(this, text, ...args);
};
});
await installApiFixture(page);
await page.goto(BASE_URL);
await page.getByRole("button", { name: /Open Semantica Explorer/ }).click();
await page.locator("canvas").nth(0).waitFor({ state: "attached" });
await page.waitForFunction(() => document.querySelectorAll("canvas").length >= 2);
await page.waitForFunction(() => {
const labels = (window as Window & { __capturedCanvasText?: string[] }).__capturedCanvasText ?? [];
return ["WORKS_AT", "KNOWS", "LOCATED_IN"].every((label) => labels.includes(label));
}, undefined, { timeout: 10_000 });
const capturedLabels = await page.evaluate(() => (window as Window & { __capturedCanvasText?: string[] }).__capturedCanvasText ?? []);
for (const label of ["WORKS_AT", "KNOWS", "LOCATED_IN"]) {
assert.ok(capturedLabels.includes(label), `Expected rendered edge label ${label}`);
}
assert.ok(capturedLabels.includes("Alice"));
await page.getByRole("button", { name: "Zoom In" }).click();
await page.waitForTimeout(250);
const labelsAfterZoom = await page.evaluate(() => (window as Window & { __capturedCanvasText?: string[] }).__capturedCanvasText ?? []);
for (const label of ["WORKS_AT", "KNOWS", "LOCATED_IN"]) {
assert.ok(labelsAfterZoom.includes(label), `Expected edge label ${label} after zoom`);
}
});
@@ -0,0 +1,508 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
batchMergeEdges,
batchMergeNodes,
clearGraph,
graph,
} from "../src/store/graphStore.ts";
import {
buildStructuralDistanceSnapshot,
classifyFullGraphEdge,
resolveDisplayGraph,
resolveEdgeElementStyle,
resolveEdgeVisualState,
resolveNodeElementStyle,
resolveNodeVisualState,
shouldForceNodeLabel,
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
import { GRAPH_THEME, type GraphZoomTier } from "../src/workspaces/GraphWorkspace/graphTheme.ts";
test.beforeEach(() => {
clearGraph();
});
test.after(() => {
clearGraph();
});
/**
* Loads the canonical 4-node, 3-edge deterministic test graph (Semantica #1037).
*
* Graph structure:
* Alice (Person) --WORKS_AT--> Acme (Organization)
* Bob (Person) --KNOWS--> Alice (Person)
* Acme (Organization) --LOCATED_IN--> New York (Location)
*/
function loadDeterministicTestGraph() {
batchMergeNodes([
{
id: "alice",
attributes: {
label: "Alice",
content: "Alice",
x: 0,
y: 0,
size: 8,
color: "#63E6FF",
baseColor: "#63E6FF",
nodeType: "Person",
semanticGroup: "Person",
properties: {},
},
},
{
id: "bob",
attributes: {
label: "Bob",
content: "Bob",
x: -50,
y: 0,
size: 8,
color: "#63E6FF",
baseColor: "#63E6FF",
nodeType: "Person",
semanticGroup: "Person",
properties: {},
},
},
{
id: "acme",
attributes: {
label: "Acme",
content: "Acme",
x: 50,
y: 0,
size: 8,
color: "#A78BFA",
baseColor: "#A78BFA",
nodeType: "Organization",
semanticGroup: "Organization",
properties: {},
},
},
{
id: "new_york",
attributes: {
label: "New York",
content: "New York",
x: 100,
y: 0,
size: 8,
color: "#34D399",
baseColor: "#34D399",
nodeType: "Location",
semanticGroup: "Location",
properties: {},
},
},
]);
batchMergeEdges([
{
id: "edge_alice_acme",
source: "alice",
target: "acme",
attributes: {
edgeId: "edge_alice_acme",
edgeType: "WORKS_AT",
weight: 1.0,
visualPriority: 0.8,
baseSize: 0.8,
properties: {},
},
},
{
id: "edge_bob_alice",
source: "bob",
target: "alice",
attributes: {
edgeId: "edge_bob_alice",
edgeType: "KNOWS",
weight: 1.0,
visualPriority: 0.8,
baseSize: 0.8,
properties: {},
},
},
{
id: "edge_acme_new_york",
source: "acme",
target: "new_york",
attributes: {
edgeId: "edge_acme_new_york",
edgeType: "LOCATED_IN",
weight: 1.0,
visualPriority: 0.8,
baseSize: 0.8,
properties: {},
},
},
]);
}
test("deterministic graph contains exactly 4 nodes and 3 edges in store", () => {
loadDeterministicTestGraph();
assert.equal(graph.order, 4, "Expected exactly 4 nodes");
assert.equal(graph.size, 3, "Expected exactly 3 edges");
// Verify node identities and labels
const alice = graph.getNodeAttributes("alice");
const bob = graph.getNodeAttributes("bob");
const acme = graph.getNodeAttributes("acme");
const newYork = graph.getNodeAttributes("new_york");
assert.equal(alice.label, "Alice");
assert.equal(alice.nodeType, "Person");
assert.equal(alice.color, "#63E6FF");
assert.equal(bob.label, "Bob");
assert.equal(bob.nodeType, "Person");
assert.equal(bob.color, "#63E6FF");
assert.equal(acme.label, "Acme");
assert.equal(acme.nodeType, "Organization");
assert.equal(acme.color, "#A78BFA");
assert.equal(newYork.label, "New York");
assert.equal(newYork.nodeType, "Location");
assert.equal(newYork.color, "#34D399");
// Verify edge connectivity and canonical edgeType labels
const edgeAliceAcme = graph.getEdgeAttributes("edge_alice_acme");
const edgeBobAlice = graph.getEdgeAttributes("edge_bob_alice");
const edgeAcmeNewYork = graph.getEdgeAttributes("edge_acme_new_york");
assert.equal(edgeAliceAcme.edgeType, "WORKS_AT");
assert.equal(graph.source("edge_alice_acme"), "alice");
assert.equal(graph.target("edge_alice_acme"), "acme");
assert.equal(edgeBobAlice.edgeType, "KNOWS");
assert.equal(graph.source("edge_bob_alice"), "bob");
assert.equal(graph.target("edge_bob_alice"), "alice");
assert.equal(edgeAcmeNewYork.edgeType, "LOCATED_IN");
assert.equal(graph.source("edge_acme_new_york"), "acme");
assert.equal(graph.target("edge_acme_new_york"), "new_york");
});
test("display graph resolution preserves all 4 nodes and 3 edges in full view", () => {
loadDeterministicTestGraph();
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
assert.equal(displayGraph.order, 4);
assert.equal(displayGraph.size, 3);
assert.ok(displayGraph.hasNode("alice"));
assert.ok(displayGraph.hasNode("bob"));
assert.ok(displayGraph.hasNode("acme"));
assert.ok(displayGraph.hasNode("new_york"));
assert.ok(displayGraph.hasEdge("edge_alice_acme"));
assert.ok(displayGraph.hasEdge("edge_bob_alice"));
assert.ok(displayGraph.hasEdge("edge_acme_new_york"));
});
test("structural distance calculation resolves correct hop counts across the 3-edge chain", () => {
loadDeterministicTestGraph();
// From Bob: Bob (0) -> Alice (1) -> Acme (2) -> New York (3)
const distances = buildStructuralDistanceSnapshot(graph, "bob", 3);
assert.equal(distances.bob, 0);
assert.equal(distances.alice, 1);
assert.equal(distances.acme, 2);
assert.equal(distances.new_york, 3);
});
test("edge rendering and canonical edge labels remain legible across zoom tiers and inspection modes", () => {
loadDeterministicTestGraph();
const canonicalEdges = [
{ id: "edge_alice_acme", source: "alice", target: "acme", label: "WORKS_AT" },
{ id: "edge_bob_alice", source: "bob", target: "alice", label: "KNOWS" },
{ id: "edge_acme_new_york", source: "acme", target: "new_york", label: "LOCATED_IN" },
];
// 1. Edge attributes preserve canonical edgeType labels in graph store:
for (const item of canonicalEdges) {
const attrs = graph.getEdgeAttributes(item.id);
assert.equal(attrs.edgeType, item.label, `Edge ${item.id} must have edgeType ${item.label}`);
assert.equal(graph.source(item.id), item.source);
assert.equal(graph.target(item.id), item.target);
}
// 2. In active context / neighbor state across all zoom tiers (overview, structure, inspection):
const allTiers: GraphZoomTier[] = ["overview", "structure", "inspection"];
for (const tier of allTiers) {
for (const item of canonicalEdges) {
const attrs = graph.getEdgeAttributes(item.id);
const contextStyle = resolveEdgeElementStyle(
GRAPH_THEME,
tier,
"neighbor",
attrs,
item.source,
item.target,
"full",
item.id,
);
assert.equal(
contextStyle.hidden,
false,
`Edge ${item.id} (${item.label}) in context state 'neighbor' must be visible in zoom tier '${tier}'`,
);
assert.ok(
contextStyle.size !== undefined && contextStyle.size > 0,
`Edge ${item.id} (${item.label}) must have positive render size in zoom tier '${tier}'`,
);
}
}
// 3. In selected state in inspection zoom tier (close examination of edge details and label):
for (const item of canonicalEdges) {
const attrs = graph.getEdgeAttributes(item.id);
const selectedStyle = resolveEdgeElementStyle(
GRAPH_THEME,
"inspection",
"selected",
attrs,
item.source,
item.target,
"full",
item.id,
"selected",
);
assert.equal(
selectedStyle.hidden,
false,
`Selected edge ${item.id} (${item.label}) must be visible in inspection zoom tier`,
);
assert.ok(
selectedStyle.size !== undefined && selectedStyle.size > 0,
`Selected edge ${item.id} (${item.label}) must have positive render size`,
);
}
// 4. Verify inspection zoom tier camera and arrow rendering settings
assert.equal(GRAPH_THEME.zoomTiers.inspection.showContextualArrows, true);
assert.equal(GRAPH_THEME.zoomTiers.inspection.showCurves, true);
});
test("node hover interaction preserves edge visibility and highlights canonical incident edge types", () => {
loadDeterministicTestGraph();
// Scenario 1: Hover Alice
// Incident edges: Alice -> Acme (WORKS_AT) and Bob -> Alice (KNOWS)
const aliceAttrs = graph.getNodeAttributes("alice");
const aliceVisual = resolveNodeVisualState("alice", "structure", "alice", "", "", new Set(), new Set(), new Set());
assert.equal(aliceVisual, "hovered");
const aliceStyle = resolveNodeElementStyle(GRAPH_THEME, "structure", "hovered", aliceAttrs, "Alice");
assert.equal(aliceStyle.forceLabel, true, "Hovered Alice must force-render label");
assert.equal(aliceStyle.label, "Alice");
assert.equal(aliceStyle.showHalo, true, "Hovered Alice must show interactive halo");
const aliceIncidentEdges = new Set(["edge_alice_acme", "edge_bob_alice"]);
// Edge Alice -> Acme (WORKS_AT) under Alice hover
const aliceAcmeAttrs = graph.getEdgeAttributes("edge_alice_acme");
assert.equal(aliceAcmeAttrs.edgeType, "WORKS_AT");
const aliceAcmeState = resolveEdgeVisualState(
"edge_alice_acme",
"alice",
"acme",
"structure",
"alice",
"",
"",
new Set(),
new Set(),
aliceIncidentEdges,
);
assert.equal(aliceAcmeState, "hovered");
const aliceAcmeStyle = resolveEdgeElementStyle(
GRAPH_THEME,
"structure",
"hovered",
aliceAcmeAttrs,
"alice",
"acme",
"full",
"edge_alice_acme",
);
assert.equal(aliceAcmeStyle.hidden, false, "Incident edge WORKS_AT must remain visible on hover");
assert.ok(aliceAcmeStyle.size !== undefined && aliceAcmeStyle.size > 0);
// Edge Bob -> Alice (KNOWS) under Alice hover
const bobAliceAttrs = graph.getEdgeAttributes("edge_bob_alice");
assert.equal(bobAliceAttrs.edgeType, "KNOWS");
const bobAliceState = resolveEdgeVisualState(
"edge_bob_alice",
"bob",
"alice",
"structure",
"alice",
"",
"",
new Set(),
new Set(),
aliceIncidentEdges,
);
assert.equal(bobAliceState, "hovered");
const bobAliceStyle = resolveEdgeElementStyle(
GRAPH_THEME,
"structure",
"hovered",
bobAliceAttrs,
"bob",
"alice",
"full",
"edge_bob_alice",
);
assert.equal(bobAliceStyle.hidden, false, "Incident edge KNOWS must remain visible on hover");
// Non-incident edge Acme -> New York (LOCATED_IN) under Alice hover
const acmeNyAttrs = graph.getEdgeAttributes("edge_acme_new_york");
assert.equal(acmeNyAttrs.edgeType, "LOCATED_IN");
const acmeNyState = resolveEdgeVisualState(
"edge_acme_new_york",
"acme",
"new_york",
"structure",
"alice",
"",
"",
new Set(),
new Set(),
aliceIncidentEdges,
);
assert.equal(acmeNyState, "muted");
// Scenario 2: Hover Acme
// Incident edges: Alice -> Acme (WORKS_AT) and Acme -> New York (LOCATED_IN)
const acmeAttrs = graph.getNodeAttributes("acme");
const acmeStyle = resolveNodeElementStyle(GRAPH_THEME, "structure", "hovered", acmeAttrs, "Acme");
assert.equal(acmeStyle.forceLabel, true);
assert.equal(acmeStyle.label, "Acme");
const acmeIncidentEdges = new Set(["edge_alice_acme", "edge_acme_new_york"]);
const acmeNyHoverState = resolveEdgeVisualState(
"edge_acme_new_york",
"acme",
"new_york",
"structure",
"acme",
"",
"",
new Set(),
new Set(),
acmeIncidentEdges,
);
assert.equal(acmeNyHoverState, "hovered");
const acmeNyHoverStyle = resolveEdgeElementStyle(
GRAPH_THEME,
"structure",
"hovered",
acmeNyAttrs,
"acme",
"new_york",
"full",
"edge_acme_new_york",
);
assert.equal(acmeNyHoverStyle.hidden, false, "Incident edge LOCATED_IN must remain visible on hover");
// Scenario 3: Hover Bob
// Incident edge: Bob -> Alice (KNOWS)
const bobAttrs = graph.getNodeAttributes("bob");
const bobStyle = resolveNodeElementStyle(GRAPH_THEME, "structure", "hovered", bobAttrs, "Bob");
assert.equal(bobStyle.forceLabel, true);
assert.equal(bobStyle.label, "Bob");
const bobIncidentEdges = new Set(["edge_bob_alice"]);
const bobAliceHoverState = resolveEdgeVisualState(
"edge_bob_alice",
"bob",
"alice",
"structure",
"bob",
"",
"",
new Set(),
new Set(),
bobIncidentEdges,
);
assert.equal(bobAliceHoverState, "hovered");
});
test("edge selection maintains canonical edge type labels and active visual state", () => {
loadDeterministicTestGraph();
const edgeCases = [
{ id: "edge_alice_acme", source: "alice", target: "acme", label: "WORKS_AT" },
{ id: "edge_bob_alice", source: "bob", target: "alice", label: "KNOWS" },
{ id: "edge_acme_new_york", source: "acme", target: "new_york", label: "LOCATED_IN" },
];
for (const { id, source, target, label } of edgeCases) {
const attrs = graph.getEdgeAttributes(id);
assert.equal(attrs.edgeType, label);
const visualState = resolveEdgeVisualState(
id,
source,
target,
"inspection",
null,
"",
id, // selected edge
new Set(),
new Set(),
);
assert.equal(visualState, "selected", `Selected edge ${id} must resolve to 'selected' state`);
const style = resolveEdgeElementStyle(
GRAPH_THEME,
"inspection",
"selected",
attrs,
source,
target,
"full",
id,
"selected",
);
assert.equal(style.hidden, false, `Selected edge ${id} (${label}) must not be hidden`);
assert.ok(
style.size !== undefined && style.size > 0,
`Selected edge ${id} (${label}) must have positive render size`,
);
}
});
test("node labels remain forced visible during hover, selection, and inspection zoom tier", () => {
loadDeterministicTestGraph();
const nodes = ["alice", "bob", "acme", "new_york"];
for (const nid of nodes) {
const attrs = graph.getNodeAttributes(nid);
// Hover state forces label visibility
const hoverForcesLabel = shouldForceNodeLabel(GRAPH_THEME, "structure", "hovered", attrs, 0);
assert.equal(hoverForcesLabel, true, `Node ${nid} label must force visible on hover`);
// Selected state forces label visibility
const selectForcesLabel = shouldForceNodeLabel(GRAPH_THEME, "structure", "selected", attrs, 0);
assert.equal(selectForcesLabel, true, `Node ${nid} label must force visible on selection`);
// Resolved style emits actual string label
const style = resolveNodeElementStyle(GRAPH_THEME, "inspection", "hovered", attrs, attrs.label);
assert.equal(style.forceLabel, true);
assert.equal(style.label, attrs.label);
}
});
+2 -1
View File
@@ -5,7 +5,8 @@ import { renderToString } from "react-dom/server";
(globalThis as any).React = React;
import { isSafeUrl, MarkdownContentViewer } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
import { MarkdownContentViewer } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
import { isSafeUrl } from "../src/workspaces/GraphWorkspace/markdownUrlSafety.ts";
test("isSafeUrl permits safe http, https, and mailto URLs and relative paths", () => {
assert.equal(isSafeUrl("https://example.com"), true);
@@ -0,0 +1,150 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTemporalSnapshotGuards } from "../src/workspaces/GraphWorkspace/temporalSnapshotGuards.ts";
const POSITION_1 = new Date("2023-07-02T00:00:00Z").getTime();
const POSITION_2 = new Date("2024-01-02T00:00:00Z").getTime();
const POSITION_3 = new Date("2024-07-02T00:00:00Z").getTime();
const SNAPSHOT = { active_node_ids: ["n1", "n2"], active_node_count: 2 };
// ── begin: one request per scrubber position ─────────────────────────────────
test("begin: a new position returns a fresh request sequence", () => {
const guards = createTemporalSnapshotGuards();
assert.deepEqual(guards.begin(POSITION_1), { seq: 1, cached: null });
});
test("begin: an identical in-flight request is deduplicated (no duplicate fetch)", () => {
const guards = createTemporalSnapshotGuards();
guards.begin(POSITION_1);
assert.deepEqual(guards.begin(POSITION_1), { seq: null, cached: null });
});
test("begin: distinct positions request independently", () => {
const guards = createTemporalSnapshotGuards();
assert.equal(guards.begin(POSITION_1).seq, 1);
assert.equal(guards.begin(POSITION_2).seq, 2);
});
test("begin: revisiting an applied position returns its cached snapshot", () => {
const guards = createTemporalSnapshotGuards();
const { seq } = guards.begin(POSITION_1);
guards.apply(POSITION_1, seq, SNAPSHOT);
const revisit = guards.begin(POSITION_1);
assert.equal(revisit.seq, 2);
assert.deepEqual(revisit.cached, SNAPSHOT);
});
test("begin: a failed position (finished) can be requested again", () => {
const guards = createTemporalSnapshotGuards();
const { seq } = guards.begin(POSITION_1);
guards.finish(POSITION_1, seq);
const retry = guards.begin(POSITION_1);
assert.equal(retry.seq, 2);
assert.equal(retry.cached, null);
});
test("finish: does not clear a position whose snapshot was already applied", () => {
const guards = createTemporalSnapshotGuards();
const { seq } = guards.begin(POSITION_1);
guards.apply(POSITION_1, seq, SNAPSHOT);
guards.finish(POSITION_1, seq);
assert.deepEqual(guards.begin(POSITION_1).cached, SNAPSHOT);
});
test("finish: a stale sequence cannot release a newer request's position", () => {
const guards = createTemporalSnapshotGuards();
const first = guards.begin(POSITION_1);
guards.finish(POSITION_1, first.seq);
guards.begin(POSITION_1); // seq 2, in flight again
guards.finish(POSITION_1, first.seq); // stale seq: must not release seq 2
assert.deepEqual(guards.begin(POSITION_1), { seq: null, cached: null });
});
// ── shouldApply: applied only while the scrubber is on that position ─────────
test("shouldApply: the current position's response is applied", () => {
const guards = createTemporalSnapshotGuards();
const { seq } = guards.begin(POSITION_1);
assert.equal(guards.shouldApply(POSITION_1, seq), true);
});
test("shouldApply: a response for a position the scrubber left is discarded", () => {
const guards = createTemporalSnapshotGuards();
const { seq: seq1 } = guards.begin(POSITION_1);
guards.begin(POSITION_2);
assert.equal(guards.shouldApply(POSITION_1, seq1), false);
assert.equal(guards.shouldApply(POSITION_2, 2), true);
});
test("shouldApply: a late response for the position the scrubber returned to is applied", () => {
const guards = createTemporalSnapshotGuards();
const { seq: seq1 } = guards.begin(POSITION_1);
const { seq: seq2 } = guards.begin(POSITION_2);
guards.begin(POSITION_1); // back to 1: deduplicated, no new request
assert.equal(guards.shouldApply(POSITION_1, seq1), true);
assert.equal(guards.shouldApply(POSITION_2, seq2), false);
});
test("shouldApply: an unknown sequence is discarded", () => {
const guards = createTemporalSnapshotGuards();
guards.begin(POSITION_1);
assert.equal(guards.shouldApply(POSITION_1, 99), false);
});
test("shouldApply: after a reset no pre-reset response applies", () => {
const guards = createTemporalSnapshotGuards();
const { seq } = guards.begin(POSITION_1);
guards.reset();
assert.equal(guards.shouldApply(POSITION_1, seq), false);
});
// ── apply: caching for revisits ─────────────────────────────────────────────
test("apply: stores the snapshot so a revisit re-applies it without a request", () => {
const guards = createTemporalSnapshotGuards();
const { seq } = guards.begin(POSITION_1);
guards.apply(POSITION_1, seq, SNAPSHOT);
guards.begin(POSITION_2);
assert.deepEqual(guards.begin(POSITION_1).cached, SNAPSHOT);
});
test("apply: play wrap-around re-applies the wrapped-to position's snapshot", () => {
const guards = createTemporalSnapshotGuards();
const { seq } = guards.begin(POSITION_1);
guards.apply(POSITION_1, seq, SNAPSHOT);
guards.begin(POSITION_2);
guards.begin(POSITION_3);
const wrap = guards.begin(POSITION_1);
assert.deepEqual(wrap.cached, SNAPSHOT);
assert.equal(guards.shouldApply(POSITION_1, wrap.seq), true);
});
// ── reset: graph reload ─────────────────────────────────────────────────────
test("reset: clears requested and cached state so positions refetch", () => {
const guards = createTemporalSnapshotGuards();
const { seq } = guards.begin(POSITION_1);
guards.apply(POSITION_1, seq, SNAPSHOT);
guards.reset();
const fresh = guards.begin(POSITION_1);
assert.equal(fresh.seq, 1);
assert.equal(fresh.cached, null);
});
// ── cache bound ─────────────────────────────────────────────────────────────
test("cache: oldest positions are evicted when the cache is full", () => {
const guards = createTemporalSnapshotGuards();
const count = 300;
for (let i = 0; i < count; i++) {
const { seq } = guards.begin(POSITION_1 + i * 1000);
guards.apply(POSITION_1 + i * 1000, seq, SNAPSHOT);
}
const oldest = guards.begin(POSITION_1);
assert.equal(oldest.cached, null); // evicted: must refetch on revisit
const newest = guards.begin(POSITION_1 + (count - 1) * 1000);
assert.deepEqual(newest.cached, SNAPSHOT); // still cached
});
+1 -1
View File
@@ -1,7 +1,7 @@
"""
Semantica Framework Integrations
Optional integration packages for agentic frameworks (Google ADK, Claude Agent SDK, Agno, etc.).
Optional integration packages for agentic frameworks (Google ADK, Claude Agent SDK, Agno, CrewAI, LangChain, etc.).
Each integration is self-contained, independently installable via extras_require, and maintains
zero impact on core Semantica - keeping the semantic layer lean while maximizing ecosystem reach.
"""
+10 -13
View File
@@ -277,25 +277,22 @@ class AgnoKnowledgeGraph(_KnowledgeBase): # type: ignore[misc]
def load_urls(self, urls: List[str]) -> None:
"""Fetch each URL and ingest the response body.
Only ``http`` and ``https`` schemes are permitted to prevent SSRF.
Uses the shared SSRF guard so that ``http`` and ``https`` are the only
permitted schemes, private/loopback/link-local/cloud-metadata addresses
are blocked by default, DNS resolution is validated, and every redirect
hop is re-checked before being followed.
"""
import urllib.request
from urllib.parse import urlparse
from semantica.ingest.ssrf import request_with_ssrf_guard
from semantica.utils.exceptions import ValidationError
for url in urls:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
logger.warning(
"Skipping URL with disallowed scheme '%s': %s",
parsed.scheme,
url,
)
continue
try:
with urllib.request.urlopen(url, timeout=10) as resp: # noqa: S310
text = resp.read().decode("utf-8", errors="replace")
response = request_with_ssrf_guard("GET", url, timeout=10)
text = response.text
self._ingest_text(text, source=url)
logger.info("Loaded URL: %s", url)
except ValidationError as exc:
logger.warning("Skipping URL (SSRF check failed) %s: %s", url, exc)
except Exception as exc:
logger.warning("Failed to fetch %s: %s", url, exc)
+67
View File
@@ -0,0 +1,67 @@
# Semantica × LangChain
Drop Semantica into existing LangChain / LangGraph pipelines: GraphRAG-style
retrieval, a `VectorStore` adapter, and agent tools.
## Install
```bash
pip install semantica[langchain]
# or just the core adapter dependency:
pip install langchain-core
```
## Retriever (GraphRAG)
```python
from integrations.langchain import SemanticaRetriever
from semantica.context import ContextGraph
from semantica.vector_store import HybridSearch
graph = ContextGraph()
hybrid = HybridSearch()
retriever = SemanticaRetriever(graph=graph, hybrid=hybrid, hops=2, top_k=10)
# Use with any LangChain chain that accepts a retriever:
from langchain.chains import RetrievalQA
qa = RetrievalQA.from_chain_type(llm=llm, retriever=retriever)
```
Hybrid search seeds retrieval; then graph edges are walked `hops` steps so
results go beyond flat vector similarity.
## VectorStore
```python
from integrations.langchain import SemanticaVectorStore
store = SemanticaVectorStore(hybrid=hybrid)
store.add_texts(["document one", "document two"], metadatas=[{"source": "a"}, {"source": "b"}])
docs = store.similarity_search("document", k=2)
docs, scores = store.similarity_search_with_score("document", k=2)
```
## Agent tools (LangGraph / tool-calling agents)
```python
from integrations.langchain import SemanticaKGTool, SemanticaDecisionTool
from langgraph.prebuilt import create_react_agent
tools = [
SemanticaKGTool(graph),
SemanticaDecisionTool(graph),
]
agent = create_react_agent(model, tools)
```
- `semantica_query_graph` — query the shared context graph (keyword / NL)
- `semantica_query_decisions` — search the recorded decision log
## Compatibility
- Requires `langchain-core >= 0.3`.
- All classes degrade gracefully when `langchain-core` is absent: they remain
importable (carrying the full Semantica API), and `build()` returns `None`,
so agents can branch on `LANGCHAIN_AVAILABLE`.
+48
View File
@@ -0,0 +1,48 @@
"""
Semantica × LangChain Integration
=================================
First-class integration between the Semantica semantic intelligence stack and
the `LangChain <https://github.com/langchain-ai/langchain>`_ / LangGraph
ecosystem.
Public surface
--------------
SemanticaRetriever ``BaseRetriever`` with multi-hop GraphRAG (walks graph
edges from hybrid-search hits)
SemanticaVectorStore ``VectorStore`` adapter over Semantica's hybrid search
(drop-in for RetrievalQA / LCEL chains)
SemanticaKGTool ``BaseTool`` for querying the context graph
SemanticaDecisionTool ``BaseTool`` exposing the recorded decision log
Quick start
-----------
pip install semantica[langchain]
>>> from integrations.langchain import (
... SemanticaRetriever,
... SemanticaVectorStore,
... SemanticaKGTool,
... SemanticaDecisionTool,
... )
Compatibility
-------------
Requires ``langchain-core >= 0.3``. All classes degrade gracefully when
``langchain-core`` is not installed they are still importable and carry the
full Semantica API, but cannot be bound to LangChain chains/agents.
"""
from .retriever import LANGCHAIN_AVAILABLE, SemanticaRetriever
from .tools import SemanticaDecisionTool, SemanticaKGTool
from .vectorstore import SemanticaVectorStore
__all__ = [
"SemanticaRetriever",
"SemanticaVectorStore",
"SemanticaKGTool",
"SemanticaDecisionTool",
"LANGCHAIN_AVAILABLE",
]
__version__ = "0.1.0"
+216
View File
@@ -0,0 +1,216 @@
"""
SemanticaRetriever LangChain ``BaseRetriever`` with multi-hop GraphRAG.
Hybrid search seeds the retrieval, then graph edges are walked for ``hops``
steps so results go beyond flat vector similarity.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from semantica.utils.logging import get_logger
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Optional: LangChain core
# ---------------------------------------------------------------------------
LANGCHAIN_AVAILABLE = False
LANGCHAIN_IMPORT_ERROR: Optional[str] = None
_BaseRetriever: Any = object
_Document: Any = None
def _get_document(**kwargs: Any) -> Any:
"""Instantiate a langchain Document lazily (keeps the import optional)."""
if _Document is None: # pragma: no cover - exercised only with langchain
raise RuntimeError(LANGCHAIN_IMPORT_ERROR or "langchain-core not installed")
return _Document(**kwargs)
try:
from langchain_core.documents import Document as _Document # type: ignore
from langchain_core.retrievers import (
BaseRetriever as _BaseRetriever, # type: ignore
)
LANGCHAIN_AVAILABLE = True
except ImportError: # pragma: no cover - exercised only without langchain
LANGCHAIN_IMPORT_ERROR = (
"langchain-core is not installed. Install with: pip install langchain-core"
)
logger.debug(LANGCHAIN_IMPORT_ERROR)
def _hit_layers(hit: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""Nested HybridSearch metadata and ContextGraph.query node, if present."""
metadata = hit.get("metadata") if isinstance(hit.get("metadata"), dict) else {}
node = hit.get("node") if isinstance(hit.get("node"), dict) else {}
return metadata, node
def _hit_id(hit: Dict[str, Any]) -> Optional[str]:
"""Graph node id, preferring metadata over a HybridSearch vector id."""
metadata, node = _hit_layers(hit)
return (
hit.get("node_id")
or metadata.get("node_id")
or node.get("id")
or node.get("node_id")
or hit.get("id")
)
def _hit_content(hit: Dict[str, Any], fallback: str = "") -> str:
metadata, node = _hit_layers(hit)
props = node.get("properties") if isinstance(node.get("properties"), dict) else {}
return (
hit.get("content")
or hit.get("text")
or metadata.get("content")
or metadata.get("text")
or props.get("content")
or fallback
)
def _hit_type(hit: Dict[str, Any]) -> str:
metadata, node = _hit_layers(hit)
return (
hit.get("node_type")
or hit.get("type")
or metadata.get("node_type")
or metadata.get("type")
or node.get("type")
or node.get("node_type")
or "node"
)
def _hit_score(hit: Dict[str, Any], default: float = 1.0) -> float:
return float(hit.get("score") if hit.get("score") is not None else hit.get("distance") or default)
class SemanticaRetriever(_BaseRetriever): # type: ignore[misc]
"""GraphRAG-style retriever over a Semantica ``ContextGraph``.
Args:
graph: A semantica.context.ContextGraph instance.
hybrid: A semantica.vector_store.HybridSearch instance used to seed
retrieval. If omitted, a best-effort keyword search on the graph
is used.
hops: Number of graph-edge expansion hops (default 2).
top_k: Number of seed hits (default 10).
"""
graph: Any
hybrid: Any = None
hops: int = 2
top_k: int = 10
def __init__(
self,
graph: Any,
hybrid: Any = None,
hops: int = 2,
top_k: int = 10,
**kwargs: Any,
) -> None:
"""Explicit init so the retriever works with and without langchain."""
if LANGCHAIN_AVAILABLE:
# BaseRetriever is a Pydantic model: pass the declared fields
# through so validation succeeds.
super().__init__(
graph=graph,
hybrid=hybrid,
hops=hops,
top_k=top_k,
**kwargs,
)
else:
# Without langchain-core, BaseRetriever is a plain object
super().__init__() # type: ignore[call-arg]
self.graph = graph
self.hybrid = hybrid
self.hops = hops
self.top_k = top_k
def _get_relevant_documents(self, query: str, **kwargs: Any) -> List[Any]:
"""LangChain BaseRetriever entry point."""
seed = self._seed_results(query)
if not seed:
return []
# Expand each seed node through the graph
expanded: Dict[str, Dict[str, Any]] = {}
for hit in seed:
node_id = _hit_id(hit)
if not node_id:
continue
metadata, _ = _hit_layers(hit)
expanded[node_id] = {
"content": _hit_content(hit, fallback=str(node_id)),
"node_type": _hit_type(hit),
"score": _hit_score(hit),
"metadata": metadata,
}
try:
neighbors = self.graph.get_neighbors(node_id, hops=self.hops)
for neighbor in neighbors:
nid = neighbor.get("node_id") or neighbor.get("id")
if nid and nid not in expanded:
expanded[nid] = {
"content": neighbor.get("content")
or neighbor.get("text")
or neighbor.get("name")
or str(nid),
"node_type": neighbor.get("node_type")
or neighbor.get("type")
or "node",
"score": float(neighbor.get("weight") or 0.5),
"metadata": {},
}
except Exception as exc: # graph expansion is best-effort
logger.debug("graph expansion failed for %s: %s", node_id, exc)
# Order: seed hits first (they have real scores), then neighbors.
# Keep a deterministic id->payload list (sets are unordered — see Qodo).
ordered_pairs: List[tuple] = []
seen_ids = set()
for hit in seed:
nid = _hit_id(hit)
if nid and nid in expanded and nid not in seen_ids:
ordered_pairs.append((nid, expanded[nid]))
seen_ids.add(nid)
for nid, item in expanded.items():
if nid not in seen_ids:
ordered_pairs.append((nid, item))
seen_ids.add(nid)
return [
_get_document(
page_content=item["content"],
metadata={
**item["metadata"],
"node_id": nid,
"node_type": item["node_type"],
"score": item["score"],
},
)
for nid, item in ordered_pairs
]
def _seed_results(self, query: str) -> List[Dict[str, Any]]:
"""Get seed results from hybrid search or a graph keyword scan."""
if self.hybrid is not None:
try:
return self.hybrid.search(query, k=self.top_k)
except Exception as exc:
logger.debug("hybrid search failed, falling back: %s", exc)
# Best-effort keyword scan over graph nodes (ContextGraph.query)
try:
return self.graph.query(query, limit=self.top_k)
except Exception:
return []
+133
View File
@@ -0,0 +1,133 @@
"""
SemanticaKGTool / SemanticaDecisionTool LangChain ``BaseTool`` adapters
for LangChain / LangGraph agents.
"""
from __future__ import annotations
import json
from typing import Any, Optional, Type
from pydantic import BaseModel, ConfigDict, Field
from semantica.utils.logging import get_logger
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Optional: LangChain core
# ---------------------------------------------------------------------------
LANGCHAIN_AVAILABLE = False
LANGCHAIN_IMPORT_ERROR: Optional[str] = None
_BaseTool: Any = object
try:
from langchain_core.tools import BaseTool as _BaseTool # type: ignore
LANGCHAIN_AVAILABLE = True
except ImportError: # pragma: no cover
LANGCHAIN_IMPORT_ERROR = (
"langchain-core is not installed. Install with: pip install langchain-core"
)
logger.debug(LANGCHAIN_IMPORT_ERROR)
def _json(payload: Any) -> str:
return json.dumps(payload, default=str, ensure_ascii=False)
class QueryGraphInput(BaseModel):
query: str = Field(..., description="Natural-language or keyword graph query")
limit: int = Field(10, description="Maximum matching nodes to return")
class QueryDecisionsInput(BaseModel):
category: str = Field(
"",
description="Keyword to search recorded decisions; empty returns insights",
)
limit: int = Field(10, description="Maximum results when searching by keyword")
class SemanticaKGTool(_BaseTool): # type: ignore[misc]
"""LangChain tool for querying a Semantica ``ContextGraph``.
Args:
graph: A semantica.context.ContextGraph instance.
Example:
>>> tool = SemanticaKGTool(graph)
>>> agent = create_react_agent(model, tools=[tool])
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
name: str = "semantica_query_graph"
description: str = (
"Query Semantica's shared context graph with a natural-language "
"keyword query. Returns matching entities and relationships."
)
args_schema: Type[BaseModel] = QueryGraphInput
graph: Any = None
def __init__(self, graph: Any = None, **kwargs: Any) -> None:
if LANGCHAIN_AVAILABLE:
super().__init__(graph=graph, **kwargs)
else:
super().__init__()
self.graph = graph
def build(self) -> Any:
"""Return this tool, or None if langchain-core is missing."""
return self if LANGCHAIN_AVAILABLE else None
def _run(self, query: str, limit: int = 10, **kwargs: Any) -> str:
try:
return _json(self.graph.query(query, limit=limit))
except Exception as exc:
return _json({"error": str(exc)})
async def _arun(self, query: str, limit: int = 10, **kwargs: Any) -> str:
return self._run(query, limit=limit)
class SemanticaDecisionTool(_BaseTool): # type: ignore[misc]
"""LangChain tool for searching Semantica's recorded decision log.
Args:
graph: A semantica.context.ContextGraph instance.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
name: str = "semantica_query_decisions"
description: str = (
"Search Semantica's recorded decision log with a keyword query. "
"Returns decisions, rationale, and context."
)
args_schema: Type[BaseModel] = QueryDecisionsInput
graph: Any = None
def __init__(self, graph: Any = None, **kwargs: Any) -> None:
if LANGCHAIN_AVAILABLE:
super().__init__(graph=graph, **kwargs)
else:
super().__init__()
self.graph = graph
def build(self) -> Any:
"""Return this tool, or None if langchain-core is missing."""
return self if LANGCHAIN_AVAILABLE else None
def _run(self, category: str = "", limit: int = 10, **kwargs: Any) -> str:
try:
if category:
return _json(self.graph.query(category, limit=limit))
return _json(self.graph.get_decision_insights())
except Exception as exc:
return _json({"error": str(exc)})
async def _arun(self, category: str = "", limit: int = 10, **kwargs: Any) -> str:
return self._run(category=category, limit=limit)
+143
View File
@@ -0,0 +1,143 @@
"""
SemanticaVectorStore LangChain ``VectorStore`` adapter over Semantica's
hybrid search (``semantica.vector_store.HybridSearch``).
"""
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Optional
from semantica.utils.logging import get_logger
from .retriever import _hit_content, _hit_id, _hit_score, _hit_type, _hit_layers
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Optional: LangChain core
# ---------------------------------------------------------------------------
LANGCHAIN_AVAILABLE = False
LANGCHAIN_IMPORT_ERROR: Optional[str] = None
_VectorStoreBase: Any = object
_Document: Any = None
def _make_document(**kwargs: Any) -> Any:
if _Document is None: # pragma: no cover
raise RuntimeError(LANGCHAIN_IMPORT_ERROR or "langchain-core not installed")
return _Document(**kwargs)
try:
from langchain_core.documents import Document as _Document # type: ignore
from langchain_core.vectorstores import (
VectorStore as _VectorStoreBase, # type: ignore
)
LANGCHAIN_AVAILABLE = True
except ImportError: # pragma: no cover
LANGCHAIN_IMPORT_ERROR = (
"langchain-core is not installed. Install with: pip install langchain-core"
)
logger.debug(LANGCHAIN_IMPORT_ERROR)
def _document_from_hit(hit: Dict[str, Any], include_score: bool = True) -> Any:
metadata, _ = _hit_layers(hit)
node_id = _hit_id(hit)
doc_meta = {
**metadata,
"node_id": node_id,
"node_type": _hit_type(hit),
}
if include_score:
doc_meta["score"] = _hit_score(hit, default=0.0)
return _make_document(
page_content=_hit_content(hit),
metadata=doc_meta,
)
class SemanticaVectorStore(_VectorStoreBase): # type: ignore[misc]
"""Wrap Semantica hybrid search as a LangChain ``VectorStore``.
Args:
hybrid: A semantica.vector_store.HybridSearch instance.
vector_store: Optional Semantica vector store passed through to
``HybridSearch.add_texts``.
"""
hybrid: Any
vector_store: Any = None
def __init__(self, hybrid: Any, vector_store: Any = None, **kwargs: Any) -> None:
if LANGCHAIN_AVAILABLE:
super().__init__(**kwargs)
else:
super().__init__()
self.hybrid = hybrid
self.vector_store = vector_store
# -- required VectorStore API ------------------------------------------
def add_texts(
self,
texts: Iterable[str],
metadatas: Optional[List[Dict[str, Any]]] = None,
**kwargs: Any,
) -> List[str]:
"""Embed and store texts; return the generated IDs.
Delegates to the Semantica ``VectorStore.add_documents`` backing the
HybridSearch instance (or to ``hybrid.vector_store`` if provided).
"""
if self.vector_store is not None:
return self.vector_store.add_documents(
list(texts), metadata=metadatas, **kwargs
)
vs = getattr(self.hybrid, "vector_store", None)
if vs is not None and hasattr(vs, "add_documents"):
return vs.add_documents(list(texts), metadata=metadatas, **kwargs)
raise ValueError(
"SemanticaVectorStore requires a Semantica vector store with "
"add_documents (pass vector_store=... to the HybridSearch or to "
"SemanticaVectorStore)"
)
def similarity_search(self, query: str, k: int = 4, **kwargs: Any) -> List[Any]:
"""Return documents most similar to the query."""
return [_document_from_hit(hit) for hit in self.hybrid.search(query, k=k)]
def similarity_search_with_score(
self, query: str, k: int = 4, **kwargs: Any
) -> List[Any]:
"""Return (document, score) pairs."""
return [
(
_document_from_hit(hit, include_score=False),
_hit_score(hit, default=0.0),
)
for hit in self.hybrid.search(query, k=k)
]
@classmethod
def from_texts(
cls,
texts: List[str],
embedding: Any = None,
metadatas: Optional[List[Dict[str, Any]]] = None,
**kwargs: Any,
) -> "SemanticaVectorStore":
"""Build a store from a list of texts (LangChain convention).
Requires a pre-configured ``hybrid`` instance passed via kwargs.
"""
hybrid = kwargs.pop("hybrid", None)
if hybrid is None:
raise ValueError(
"SemanticaVectorStore.from_texts requires a 'hybrid' "
"HybridSearch instance as a keyword argument"
)
store = cls(hybrid=hybrid, **kwargs)
store.add_texts(texts, metadatas=metadatas)
return store
+35 -1
View File
@@ -116,7 +116,41 @@ class OpenClawKGTool:
)
def __init__(self, base_url: str = "http://localhost:8000", timeout: int = 30) -> None:
self.base_url = base_url.rstrip("/")
# Validate base_url at construction time so callers get an immediate,
# actionable error rather than a cryptic failure on the first request.
# allow_private_ips=True because the documented default (localhost:8000)
# is intentionally a local Semantica server; the scheme check and
# URL-structure check still apply unconditionally.
try:
from semantica.ingest.ssrf import validate_url_for_request
validate_url_for_request(base_url, allow_private_ips=True)
except ImportError:
# semantica.ingest not installed in minimal openclaw-only environments;
# mirror the structural checks that validate_url_for_request performs
# unconditionally (before allow_private_ips is consulted), so the
# guarantee in the comment above — "scheme check and URL-structure check
# still apply unconditionally" — holds in this path too.
from urllib.parse import urlparse as _urlparse
if not isinstance(base_url, str) or not base_url.strip():
raise ValueError("OpenClawKGTool base_url must be a non-empty string.")
_parsed = _urlparse(base_url.strip())
_scheme = (_parsed.scheme or "").lower()
if _scheme not in ("http", "https"):
raise ValueError(
f"OpenClawKGTool base_url scheme '{_parsed.scheme}' is not permitted. "
"Only http and https are allowed."
)
if not _parsed.netloc:
raise ValueError(
f"Invalid OpenClawKGTool base_url '{base_url}': "
"URL must include a netloc (domain or host)."
)
if not _parsed.hostname:
raise ValueError(
f"Invalid OpenClawKGTool base_url '{base_url}': "
"URL must include a hostname."
)
self.base_url = base_url.strip().rstrip("/")
self.timeout = timeout
self._session: Any = None
+11
View File
@@ -21,6 +21,17 @@ Configure in Claude Desktop, Windsurf, Cline, Continue, VS Code:
}
"""
import os
# MCP stdio framing IS stdout: any progress bar or console renderer that writes
# to stdout would interleave with the JSON-RPC stream and corrupt framing for
# every client. This package is always used as an MCP stdio server, so force
# progress tracking off for the entire process. Set before importing server /
# tools so the Semantica progress-tracker singleton is never created with
# output enabled (the singleton reads this variable at construction time and
# the enabled.setter re-checks it, so later re-enable attempts are also blocked).
os.environ["SEMANTICA_DISABLE_PROGRESS"] = "1"
# `semantica.__version__` is the authoritative package version — see
# semantica/mcp_server/__init__.py for why it is used directly rather than
# importlib.metadata.version("semantica").
+6 -1
View File
@@ -80,7 +80,12 @@ def handle_export_graph(args: dict) -> dict:
if rdf_fmt:
try:
from semantica.export import RDFExporter
rdf_str = RDFExporter().export_to_rdf(graph, format=rdf_fmt)
# RDFExporter.export_to_rdf() expects the canonical kg dict
# {"entities": [...], "relationships": [...]}, not a ContextGraph
# object. Convert before handing off; passing the raw graph
# caused AttributeError: 'ContextGraph' object has no attribute
# 'get' on every RDF format.
rdf_str = RDFExporter().export_to_rdf(graph.to_kg_dict(), format=rdf_fmt)
return {"format": rdf_fmt, "data": rdf_str}
except Exception as exc:
return {"error": f"RDF export failed: {exc}"}
-9
View File
@@ -187,15 +187,6 @@ def poc_vuln3():
})
return nodes
# Simulate the CSV parser — mirrors export_import.py lines 131-133
def parse_import_csv_row(row: dict) -> dict:
"""Mirrors export_import.py CSV node ID extraction (no sanitization)."""
node_id = row.get("id") or row.get("node_id") or row.get(":ID") or row.get("_id")
return {
"id": str(node_id), # ← UNSANITIZED
"type": row.get("type", "entity"),
}
# Attack payloads
payloads = [
# Header injection payload (chained with VULN-1)
+6 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "semantica"
version = "0.6.6"
version = "0.6.7"
description = "Graph-Native Infrastructure for Context and Accountable AI Systems: context graphs, decision intelligence, full provenance tracking, and explainable reasoning engines — every AI decision traceable, every output auditable."
readme = "README.md"
license = { text = "MIT" }
@@ -124,11 +124,13 @@ shacl = ["pyshacl>=0.25.0"]
db-snowflake = ["snowflake-connector-python>=4.6.0", "cryptography>=49.0.0"]
db-databricks = ["databricks-sdk>=0.60.0", "databricks-sql-connector>=4.0.0"]
db-arrow = ["pyarrow>=24.0.0"]
db-salesforce = ["simple-salesforce>=1.12.0"]
ingest-parquet = ["pyarrow>=24.0.0"]
ingest-arrow = ["pyarrow>=24.0.0"]
ingest-sap = ["requests>=2.28.0"]
db-all = [
"semantica[db-snowflake,db-databricks,db-arrow]"
"semantica[db-snowflake,db-databricks,db-salesforce,db-arrow]"
]
# ---- Embedding / Models ----
@@ -206,6 +208,7 @@ agno = ["agno>=1.0.0"]
# needed (it pulls vulnerable transitive deps like chromadb) and would only
# duplicate the prebuilt tooling users can install separately.
crewai = ["crewai>=0.80.0"]
langchain = ["langchain-core>=0.3.0"]
# ---- File Watching ----
watch = ["watchdog>=6.0.0"]
@@ -253,7 +256,7 @@ explorer-lite = [
# dependency-audit/security gates. Install it explicitly via ``semantica[crewai]``.
all = [
"semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,explorer]",
"semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,agno]"
"semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,agno,langchain]"
]
# ---------------- ENTRYPOINTS ----------------
+791 -314
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -10,7 +10,7 @@ Main exports:
- Config: Configuration management
"""
__version__ = "0.6.6"
__version__ = "0.6.7"
__author__ = "Semantica Contributors"
__license__ = "MIT"
@@ -31,6 +31,7 @@ import hashlib
import json
import sqlite3
import threading
import warnings
from abc import ABC, abstractmethod
from datetime import datetime
from pathlib import Path
@@ -62,6 +63,12 @@ def create_graph_snapshot_record(
"""
Creates a standardized snapshot metadata record for a named graph.
.. deprecated::
``create_graph_snapshot_record()`` is deprecated and will be removed in
a future major version. It has no callers inside Semantica; build the
record inline and checksum it with
:func:`semantica.change_management.compute_checksum` instead.
Args:
version_id: Unique identifier for this snapshot
graph_uri: The underlying named graph URI in the triplet store
@@ -69,6 +76,13 @@ def create_graph_snapshot_record(
description: Purpose or context of the snapshot
metadata: Additional tags or pipeline context
"""
warnings.warn(
"create_graph_snapshot_record() is deprecated and will be removed in a "
"future major version. Build the snapshot record inline and use "
"semantica.change_management.compute_checksum() instead.",
DeprecationWarning,
stacklevel=2,
)
record = {
"label": version_id,
+89 -2
View File
@@ -1732,6 +1732,93 @@ def embed(ctx: click.Context) -> None:
click.echo(ctx.get_help())
def _json_default(obj) -> object:
"""JSON serialiser that converts NumPy scalars/arrays to native Python types.
Falls back to ``str()`` for everything else so the writer never crashes on
unexpected types (e.g. ``datetime``, custom domain objects).
"""
try:
import numpy as np # local import — only needed when result contains numpy
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, np.generic):
return obj.item()
except ImportError:
pass
return str(obj)
def _write_result_output(out_path: Path, result) -> None:
"""Serialize a structured CLI result (dict or list) for ``--output``.
Domain commands like ``deduplicate`` and ``ontology align`` produce dicts
and lists, not numeric matrices routing them through the embeddings
writer rejected their shapes and extensions (.csv is documented for
deduplicate). JSON-family formats serialize anything; CSV serializes a
list of dicts (or a single dict as one row).
Accepted extensions: .json, .jsonl, .csv (no-extension and .txt are
rejected so the path reported to the caller always matches the file
actually created, consistent with every other --output in the CLI).
"""
import json as _json
suffix = out_path.suffix.lower()
# ── JSON ────────────────────────────────────────────────────────────────
if suffix == ".json":
with open(out_path, "w", encoding="utf-8") as fh:
_json.dump(result, fh, indent=2, default=_json_default)
return
# ── JSON Lines ──────────────────────────────────────────────────────────
# Every record must occupy exactly one line. Wrap a bare dict in a list
# so callers never need to know whether their result is singular or plural.
if suffix == ".jsonl":
items = result if isinstance(result, list) else [result]
with open(out_path, "w", encoding="utf-8") as fh:
for item in items:
fh.write(_json.dumps(item, default=_json_default) + "\n")
return
# ── CSV ─────────────────────────────────────────────────────────────────
if suffix == ".csv":
import pandas as pd
rows = result if isinstance(result, list) else [result]
if not rows:
raise click.ClickException(
"No results to write — output file not created."
)
# Normalise numpy scalars/arrays to Python natives so to_csv() does
# not fall back to repr() strings for array-valued cells.
def _normalise(row):
if not isinstance(row, dict):
return row
out = {}
for k, v in row.items():
try:
import numpy as np
if isinstance(v, np.ndarray):
v = v.tolist()
elif isinstance(v, np.generic):
v = v.item()
except ImportError:
pass
out[k] = v
return out
pd.DataFrame([_normalise(r) for r in rows]).to_csv(out_path, index=False)
return
# ── unsupported ─────────────────────────────────────────────────────────
display = suffix if suffix else "(no extension)"
raise click.ClickException(
f"Unsupported output format '{display}'. Use .json, .jsonl, or .csv"
)
@embed.command("generate")
@click.argument("input_path")
@click.option("--model",
@@ -2032,7 +2119,7 @@ def deduplicate(
except ImportError as exc:
raise click.ClickException(f"Deduplication module not available: {exc}") from exc
if output:
Path(output).write_text(json.dumps(result, default=str), encoding="utf-8")
_write_result_output(Path(output), result)
_ok(cli_ctx, f"Wrote {output}")
elif _is_json(cli_ctx, local_json):
_jecho(result if isinstance(result, (dict, list)) else {"result": str(result)})
@@ -3156,7 +3243,7 @@ def ontology_align(cli_ctx: CLIContext, source: str, target: str, strategy: str,
except ImportError as exc:
raise click.ClickException(f"Ontology module not available: {exc}") from exc
if output:
Path(output).write_text(json.dumps(result, default=str), encoding="utf-8")
_write_result_output(Path(output), result)
_ok(cli_ctx, f"Wrote {output}")
elif _is_json(cli_ctx, local_json):
_jecho(result if isinstance(result, dict) else {"alignments": str(result)})
+4 -4
View File
@@ -109,7 +109,7 @@ class ConflictsConfig:
if value:
try:
if type_func == bool:
self._configs[config_key] = value.lower() in (
self._configs[config_key] = value.strip().lower() in (
"true",
"1",
"yes",
@@ -136,12 +136,12 @@ class ConflictsConfig:
if value:
try:
# Try to convert to appropriate type
if isinstance(default, int):
if isinstance(default, bool):
return value.strip().lower() in ("true", "1", "yes", "on")
elif isinstance(default, int):
return int(value)
elif isinstance(default, float):
return float(value)
elif isinstance(default, bool):
return value.lower() in ("true", "1", "yes", "on")
return value
except (ValueError, TypeError):
pass
+304 -11
View File
@@ -899,6 +899,11 @@ class ContextGraph:
return
node.properties.update(attributes)
node.metadata.update(attributes)
# Keep derived decision indexes consistent when a decision node is
# mutated so that category / entity / temporal lookups reflect the
# new property values without requiring a full graph reload.
if (getattr(node, "node_type", None) or "").lower() == "decision":
self._sync_decision_from_node(node_id)
if getattr(self, "mutation_callback", None) and not getattr(
self, "_suspend_mutation_callback", False
@@ -1291,6 +1296,14 @@ class ContextGraph:
if link_id:
self._unresolved_links[link_id] = link_meta
# Rebuild all derived decision indexes from the freshly-loaded
# nodes so that find_precedents_by_scenario, find_similar_decisions,
# and all decision analytics work correctly after a reload.
# _rebuild_decision_indexes() unconditionally clears the old indexes
# first, so repeated load_from_file calls never accumulate stale
# entries from a previous file.
self._rebuild_decision_indexes()
self.logger.info(f"Loaded context graph from {path}")
@staticmethod
@@ -1633,6 +1646,8 @@ class ContextGraph:
self._analytics_cache.clear()
self._retractions.clear()
self._tombstones.clear()
# Rebuild derived decision indexes from the freshly-loaded nodes.
self._rebuild_decision_indexes()
if self.mutation_callback and not self._suspend_mutation_callback:
mutation_events = [
@@ -2825,6 +2840,12 @@ class ContextGraph:
self._unresolved_links.clear()
self._retractions.clear()
self._tombstones.clear()
# Reset derived decision indexes so that decision queries against
# a cleared graph return empty results rather than stale data.
self._decisions = {}
self._decision_index = defaultdict(set)
self._entity_index = defaultdict(set)
self._temporal_index = []
self.logger.debug("Graph state fully cleared.")
# --- Internal Helpers ---
@@ -3482,6 +3503,9 @@ class ContextGraph:
)
self._add_internal_edge(edge)
# Rebuild derived decision indexes from the now-populated node store.
self._rebuild_decision_indexes()
def state_at(self, timestamp: Union[str, int, float, datetime]) -> Dict[str, Any]:
"""Return a serializable snapshot of graph state valid at the given time."""
at_time = self._normalize_timestamp(timestamp)
@@ -4707,6 +4731,7 @@ class ContextGraph:
scenario=decision["scenario"],
decision_maker=decision.get("decision_maker", ""),
reasoning=decision["reasoning"],
recorded_at=decision.get("recorded_at", ""),
**safe_metadata,
**extra_properties,
)
@@ -4788,20 +4813,288 @@ class ContextGraph:
return False
return True
def _calculate_decision_content_similarity(self, scenario: str, decision: Dict[str, Any]) -> float:
"""Calculate content similarity between scenario and decision."""
# ── decision-index helpers ────────────────────────────────────────────────
# Protected set of node properties whose values are *core* decision fields
# so that we can distinguish them from user-supplied metadata when
# rebuilding the in-memory indexes from a persisted node.
_DECISION_CORE_FIELDS: frozenset = frozenset({
"id", "category", "scenario", "reasoning", "outcome", "confidence",
"entities", "decision_maker", "timestamp", "recorded_at",
"valid_from", "valid_until", "content",
})
def _rebuild_decision_indexes(self) -> None:
"""Rebuild all derived decision indexes from the current node store.
This method is the single authoritative rebuild path. It must be
called (under the graph lock) after any operation that wholesale
replaces ``self.nodes`` namely ``load_from_file`` (JSON and Markdown
paths) and ``from_dict``.
Contract:
- Unconditionally clears ``_decisions``, ``_decision_index``,
``_entity_index``, and ``_temporal_index`` before rebuilding so that
repeated calls never accumulate stale entries.
- Derives ``_decisions[node_id]["metadata"]`` from the full set of
node properties, excluding the protected core fields, so that
user-supplied metadata survives the round-trip.
- Runs under ``self._lock`` when called from load paths; callers that
already hold the lock must invoke ``_rebuild_decision_indexes``
inside the lock block.
"""
# Always start fresh so repeated loads don't accumulate stale entries.
self._decisions: Dict[str, Any] = {}
self._decision_index: Dict[str, set] = defaultdict(set)
self._entity_index: Dict[str, set] = defaultdict(set)
self._temporal_index: List[Tuple[str, float]] = []
for node in self.nodes.values():
if (getattr(node, "node_type", None) or "").lower() != "decision":
continue
# Merge metadata and properties; properties win on collision.
meta: Dict[str, Any] = {}
meta.update(getattr(node, "metadata", {}) or {})
meta.update(getattr(node, "properties", {}) or {})
# Timestamp: keep whatever was stored (float epoch or ISO string).
# The temporal index uses it for sorting; downstream code handles
# both types via _normalize_timestamp.
raw_ts = meta.get("timestamp", 0.0)
try:
sort_ts = float(raw_ts)
except (TypeError, ValueError):
sort_ts = 0.0
# Entities may be stored as a list in meta or inferred from
# outgoing "involves" edges if the list field is absent/empty.
# _add_decision_to_graph creates entity nodes connected via
# "involves" edges; it does NOT store the list as a node property.
entities = meta.get("entities") or []
if not isinstance(entities, list):
entities = []
if not entities:
# Recover entity list from "involves" edges on this decision node
for edge in self._adjacency.get(node.node_id, []):
if edge.edge_type == "involves":
entities.append(edge.target_id)
# Everything that isn't a core field is user-supplied metadata.
extra_meta = {
k: v
for k, v in meta.items()
if k not in self._DECISION_CORE_FIELDS
}
decision: Dict[str, Any] = {
"id": node.node_id,
"category": meta.get("category", ""),
"scenario": meta.get("scenario", getattr(node, "content", "") or ""),
"reasoning": meta.get("reasoning", ""),
"outcome": meta.get("outcome", ""),
"confidence": float(meta.get("confidence", 0.0) or 0.0),
"entities": entities,
"decision_maker": meta.get("decision_maker"),
"timestamp": raw_ts,
"recorded_at": meta.get("recorded_at", ""),
"valid_from": getattr(node, "valid_from", None),
"valid_until": getattr(node, "valid_until", None),
# Preserve all non-core node properties as decision metadata so
# that user-supplied fields survive a save → load round-trip.
"metadata": extra_meta,
}
self._decisions[node.node_id] = decision
category = decision["category"]
if category:
self._decision_index[category].add(node.node_id)
for entity in entities:
self._entity_index[entity].add(node.node_id)
self._temporal_index.append((node.node_id, sort_ts))
self._temporal_index.sort(key=lambda x: x[1], reverse=True)
def _sync_decision_from_node(self, node_id: str) -> None:
"""Synchronise a single decision index entry from the node store.
Called after ``add_node_attribute`` mutates a decision node so that
``_decisions`` and the derived indexes stay consistent without
requiring a full rebuild of all decisions.
"""
node = self.nodes.get(node_id)
if node is None:
return
if (getattr(node, "node_type", None) or "").lower() != "decision":
return
if not hasattr(self, "_decisions"):
# Indexes don't exist yet — a full rebuild is safer.
self._rebuild_decision_indexes()
return
# Remove stale index entries for this decision ID.
old = self._decisions.get(node_id)
if old:
old_cat = old.get("category", "")
if old_cat and node_id in self._decision_index.get(old_cat, set()):
self._decision_index[old_cat].discard(node_id)
for ent in old.get("entities", []):
self._entity_index[ent].discard(node_id)
self._temporal_index = [
(nid, ts) for nid, ts in self._temporal_index if nid != node_id
]
# Rebuild the entry for this node and re-insert index entries.
meta: Dict[str, Any] = {}
meta.update(getattr(node, "metadata", {}) or {})
meta.update(getattr(node, "properties", {}) or {})
raw_ts = meta.get("timestamp", 0.0)
try:
# Simple word-based similarity
sort_ts = float(raw_ts)
except (TypeError, ValueError):
sort_ts = 0.0
entities = meta.get("entities") or []
if not isinstance(entities, list):
entities = []
if not entities:
# Recover entity list from "involves" edges
for edge in self._adjacency.get(node_id, []):
if edge.edge_type == "involves":
entities.append(edge.target_id)
extra_meta = {
k: v for k, v in meta.items() if k not in self._DECISION_CORE_FIELDS
}
decision: Dict[str, Any] = {
"id": node_id,
"category": meta.get("category", ""),
"scenario": meta.get("scenario", getattr(node, "content", "") or ""),
"reasoning": meta.get("reasoning", ""),
"outcome": meta.get("outcome", ""),
"confidence": float(meta.get("confidence", 0.0) or 0.0),
"entities": entities,
"decision_maker": meta.get("decision_maker"),
"timestamp": raw_ts,
"recorded_at": meta.get("recorded_at", ""),
"valid_from": getattr(node, "valid_from", None),
"valid_until": getattr(node, "valid_until", None),
"metadata": extra_meta,
}
self._decisions[node_id] = decision
if decision["category"]:
self._decision_index[decision["category"]].add(node_id)
for ent in entities:
self._entity_index[ent].add(node_id)
self._temporal_index.append((node_id, sort_ts))
self._temporal_index.sort(key=lambda x: x[1], reverse=True)
@staticmethod
def _char_bigrams(text: str) -> set:
"""Character bigrams over whitespace-stripped text (CJK fallback).
Strips whitespace so CJK characters without word-separating spaces are
treated as a contiguous character sequence rather than a single token.
"""
chars = "".join(text.lower().split())
return {chars[i:i + 2] for i in range(len(chars) - 1)}
@staticmethod
def _looks_cjk(text: str) -> bool:
"""True if text contains CJK/Japanese/Korean script characters.
Used to gate the character-bigram similarity fallback so it only
activates for scripts where whitespace tokenisation doesn't work.
"""
for ch in text:
code = ord(ch)
if (
0x4E00 <= code <= 0x9FFF # CJK Unified Ideographs
or 0x3400 <= code <= 0x4DBF # CJK Extension A
or 0x3040 <= code <= 0x30FF # Hiragana + Katakana
or 0xAC00 <= code <= 0xD7A3 # Hangul Syllables
or 0x1100 <= code <= 0x11FF # Hangul Jamo
):
return True
return False
def _calculate_decision_content_similarity(self, scenario: str, decision: Dict[str, Any]) -> float:
"""Calculate content similarity between scenario and decision.
Uses word-level Jaccard for space-separated languages. For text where
whitespace tokenisation is unreliable (CJK/Japanese/Korean scripts, or
a query with no whitespace at all) a character-bigram Jaccard is
computed over the *stripped* character sequences instead.
The bigram fallback only activates when whitespace tokenisation would
not help i.e. the query is CJK-like or has at most one whitespace
token so it never contributes for ordinary multi-word English
queries, where incidental bigram overlap between unrelated sentences
would otherwise inflate scores.
The bigram side uses *Jaccard* (|AB|/|AB|), not the overlap
coefficient, so a 2-character query whose single bigram happens to
appear anywhere in a long document does not silently receive a score of
1.0. A minimum bigram set size of 3 is required before the bigram
signal contributes; this prevents 1- and 2-character English queries
from polluting results while still allowing 3-character CJK phrases (2
bigrams) to match.
"""
try:
decision_text = (
f"{decision['scenario']} {decision['reasoning']} "
f"{' '.join(decision['entities'])}"
)
# --- word-level Jaccard (primary metric for Latin/space-delimited) ---
scenario_words = set(scenario.lower().split())
decision_text = f"{decision['scenario']} {decision['reasoning']} {' '.join(decision['entities'])}"
decision_words = set(decision_text.lower().split())
intersection = scenario_words.intersection(decision_words)
union = scenario_words.union(decision_words)
return len(intersection) / len(union) if union else 0.0
except Exception as e:
word_union = scenario_words | decision_words
word_sim = (
len(scenario_words & decision_words) / len(word_union)
if word_union
else 0.0
)
# --- character-bigram Jaccard (CJK / very-short-query fallback) ---
# Only used when whitespace tokenisation can't do the job: CJK-like
# scripts, or a query that is a single whitespace token (no spaces
# to split on). Ordinary multi-word English queries rely on
# word_sim alone, so incidental bigram overlap between unrelated
# sentences can never inflate their score.
bigram_sim = 0.0
needs_bigram_fallback = (
self._looks_cjk(scenario) or len(scenario.split()) <= 1
)
if needs_bigram_fallback:
scenario_bigrams = self._char_bigrams(scenario)
decision_bigrams = self._char_bigrams(decision_text)
# Require at least 3 bigrams in the query before the bigram
# signal is used. A 2-char query produces only 1 bigram; that
# single bigram is far too likely to appear as a substring of
# any English word and would produce a spuriously high overlap
# coefficient. 3 bigrams correspond to a 4-char stripped query
# (e.g. two CJK characters produce 1 bigram each → need ≥3
# chars stripped).
if len(scenario_bigrams) >= 3 and decision_bigrams:
bigram_union = scenario_bigrams | decision_bigrams
bigram_sim = (
len(scenario_bigrams & decision_bigrams) / len(bigram_union)
if bigram_union
else 0.0
)
return max(word_sim, bigram_sim)
except Exception:
self.logger.exception("Content similarity calculation failed")
return 0.0
+15
View File
@@ -6,6 +6,7 @@ including node labels, relationship types, and indexes for graph databases.
"""
import json
import warnings
from typing import Dict, Any, List
from ..graph_store import GraphStore
@@ -460,11 +461,25 @@ def drop_decision_schema(graph_store: GraphStore) -> None:
"""
Drop decision tracking schema (for cleanup/testing).
.. deprecated::
``drop_decision_schema()`` is deprecated and will be removed in a future
major version. It has no callers inside Semantica; issue the DROP
CONSTRAINT / DROP INDEX / DETACH DELETE statements directly against your
:class:`~semantica.graph_store.GraphStore` instead.
Args:
graph_store: Graph database instance
"""
logger = get_logger(__name__)
warnings.warn(
"drop_decision_schema() is deprecated and will be removed in a future "
"major version. Issue the DROP CONSTRAINT / DROP INDEX / DETACH DELETE "
"statements directly against your GraphStore instead.",
DeprecationWarning,
stacklevel=2,
)
try:
# Drop constraints
constraints = [
+4 -4
View File
@@ -110,7 +110,7 @@ class DeduplicationConfig:
if value:
try:
if type_func == bool:
self._configs[config_key] = value.lower() in (
self._configs[config_key] = value.strip().lower() in (
"true",
"1",
"yes",
@@ -137,12 +137,12 @@ class DeduplicationConfig:
if value:
try:
# Try to convert to appropriate type
if isinstance(default, int):
if isinstance(default, bool):
return value.strip().lower() in ("true", "1", "yes", "on")
elif isinstance(default, int):
return int(value)
elif isinstance(default, float):
return float(value)
elif isinstance(default, bool):
return value.lower() in ("true", "1", "yes", "on")
return value
except (ValueError, TypeError):
pass
+29 -31
View File
@@ -147,9 +147,12 @@ def calculate_similarity(
>>> result = calculate_similarity(entity1, entity2, method="levenshtein")
>>> print(f"Similarity: {result.score:.2f}")
"""
# Check for custom method in registry
# Check for custom method in registry, skip self-referential wrappers.
# _multi_factor_similarity is registered under "multi_factor" and calls back
# into calculate_similarity(method="multi_factor"), creating indirect
# infinite recursion. The identity guard short-circuits that loop.
custom_method = method_registry.get("similarity", method)
if custom_method:
if custom_method and custom_method is not calculate_similarity:
return custom_method(entity1, entity2, **kwargs)
# Use default SimilarityCalculator
@@ -235,9 +238,11 @@ def detect_duplicates(
>>> duplicates = detect_duplicates(entities, method="pairwise", similarity_threshold=0.8)
>>> print(f"Found {len(duplicates)} duplicate candidates")
"""
# Check for custom method in registry
# Check for custom method in registry, skip self-referential wrappers.
# _pairwise_detection is registered under "pairwise" and calls back into
# detect_duplicates(method="pairwise"), creating indirect infinite recursion.
custom_method = method_registry.get("detection", method)
if custom_method:
if custom_method and custom_method is not detect_duplicates:
return custom_method(
entities, similarity_threshold=similarity_threshold, **kwargs
)
@@ -282,9 +287,10 @@ def dedup_triplets(
List of duplicate relationship piars (rel1, rel2).
"""
# Check for custom method in registry (but not ourself)
# Check for custom method in registry (but not ourself — identity guard
# consistent with the other dispatch functions in this module).
custom_method = method_registry.get("detection", "triplets")
if custom_method and custom_method.__name__ != "dedup_triplets":
if custom_method and custom_method is not dedup_triplets:
return custom_method(relationships, mode=mode, threshold=threshold, **kwargs)
detector = DuplicateDetector(**kwargs)
@@ -328,9 +334,11 @@ def merge_entities(
>>> operations = merge_entities(duplicate_entities, method="keep_most_complete")
>>> print(f"Performed {len(operations)} merge operations")
"""
# Check for custom method in registry
# Check for custom method in registry, skip self-referential registration.
# merge_entities is now registered directly under its default method name;
# the identity guard prevents a direct recursion loop.
custom_method = method_registry.get("merging", method)
if custom_method:
if custom_method and custom_method is not merge_entities:
return custom_method(
entities, preserve_provenance=preserve_provenance, **kwargs
)
@@ -374,9 +382,12 @@ def build_clusters(
>>> result = build_clusters(entities, method="graph_based", similarity_threshold=0.8)
>>> print(f"Found {len(result.clusters)} clusters")
"""
# Check for custom method in registry
# Check for custom method in registry, skip self-referential wrappers.
# _graph_based_clustering is registered under "graph_based" and calls back
# into build_clusters(method="graph_based"), creating indirect infinite
# recursion.
custom_method = method_registry.get("clustering", method)
if custom_method:
if custom_method and custom_method is not build_clusters:
return custom_method(
entities, similarity_threshold=similarity_threshold, **kwargs
)
@@ -546,25 +557,12 @@ def list_available_methods(task: Optional[str] = None) -> Dict[str, List[str]]:
return result
# Register default methods with registry
def _multi_factor_similarity(e1, e2, **kw):
return calculate_similarity(e1, e2, method="multi_factor", **kw)
def _pairwise_detection(entities, **kw):
return detect_duplicates(entities, method="pairwise", **kw)
def _keep_most_complete_merging(entities, **kw):
return merge_entities(entities, method="keep_most_complete", **kw)
def _graph_based_clustering(entities, **kw):
return build_clusters(entities, method="graph_based", **kw)
method_registry.register("similarity", "multi_factor", _multi_factor_similarity)
method_registry.register("detection", "pairwise", _pairwise_detection)
method_registry.register("merging", "keep_most_complete", _keep_most_complete_merging)
method_registry.register("clustering", "graph_based", _graph_based_clustering)
# Register default methods with registry.
# The public dispatch functions are registered directly so the identity guard
# in each function short-circuits the self-reference rather than going through
# an intermediate wrapper that re-enters the same dispatch path.
method_registry.register("similarity", "multi_factor", calculate_similarity)
method_registry.register("detection", "pairwise", detect_duplicates)
method_registry.register("merging", "keep_most_complete", merge_entities)
method_registry.register("clustering", "graph_based", build_clusters)
method_registry.register("detection", "triplets", dedup_triplets)
+6 -6
View File
@@ -106,7 +106,7 @@ class EmbeddingsConfig:
if value:
try:
if type_func == bool:
self._configs[config_key] = value.lower() in (
self._configs[config_key] = value.strip().lower() in (
"true",
"1",
"yes",
@@ -123,8 +123,8 @@ class EmbeddingsConfig:
if key.startswith(env_prefix) and key not in env_mappings:
config_key = key[len(env_prefix) :].lower()
# Try to convert to appropriate type
if value.lower() in ("true", "false"):
self._configs[config_key] = value.lower() == "true"
if value.strip().lower() in ("true", "false"):
self._configs[config_key] = value.strip().lower() == "true"
elif value.isdigit():
self._configs[config_key] = int(value)
else:
@@ -149,12 +149,12 @@ class EmbeddingsConfig:
if value:
try:
# Try to convert to appropriate type
if isinstance(default, int):
if isinstance(default, bool):
return value.strip().lower() in ("true", "1", "yes", "on")
elif isinstance(default, int):
return int(value)
elif isinstance(default, float):
return float(value)
elif isinstance(default, bool):
return value.lower() in ("true", "1", "yes", "on")
return value
except (ValueError, TypeError):
pass
+3 -13
View File
@@ -2,8 +2,9 @@
Semantica Explorer : FastAPI Dependencies
Provides ``Depends()``-compatible callables for injecting the
current ``GraphSession`` and ``ConnectionManager`` into route handlers,
and for enforcing API-key authentication on protected routes.
current ``GraphSession`` into route handlers, and for enforcing API-key
authentication on protected routes. WebSocket manager access is handled
directly via ``app.state.ws_manager``.
"""
import hmac
@@ -14,7 +15,6 @@ from fastapi import Request, HTTPException, Security, status
from fastapi.security.api_key import APIKeyHeader
from .session import GraphSession
from .ws import ConnectionManager
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
@@ -80,13 +80,3 @@ def get_session(request: Request) -> GraphSession:
detail="GraphSession not initialized."
)
return request.app.state.session
def get_ws_manager(request: Request) -> ConnectionManager:
"""Retrieve the ConnectionManager stored on ``app.state``."""
if not hasattr(request.app.state, "ws_manager") or request.app.state.ws_manager is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="WebSocket manager not initialized.",
)
return request.app.state.ws_manager
-60
View File
@@ -78,66 +78,6 @@ def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float,
return min_x, min_y, max_x, max_y
def _coerce_embedding_vector(value: object) -> Optional[List[float]]:
if isinstance(value, dict):
# Probe keys in priority order: generic first, then framework-specific.
# Must stay aligned with the top-level keys in _extract_node_embeddings.
for key in ("embedding", "embeddings", "vector", "values", "node2vec", "semantic"):
nested = _coerce_embedding_vector(value.get(key))
if nested is not None:
return nested
return None
if not isinstance(value, (list, tuple)):
return None
vector: List[float] = []
for item in value:
try:
vector.append(float(item))
except (TypeError, ValueError):
return None
return vector if vector else None
def _extract_node_embeddings(graph_dict: dict) -> dict[str, List[float]]:
"""Extract embeddings from graph dictionary."""
# Top-level keys to probe on each entity (and its metadata/properties dicts).
# Priority: generic names first, then KG-extras-specific names.
# Must stay aligned with the inner probe list in _coerce_embedding_vector.
embedding_keys = (
"embedding",
"embeddings",
"vector",
"node_embedding",
"node2vec_embedding",
"semantic_embedding",
"reasoning_embedding",
)
embeddings: dict[str, List[float]] = {}
for entity in graph_dict.get("entities") or graph_dict.get("nodes") or []:
if not isinstance(entity, dict):
continue
node_id = entity.get("id") or entity.get("node_id")
if not node_id:
continue
metadata = entity.get("metadata") if isinstance(entity.get("metadata"), dict) else {}
properties = entity.get("properties") if isinstance(entity.get("properties"), dict) else {}
for key in embedding_keys:
vector = _coerce_embedding_vector(
entity.get(key, metadata.get(key, properties.get(key)))
)
if vector is not None:
embeddings[str(node_id)] = vector
break
return embeddings
def _get_cached_embeddings(session: GraphSession) -> dict[str, List[float]]:
"""Get embeddings from session cache for optimal performance."""
return session.get_cached_embeddings()
-4
View File
@@ -456,10 +456,6 @@ class DraftResponse(BaseModel):
updated_at: str
class ProposalState(BaseModel):
state: Literal["draft", "proposed", "approved", "published", "rejected"]
class ProposalRequest(BaseModel):
draft_id: str
ontology_uri: str
-23
View File
@@ -8,11 +8,6 @@ from typing import Any, Dict, List, Literal, Optional, Tuple
from pydantic import BaseModel, Field, field_validator
class ErrorResponse(BaseModel):
detail: str
status_code: int = 500
class NodeResponse(BaseModel):
id: str
type: str
@@ -187,12 +182,6 @@ class ComplianceResponse(BaseModel):
violations: List[Dict[str, Any]] = Field(default_factory=list)
class TemporalSnapshotResponse(BaseModel):
timestamp: str
active_nodes: List[NodeResponse]
active_node_count: int
class TemporalDiffResponse(BaseModel):
from_time: str
to_time: str
@@ -256,13 +245,6 @@ class ExportRequest(BaseModel):
node_ids: Optional[List[str]] = None
class ExportResponse(BaseModel):
format: str
content_type: str
filename: str
size_bytes: int = 0
class ImportResponse(BaseModel):
status: str = "success"
message: str = "Import successful"
@@ -272,11 +254,6 @@ class ImportResponse(BaseModel):
edges_imported: Optional[int] = None
class StandardMessageResponse(BaseModel):
status: str
message: str
class AnnotationCreate(BaseModel):
node_id: str
content: str
+6 -6
View File
@@ -105,7 +105,7 @@ class ExportConfig:
if value:
try:
if type_func == bool:
self._configs[config_key] = value.lower() in (
self._configs[config_key] = value.strip().lower() in (
"true",
"1",
"yes",
@@ -122,8 +122,8 @@ class ExportConfig:
if key.startswith(env_prefix) and key not in env_mappings:
config_key = key[len(env_prefix) :].lower()
# Try to convert to appropriate type
if value.lower() in ("true", "false"):
self._configs[config_key] = value.lower() == "true"
if value.strip().lower() in ("true", "false"):
self._configs[config_key] = value.strip().lower() == "true"
elif value.isdigit():
self._configs[config_key] = int(value)
else:
@@ -148,12 +148,12 @@ class ExportConfig:
if value:
try:
# Try to convert to appropriate type
if isinstance(default, int):
if isinstance(default, bool):
return value.strip().lower() in ("true", "1", "yes", "on")
elif isinstance(default, int):
return int(value)
elif isinstance(default, float):
return float(value)
elif isinstance(default, bool):
return value.lower() in ("true", "1", "yes", "on")
return value
except (ValueError, TypeError):
pass
+30 -11
View File
@@ -306,10 +306,10 @@ class JSONExporter:
self.logger.debug(f"Exporting {len(entities)} entity(ies) to JSON")
# Build JSON data with JSON-LD context
# Build JSON data with JSON-LD context. No @vocab: it would expand
# every bare key in the caller's entity dicts into ns# (#1146).
json_data = {
"@context": {
"@vocab": "https://semantica.dev/vocab/",
"semantica": SEMANTICA_NS,
"entities": {"@id": "semantica:entities", "@container": "@list"},
},
@@ -339,7 +339,6 @@ class JSONExporter:
"""
json_data = {
"@context": {
"@vocab": "https://semantica.dev/vocab/",
"semantica": SEMANTICA_NS,
"relationships": {
"@id": "semantica:relationships",
@@ -434,11 +433,14 @@ class JSONExporter:
Returns:
Dictionary in JSON-LD format with @context, @graph/@value, and metadata
"""
# Initialize JSON-LD structure with context
# Initialize JSON-LD structure with context. No @vocab: for a generic
# payload it turned whatever bare keys the caller happened to use into
# ns# terms (#1146). Undeclared terms now simply expand to nothing,
# which is standard JSON-LD behaviour for a context that does not
# know them; the raw payload is still in the document.
jsonld = {
"@context": {
"@vocab": "https://semantica.dev/vocab/",
"semantica": "https://semantica.dev/ns#",
"semantica": SEMANTICA_NS,
}
}
@@ -598,13 +600,21 @@ class JSONExporter:
Returns:
Dictionary in JSON-LD format with @context, @id, @type, and graph data
"""
# Initialize JSON-LD structure with RDF context
# Initialize JSON-LD structure with RDF context. No @vocab: it applied
# to every bare term in caller data, so an extracted type like "ORG"
# became ns#ORG and a metadata key like "source" collided with the
# real sem:source object property (#1146). Only explicit semantica:
# terms resolve now, and the caller's metadata dict is typed @json so
# it survives as one rdf:JSON literal instead of expanding its keys.
jsonld = {
"@context": {
"@vocab": "https://semantica.dev/vocab/",
"semantica": "https://semantica.dev/ns#",
"semantica": SEMANTICA_NS,
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
"semantica:metadata": {
"@id": "semantica:metadata",
"@type": "@json",
},
},
# Minted from the graph's own content rather than the wall clock
# (#1147): re-exporting an unchanged graph must produce the same
@@ -664,14 +674,23 @@ class JSONExporter:
entity_text = entity.get("text") or entity.get("label", "unknown")
entity_id = entity.get("id") or mint_entity_iri(entity_text)
# The caller's type label is data, not a class we define: minting it
# into @type expanded it through @vocab into ns#ORG and friends, terms
# that look official but do not exist (#1146). The node is always a
# semantica:Entity and the label travels as semantica:type, exactly
# how _relationship_to_jsonld has always carried the relationship type.
jsonld = {
"@id": entity_id,
"@type": entity.get("type") or "semantica:Entity",
"@type": "semantica:Entity",
"semantica:text": entity.get("text") or entity.get("label", ""),
"semantica:confidence": entity.get("confidence", 1.0),
}
entity_type = entity.get("type")
if entity_type:
jsonld["semantica:type"] = entity_type
# Add metadata if present
# Add metadata if present. The @json term definition on
# semantica:metadata keeps the whole dict one rdf:JSON literal.
if "metadata" in entity:
jsonld["semantica:metadata"] = entity["metadata"]
+478 -18
View File
@@ -146,6 +146,294 @@ def mint_relationship_iri(index: int, source: Any, target: Any) -> str:
return f"{SEMANTICA_NS}rel_{index}_{digest}"
#: The metadata keys Semantica itself produces, and the terms they are written
#: as. GraphBuilder.build_graph writes the first five, create_snapshot writes
#: snapshot_time, and load_from_neo4j writes source / uri / database. These are
#: Semantica's own vocabulary, so they are minted in the declared namespace and
#: declared in semantica-ns.ttl.
#:
#: A key the caller supplied is a different matter. Which namespace an
#: arbitrary metadata key belongs in is issue #1146, and until that is settled
#: the exporter refuses to guess: it warns and skips, and a caller who already
#: knows the answer passes ``metadata_terms``.
#:
#: The map is key -> term rather than key -> namespace because two of the keys
#: cannot keep their own name. ``source`` on a graph loaded from Neo4j is the
#: system it came from, while sem:source is already the ObjectProperty holding
#: the subject of a reified relationship; reusing it would put a string where
#: an entity belongs.
DEFAULT_METADATA_TERMS: Dict[str, str] = {
"num_entities": f"{SEMANTICA_NS}numEntities",
"num_relationships": f"{SEMANTICA_NS}numRelationships",
"temporal_enabled": f"{SEMANTICA_NS}temporalEnabled",
"entity_resolution_applied": f"{SEMANTICA_NS}entityResolutionApplied",
"timestamp": f"{SEMANTICA_NS}builtAt",
"snapshot_time": f"{SEMANTICA_NS}snapshotAt",
"source": f"{SEMANTICA_NS}sourceSystem",
"uri": f"{SEMANTICA_NS}sourceUri",
"database": f"{SEMANTICA_NS}sourceDatabase",
}
#: Terms whose value is a node rather than a string. Everything else stays a
#: literal: a metadata value that merely looks like a URL is not thereby a
#: reference to one.
IRI_VALUED_METADATA_TERMS: Set[str] = {f"{SEMANTICA_NS}sourceUri"}
_XSD_NS = "http://www.w3.org/2001/XMLSchema#"
def _escape_literal(value: str) -> str:
"""Escape a string for a Turtle or N-Triples quoted literal."""
return (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
def _escape_temporal_literal(value: Any) -> str:
"""Escape a temporal bound for a Turtle ``dateTimeStamp`` literal.
Bounds are normally strings, but callers may hand us a ``datetime`` or
``None``. ``_escape_literal`` is str-only, so stringify non-str values
first instead of calling ``.replace()`` on them; ``None`` yields an empty
bound rather than crashing. Datetimes must use ISO 8601 so the
``xsd:dateTimeStamp`` ``T`` separator is preserved ``str()`` yields a
space ("00:00:00+00:00"), which is a lexically invalid timestamp.
"""
if value is None:
return ""
if isinstance(value, str):
return _escape_literal(value)
if hasattr(value, "isoformat"):
return value.isoformat()
return str(value)
#: Turtle/N-Triples IRIREF grammar excludes these unescaped between `<` and
#: `>`: control characters, space, and <>"{}|^`\. An IRI-valued metadata
#: value (currently only sem:sourceUri, from the caller-controlled "uri"
#: metadata key) is written as `<{value}>` with no other quoting, so a value
#: containing one of these characters — a ">" followed by a full triple, for
#: instance — closes the IRIREF early and lets the rest of the string be
#: parsed as further RDF statements. This is the same shape of defect the
#: entity/relationship IRIs were hardened against; that hardening resolves
#: prefixes as well, which a metadata value never needs, so this stays a
#: narrower, dedicated guard rather than reusing _as_turtle_iri.
_IRI_REF_UNSAFE_RE = re.compile(r'[\x00-\x20<>"{}|^`\\]')
def _safe_iri_ref(value: str) -> str:
"""Percent-encode the characters an IRIREF may not contain unescaped."""
return _IRI_REF_UNSAFE_RE.sub(lambda m: quote(m.group(0), safe=""), value)
def _escape_xml(value: str) -> str:
"""Escape a string for either XML element text or an attribute value.
The quotes matter. This helper feeds `rdf:about`, `rdf:resource` and
`xmlns:` attribute values, which are delimited by double quotes, so a value
carrying one would close the attribute early and produce a document that
does not parse. Escaping them in element text as well is harmless and
means one helper cannot be used in the wrong place.
"""
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)
def _is_ncname(value: str) -> bool:
"""Whether a string can be an XML NCName, which is what RDF/XML requires.
Checked over the ASCII range rather than the full XML production: the
grammar also admits combining characters and extenders, so this is
deliberately conservative. It refuses names it could have accepted, and it
never accepts one that would produce a document a parser rejects. The
earlier check tested only that the first character was not a digit, which
let through every other way a local name can fail to be a name.
"""
if not value:
return False
if not (value[0].isascii() and (value[0].isalpha() or value[0] == "_")):
return False
return all(c.isascii() and (c.isalnum() or c in "._-") for c in value[1:])
def _split_iri(iri: str) -> Optional[tuple]:
"""Split an IRI into (namespace, local name) for RDF/XML's QName syntax.
Returns None when no split yields a usable local name. RDF/XML is the only
serialization here that cannot write an arbitrary predicate IRI, so this is
the one place a term can be unrepresentable, and the caller reports it
rather than dropping it quietly.
"""
for sep in ("#", "/"):
index = iri.rfind(sep)
if index != -1 and index + 1 < len(iri):
local = iri[index + 1 :]
if _is_ncname(local):
return iri[: index + 1], local
return None
def _metadata_statements(
metadata: Any,
terms: Dict[str, str],
logger: Any,
) -> List[tuple]:
"""Resolve a metadata mapping to a list of (term IRI, value) pairs.
A key with no term is skipped and reported. Silence is the defect this
fixes, so an unmapped key must be louder than a mapped one, not quieter.
"""
if not isinstance(metadata, dict):
return []
statements: List[tuple] = []
for key, value in metadata.items():
term = terms.get(key)
if term is None:
logger.warning(
"Metadata key %r has no term and was not exported. Which "
"namespace a caller-supplied key belongs in is issue #1146; "
"pass metadata_terms={%r: '<iri>'} to export it now.",
key,
key,
)
continue
if value is None:
continue
if isinstance(value, (dict, list, tuple, set)):
logger.warning(
"Metadata key %r holds a %s, which has no modelled RDF shape "
"yet, and was not exported.",
key,
type(value).__name__,
)
continue
statements.append((term, value))
return statements
def _resolve_metadata_terms(overrides: Optional[Dict[str, str]]) -> Dict[str, str]:
if not overrides:
return DEFAULT_METADATA_TERMS
return {**DEFAULT_METADATA_TERMS, **overrides}
def _typed_literal_parts(term: str, value: Any) -> tuple:
"""Return (kind, lexical, datatype) for one metadata value.
kind is "iri" or "literal". The lexical form and datatype are chosen once,
here, so that the four serializers cannot disagree about them the way they
disagreed about confidence in #1100.
"""
if term in IRI_VALUED_METADATA_TERMS and isinstance(value, str):
return "iri", value, None
if isinstance(value, bool):
return "literal", "true" if value else "false", f"{_XSD_NS}boolean"
if isinstance(value, int):
return "literal", str(value), f"{_XSD_NS}integer"
if isinstance(value, float):
# xsd:double, not xsd:decimal. `repr(1e-05)` is "1e-05" and
# `repr(float("nan"))` is "nan", and xsd:decimal admits neither the
# exponent form nor the special values, so typing a float as decimal
# produced lexicals a strict parser rejects. A Python float is an IEEE
# 754 double; xsd:double has legal lexicals for all of them, and it is
# also the honest claim, since nothing that arrived as a float was ever
# exact. `normalize_confidence` keeps xsd:decimal for confidence
# deliberately: that is a bounded score where exactness is meaningful
# and NaN is not a confidence at all.
if value != value:
lexical = "NaN"
elif value == float("inf"):
lexical = "INF"
elif value == float("-inf"):
lexical = "-INF"
else:
lexical = repr(value)
return "literal", lexical, f"{_XSD_NS}double"
return "literal", str(value), None
def _turtle_object(term: str, value: Any) -> str:
kind, lexical, datatype = _typed_literal_parts(term, value)
if kind == "iri":
return f"<{_safe_iri_ref(lexical)}>"
if datatype is None:
return f'"{_escape_literal(lexical)}"'
return f'"{lexical}"^^<{datatype}>'
def _turtle_metadata_clauses(statements: List[tuple]) -> List[str]:
return [f"<{term}> {_turtle_object(term, value)}" for term, value in statements]
def _ntriples_metadata_lines(subject: str, statements: List[tuple]) -> List[str]:
return [
f"<{subject}> <{term}> {_turtle_object(term, value)} ."
for term, value in statements
]
def _rdfxml_metadata_lines(
statements: List[tuple], indent: str, logger: Any = None
) -> List[str]:
"""RDF/XML needs a QName, so an unprefixed term declares its own prefix.
A term with no QName form has no RDF/XML representation at all, and this is
the only serialization with that restriction. Skipping it quietly would
reintroduce, in one format, exactly the silent metadata loss this module
was changed to stop, so it is reported and the other three formats still
carry the statement in full.
"""
lines: List[str] = []
for position, (term, value) in enumerate(statements):
split = _split_iri(term)
if split is None:
if logger is not None:
logger.warning(
"Term %r has no QName form, so it cannot be written in "
"RDF/XML and was omitted from that serialization only. "
"Turtle, N-Triples and JSON-LD carry it in full.",
term,
)
continue
namespace, local = split
kind, lexical, datatype = _typed_literal_parts(term, value)
prefix = f"md{position}"
opening = f'{indent}<{prefix}:{local} xmlns:{prefix}="{_escape_xml(namespace)}"'
if kind == "iri":
lines.append(f'{opening} rdf:resource="{_escape_xml(lexical)}"/>')
continue
if datatype is not None:
opening += f' rdf:datatype="{_escape_xml(datatype)}"'
lines.append(f"{opening}>{_escape_xml(lexical)}</{prefix}:{local}>")
return lines
def _jsonld_metadata_entries(statements: List[tuple]) -> Dict[str, Any]:
"""Absolute IRIs as keys, and explicit @value/@type rather than JSON's own
types: JSON's number is xsd:double, which would make the JSON-LD export
disagree with the other three about the datatype of an integer."""
entries: Dict[str, Any] = {}
for term, value in statements:
kind, lexical, datatype = _typed_literal_parts(term, value)
if kind == "iri":
entries[term] = {"@id": lexical}
elif datatype is None:
entries[term] = lexical
else:
entries[term] = {"@value": lexical, "@type": datatype}
return entries
class NamespaceManager:
"""
RDF namespace management engine.
@@ -354,6 +642,37 @@ class RDFSerializer:
self.logger.debug("RDF serializer initialized")
@staticmethod
def _local_name_from_id(identifier: str) -> str:
"""Derive a human-readable local name from an entity identifier.
Handles HTTP(S)/IRI identifiers (path segments and fragments, tolerating
trailing slashes) as well as compact/CURIE and URN-style identifiers.
"""
raw = str(identifier).strip()
if not raw:
return ""
# Prefer a fragment if present (e.g. http://ex.org/onto#acme -> acme).
if "#" in raw:
candidate = raw.rsplit("#", 1)[-1]
if candidate:
return candidate
# For IRIs/paths, take the last non-empty path segment.
if "/" in raw:
segment = raw.rstrip("/").rsplit("/", 1)[-1]
if segment:
return segment
# Fall back to the tail of a CURIE/URN (e.g. urn:x:acme, semantica:acme).
if ":" in raw:
candidate = raw.rsplit(":", 1)[-1]
if candidate:
return candidate
return raw
def convert_kg_to_rdf(self, knowledge_graph: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert knowledge graph to RDF data structure.
@@ -393,8 +712,11 @@ class RDFSerializer:
if "name" in norm_entity:
norm_entity["label"] = norm_entity["name"]
elif "id" in norm_entity:
# Use ID part as label if no name/text
norm_entity["label"] = str(norm_entity["id"]).split(":")[-1]
# Derive a readable label from the identifier's local name
# (fragment/last path segment/CURIE tail). See #1097.
local_name = self._local_name_from_id(norm_entity["id"])
if local_name:
norm_entity["label"] = local_name
rdf_data["entities"].append(norm_entity)
@@ -501,6 +823,8 @@ class RDFSerializer:
"""
include_temporal: bool = options.pop("include_temporal", False)
time_axis: str = options.pop("time_axis", "valid")
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
graph_uri: Optional[str] = options.pop("graph_uri", None)
lines = []
@@ -534,21 +858,32 @@ class RDFSerializer:
text = entity.get("text") or entity.get("label", "")
confidence = normalize_confidence(entity.get("confidence", 1.0))
lines.append(
f"<{self._as_turtle_iri(entity_id, merged_namespaces)}> a "
f"<{self._as_turtle_iri(entity_type, merged_namespaces)}> ;"
)
clauses = [
f"a <{self._as_turtle_iri(entity_type, merged_namespaces)}>",
f'semantica:text "{_escape_literal(text)}"',
]
if confidence is None:
self.logger.warning(
f"Entity {entity_id} has a confidence that is not a number "
f"({entity.get('confidence')!r}), so no confidence is written"
)
lines.append(f' semantica:text "{text}" .')
else:
lines.append(f' semantica:text "{text}" ;')
lines.append(
f' semantica:confidence "{confidence}"^^<{CONFIDENCE_DATATYPE}> .'
clauses.append(
f'semantica:confidence "{confidence}"^^<{CONFIDENCE_DATATYPE}>'
)
clauses.extend(
_turtle_metadata_clauses(
_metadata_statements(
entity.get("metadata"), metadata_terms, self.logger
)
)
)
entity_iri = self._as_turtle_iri(entity_id, merged_namespaces)
lines.append(f"<{entity_iri}> {clauses[0]} ;")
for clause in clauses[1:-1]:
lines.append(f" {clause} ;")
lines.append(f" {clauses[-1]} .")
lines.append("")
# Convert relationships to RDF triplets
@@ -582,6 +917,31 @@ class RDFSerializer:
)
lines.extend(owl_lines)
# Graph-level metadata needs a subject, and this serializer has never
# minted a document node. Rather than invent one here, it is written
# only when the caller names the graph; issue #1147 is where the
# default subject comes from once that lands.
graph_clauses = (
_turtle_metadata_clauses(
_metadata_statements(
rdf_data.get("metadata"), metadata_terms, self.logger
)
)
if graph_uri
else []
)
if graph_clauses:
graph_iri = self._as_turtle_iri(graph_uri, merged_namespaces)
lines.append("")
lines.append(
f"<{graph_iri}> {graph_clauses[0]} "
+ (";" if len(graph_clauses) > 1 else ".")
)
for clause in graph_clauses[1:-1]:
lines.append(f" {clause} ;")
if len(graph_clauses) > 1:
lines.append(f" {graph_clauses[-1]} .")
return "\n".join(lines)
def _reified_relationship_triples(
@@ -692,7 +1052,7 @@ class RDFSerializer:
lines.append(f" time:hasEnd <{end_id}> .")
lines.append(f"<{end_id}> a time:Instant ;")
lines.append(
f' time:inXSDDateTimeStamp "{until_val}"^^xsd:dateTimeStamp .'
f' time:inXSDDateTimeStamp "{_escape_temporal_literal(until_val)}"^^xsd:dateTimeStamp .'
)
else:
lines[-1] = (
@@ -701,7 +1061,7 @@ class RDFSerializer:
lines.append(f"<{begin_id}> a time:Instant ;")
lines.append(
f' time:inXSDDateTimeStamp "{from_val}"^^xsd:dateTimeStamp .'
f' time:inXSDDateTimeStamp "{_escape_temporal_literal(from_val)}"^^xsd:dateTimeStamp .'
)
lines.append("")
@@ -730,6 +1090,9 @@ class RDFSerializer:
... }
>>> rdfxml = serializer.serialize_to_rdfxml(rdf_data)
"""
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
graph_uri: Optional[str] = options.pop("graph_uri", None)
lines = ['<?xml version="1.0" encoding="UTF-8"?>']
lines.append('<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"')
lines.append(' xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"')
@@ -752,6 +1115,10 @@ class RDFSerializer:
confidence = normalize_confidence(entity.get("confidence", 1.0))
# RDF/XML syntax: rdf:Description with rdf:about
# Attribute values are delimited by quotes, and both of these
# are caller input. Element text (semantica:text) is caller input
# too, so it needs the same escaping to avoid injecting markup
# or breaking out of the element (#1097 / #1113).
entity_iri = xml_escape(
self._as_turtle_iri(entity_id, namespaces), quote=True
)
@@ -760,7 +1127,9 @@ class RDFSerializer:
)
lines.append(f' <rdf:Description rdf:about="{entity_iri}">')
lines.append(f' <rdf:type rdf:resource="{entity_type_iri}"/>')
lines.append(f" <semantica:text>{text}</semantica:text>")
lines.append(
f" <semantica:text>{xml_escape(text)}</semantica:text>"
)
if confidence is None:
self.logger.warning(
f"Entity {entity_id} has a confidence that is not a number "
@@ -771,6 +1140,15 @@ class RDFSerializer:
f' <semantica:confidence rdf:datatype="{CONFIDENCE_DATATYPE}">'
f"{confidence}</semantica:confidence>"
)
lines.extend(
_rdfxml_metadata_lines(
_metadata_statements(
entity.get("metadata"), metadata_terms, self.logger
),
" ",
self.logger,
)
)
lines.append(" </rdf:Description>")
lines.append("")
@@ -795,6 +1173,26 @@ class RDFSerializer:
lines.append(" </rdf:Description>")
lines.append("")
graph_lines = (
_rdfxml_metadata_lines(
_metadata_statements(
rdf_data.get("metadata"), metadata_terms, self.logger
),
" ",
self.logger,
)
if graph_uri
else []
)
if graph_lines:
graph_iri = xml_escape(
self._as_turtle_iri(graph_uri, namespaces), quote=True
)
lines.append(f' <rdf:Description rdf:about="{graph_iri}">')
lines.extend(graph_lines)
lines.append(" </rdf:Description>")
lines.append("")
lines.append("</rdf:RDF>")
return "\n".join(lines)
@@ -825,11 +1223,17 @@ class RDFSerializer:
"""
import json
# Initialize JSON-LD structure with context
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
graph_uri: Optional[str] = options.pop("graph_uri", None)
# Initialize JSON-LD structure with context. No @vocab: it applied to
# every bare term in caller data, so an extracted type like "ORG"
# became ns#ORG and a metadata key like "source" collided with the
# real sem:source object property (#1146). Only explicit semantica:
# terms resolve now.
jsonld = {
"@context": {
"@vocab": "https://semantica.dev/vocab/",
"semantica": "https://semantica.dev/ns#",
"semantica": SEMANTICA_NS,
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
},
@@ -851,11 +1255,20 @@ class RDFSerializer:
# and was dropped in full by a JSON-LD parser, silently.
entity_id = entity.get("id") or mint_entity_iri(entity.get("text", ""))
# The caller's type label is data, not a class we define: minting
# it into @type expanded it through @vocab into ns#ORG and
# friends, terms that look official but do not exist (#1146).
# The node is always a semantica:Entity and the label travels as
# semantica:type, matching the relationship node below and
# JSONExporter._entity_to_jsonld.
node = {
"@id": entity_id,
"@type": entity.get("type", "semantica:Entity"),
"@type": "semantica:Entity",
"semantica:text": entity.get("text") or entity.get("label", ""),
}
entity_type = entity.get("type")
if entity_type:
node["semantica:type"] = entity_type
confidence = normalize_confidence(entity.get("confidence", 1.0))
if confidence is None:
self.logger.warning(
@@ -869,6 +1282,13 @@ class RDFSerializer:
"@value": confidence,
"@type": CONFIDENCE_DATATYPE,
}
node.update(
_jsonld_metadata_entries(
_metadata_statements(
entity.get("metadata"), metadata_terms, self.logger
)
)
)
jsonld["@graph"].append(node)
# Convert relationships to JSON-LD
@@ -893,6 +1313,18 @@ class RDFSerializer:
}
)
graph_entries = (
_jsonld_metadata_entries(
_metadata_statements(
rdf_data.get("metadata"), metadata_terms, self.logger
)
)
if graph_uri
else {}
)
if graph_entries:
jsonld["@graph"].append({"@id": graph_uri, **graph_entries})
return json.dumps(jsonld, indent=2, ensure_ascii=False)
def serialize_to_ntriples(self, rdf_data: Dict[str, Any], **options) -> str:
@@ -909,6 +1341,9 @@ class RDFSerializer:
Returns:
String containing N-Triples serialization
"""
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
graph_uri: Optional[str] = options.pop("graph_uri", None)
lines = []
namespaces = self.namespace_manager.extract_namespaces(rdf_data)
@@ -938,7 +1373,7 @@ class RDFSerializer:
# Text property
text = entity.get("text") or entity.get("label", "")
if text:
safe_text = text.replace('"', '\\"').replace("\n", "\\n")
safe_text = _escape_literal(text)
lines.append(
f'{subject} {expand_uri("semantica:text")} "{safe_text}" .'
)
@@ -959,6 +1394,15 @@ class RDFSerializer:
f'"{confidence}"^^<{CONFIDENCE_DATATYPE}> .'
)
lines.extend(
_ntriples_metadata_lines(
subject.strip("<>"),
_metadata_statements(
entity.get("metadata"), metadata_terms, self.logger
),
)
)
# Convert relationships
relationships = rdf_data.get("relationships", [])
for rel in relationships:
@@ -971,6 +1415,16 @@ class RDFSerializer:
f"{expand_uri(source_id)} {expand_uri(rel_type)} {expand_uri(target_id)} ."
)
if graph_uri:
lines.extend(
_ntriples_metadata_lines(
graph_uri,
_metadata_statements(
rdf_data.get("metadata"), metadata_terms, self.logger
),
)
)
return "\n".join(lines)
@@ -1309,6 +1763,12 @@ class RDFExporter:
self.logger.debug(f"Exporting to RDF format: {format}")
# Normalize the graph before serialization so every format benefits
# from field normalization (e.g. mapping 'name' -> 'label'/'text').
# Without this, graphs produced by GraphBuilder (which emit 'name')
# export with an empty semantica:text on all RDF paths. See #1097.
data = self.serializer.convert_kg_to_rdf(data)
self.progress_tracker.update_tracking(
tracking_id, message="Validating RDF data..."
)
+55 -3
View File
@@ -556,6 +556,13 @@ class GraphStore:
)
self.config = config
# Application-id -> backend-internal-id map for nodes added through
# the compatibility layer (#1136). add_nodes()/create_node() record
# the internal ids the backend returns; create_relationship()
# resolves known application ids through it so string ids stop
# mismatching backends that match on internal ids (Neo4j id(n)).
self._app_node_id_map: Dict[Any, Any] = {}
# Initialize store backend
self._store_backend = None
self._manager = None
@@ -630,7 +637,9 @@ class GraphStore:
**options,
) -> Dict[str, Any]:
"""Create a node."""
return self._manager.nodes.create(labels, properties, **options)
created = self._manager.nodes.create(labels, properties, **options)
self._record_app_node_id(created)
return created
def create_nodes(
self,
@@ -688,9 +697,25 @@ class GraphStore:
properties: Optional[Dict[str, Any]] = None,
**options,
) -> Dict[str, Any]:
"""Create a relationship."""
"""Create a relationship.
Node ids added through the compatibility layer are application-level
strings, while backends such as Neo4j match on internal integer ids
(#1136). Known application ids are resolved to the internal ids the
backend returned at creation time; unknown ids pass through
unchanged, so direct internal-id callers keep working. Only string
application ids participate: internal ids are commonly integers, so
recording or resolving an integer key could remap a caller-supplied
internal id to a different node.
"""
return self._manager.relationships.create(
start_node_id, end_node_id, rel_type, properties, **options
self._app_node_id_map.get(start_node_id, start_node_id)
if isinstance(start_node_id, str) else start_node_id,
self._app_node_id_map.get(end_node_id, end_node_id)
if isinstance(end_node_id, str) else end_node_id,
rel_type,
properties,
**options,
)
def get_relationships(
@@ -794,6 +819,24 @@ class GraphStore:
"""Create an index."""
return self._manager.create_index(label, property_name, index_type, **options)
def _record_app_node_id(self, created: Optional[Dict[str, Any]]) -> None:
"""Record the application-id -> internal-id pair of a created node.
Backends return their own internal id alongside the stored properties;
when the caller supplied an application id it is preserved in
``properties["id"]`` by the compatibility layer, which makes the pair
recoverable (#1136). Only STRING application ids are recorded:
internal ids are commonly integers, and an integer application id
would collide with (and silently remap) a caller-supplied internal id
of the same value in ``create_relationship``.
"""
if not isinstance(created, dict):
return
app_id = (created.get("properties") or {}).get("id")
internal_id = created.get("id")
if isinstance(app_id, str) and internal_id is not None:
self._app_node_id_map[app_id] = internal_id
# Compatibility with AgentMemory / ContextGraph interface
def add_nodes(self, nodes: List[Dict[str, Any]], **options) -> int:
"""
@@ -853,12 +896,21 @@ class GraphStore:
# and properties.
result = self.create_nodes(graph_nodes, **options)
# Keep the application-id -> internal-id pairs instead of discarding
# them, so add_edges()/create_relationship() can resolve the string
# ids callers actually use (#1136).
for created in result:
self._record_app_node_id(created)
return len(result)
def add_edges(self, edges: List[Dict[str, Any]], **options) -> int:
"""
Add edges (Compatibility method).
``source_id``/``target_id`` are application-level string ids; they are
resolved to the backend's internal ids via the map ``add_nodes``
populated when the nodes were created (#1136).
Args:
edges: List of edge dictionaries
**options: Additional options
+28 -2
View File
@@ -130,7 +130,10 @@ Example Usage:
from __future__ import annotations
import importlib
from typing import Any, Dict, Tuple
from typing import TYPE_CHECKING, Any, Dict, Tuple
if TYPE_CHECKING:
from .salesforce_ingestor import SalesforceConnector, SalesforceData, SalesforceIngestor
from .config import IngestConfig, ingest_config
from .file_ingestor import (
@@ -152,6 +155,7 @@ from .methods import (
ingest_parquet,
ingest_public_api,
ingest_repository,
ingest_salesforce,
ingest_stream,
ingest_web,
ingest_xml,
@@ -218,6 +222,10 @@ _LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
"SnowflakeIngestor": (".snowflake_ingestor", "SnowflakeIngestor"),
"SnowflakeData": (".snowflake_ingestor", "SnowflakeData"),
"SnowflakeConnector": (".snowflake_ingestor", "SnowflakeConnector"),
# SAP OData ingestion
"SAPIngestor": (".sap_ingestor", "SAPIngestor"),
"SAPODataEntity": (".sap_ingestor", "SAPODataEntity"),
"SAPODataConnector": (".sap_ingestor", "SAPODataConnector"),
# Databricks ingestion
"DatabricksIngestor": (".databricks_ingestor", "DatabricksIngestor"),
"DatabricksData": (".databricks_ingestor", "DatabricksData"),
@@ -231,6 +239,10 @@ _LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
# XML ingestion
"XMLIngestor": (".xml_ingestor", "XMLIngestor"),
"XMLIngestionData": (".xml_ingestor", "XMLIngestionData"),
# Salesforce ingestion
"SalesforceIngestor": (".salesforce_ingestor", "SalesforceIngestor"),
"SalesforceData": (".salesforce_ingestor", "SalesforceData"),
"SalesforceConnector": (".salesforce_ingestor", "SalesforceConnector"),
}
_OPTIONAL_DEPENDENCY_MESSAGES = {
@@ -258,6 +270,11 @@ _OPTIONAL_DEPENDENCY_MESSAGES = {
"Arrow ingestion requires optional dependency 'pyarrow'. "
"Install it before importing ArrowIngestor or using ingest_arrow()."
),
".salesforce_ingestor": (
"Salesforce ingestion requires optional dependency 'simple-salesforce'. "
"Install it with: pip install \"semantica[db-salesforce]\" "
"or: pip install simple-salesforce>=1.12.0"
),
}
@@ -272,7 +289,7 @@ def __getattr__(name: str) -> Any:
except ModuleNotFoundError as exc:
message = _OPTIONAL_DEPENDENCY_MESSAGES.get(module_name)
missing_name = getattr(exc, "name", None)
if message and missing_name in {"git", "bs4", "pyarrow"}:
if message and missing_name in {"git", "bs4", "pyarrow", "simple_salesforce"}:
raise ImportError(message) from exc
raise
@@ -345,6 +362,10 @@ __all__ = [
"SnowflakeIngestor",
"SnowflakeData",
"SnowflakeConnector",
# SAP OData ingestion
"SAPIngestor",
"SAPODataEntity",
"SAPODataConnector",
# Databricks ingestion
"DatabricksIngestor",
"DatabricksData",
@@ -358,6 +379,10 @@ __all__ = [
# XML ingestion
"XMLIngestor",
"XMLIngestionData",
# Salesforce ingestion
"SalesforceIngestor",
"SalesforceData",
"SalesforceConnector",
# Registry and Methods
"MethodRegistry",
"method_registry",
@@ -369,6 +394,7 @@ __all__ = [
"ingest_repository",
"ingest_email",
"ingest_database",
"ingest_salesforce",
"ingest_ontology",
"ingest_arrow",
"ingest_parquet",
+15 -10
View File
@@ -110,7 +110,7 @@ class IngestConfig:
if value:
try:
if type_func == bool:
self._configs[config_key] = value.lower() in (
self._configs[config_key] = value.strip().lower() in (
"true",
"1",
"yes",
@@ -127,8 +127,8 @@ class IngestConfig:
if key.startswith(env_prefix) and key not in env_mappings:
config_key = key[len(env_prefix) :].lower()
# Try to convert to appropriate type
if value.lower() in ("true", "false"):
self._configs[config_key] = value.lower() == "true"
if value.strip().lower() in ("true", "false"):
self._configs[config_key] = value.strip().lower() == "true"
elif value.isdigit():
self._configs[config_key] = int(value)
else:
@@ -143,8 +143,8 @@ class IngestConfig:
if key.startswith(mcp_prefix) and key not in env_mappings:
config_key = key[len(mcp_prefix) :].lower()
# Try to convert to appropriate type
if value.lower() in ("true", "false"):
self._configs[f"mcp_{config_key}"] = value.lower() == "true"
if value.strip().lower() in ("true", "false"):
self._configs[f"mcp_{config_key}"] = value.strip().lower() == "true"
elif value.isdigit():
self._configs[f"mcp_{config_key}"] = int(value)
else:
@@ -169,12 +169,12 @@ class IngestConfig:
if value:
try:
# Try to convert to appropriate type
if isinstance(default, int):
if isinstance(default, bool):
return value.strip().lower() in ("true", "1", "yes", "on")
elif isinstance(default, int):
return int(value)
elif isinstance(default, float):
return float(value)
elif isinstance(default, bool):
return value.lower() in ("true", "1", "yes", "on")
return value
except (ValueError, TypeError):
pass
@@ -186,8 +186,13 @@ class IngestConfig:
self._method_configs[method] = config
def get_method_config(self, method: str) -> Dict:
"""Get method-specific configuration."""
return self._method_configs.get(method, {})
"""Get method-specific configuration.
Returns a **copy** of the stored method configuration so callers can
safely mutate it (e.g. to merge per-call options) without poisoning the
global configuration for subsequent calls.
"""
return dict(self._method_configs.get(method, {}))
def get_all(self) -> Dict[str, Any]:
"""Get all configuration."""
+2
View File
@@ -815,6 +815,8 @@ class DBIngestor:
engine = connector.connect(connection_string)
try:
from sqlalchemy import text
with engine.connect() as conn:
# Execute query with parameters (parameterized queries for safety)
result = conn.execute(text(query), params)
+141
View File
@@ -875,6 +875,96 @@ schema = connector.get_schema(engine)
print(f" {table_name}: {[col['name'] for col in columns]}")
```
## Salesforce CRM Ingestion
Salesforce ingestion requires `simple-salesforce`:
```bash
pip install "semantica[db-salesforce]"
```
### Basic Usage
```python
from semantica.ingest import SalesforceIngestor
import os
ingestor = SalesforceIngestor(
username=os.getenv("SALESFORCE_USERNAME"),
password=os.getenv("SALESFORCE_PASSWORD"),
security_token=os.getenv("SALESFORCE_SECURITY_TOKEN"),
domain="login", # "test" for sandbox
)
# Ingest Account records
data = ingestor.ingest_sobject(
"Account",
fields=["Id", "Name", "Industry", "BillingCity"],
where="Type = 'Customer'",
limit=5000,
)
print(f"Retrieved {data.row_count} of {data.total_size} matching records")
```
`SalesforceIngestor()` with no arguments reads from `SALESFORCE_USERNAME`, `SALESFORCE_PASSWORD`, `SALESFORCE_SECURITY_TOKEN`, and `SALESFORCE_DOMAIN` environment variables automatically.
### Custom Objects and Raw SOQL
```python
# Custom object (API name ends in __c)
data = ingestor.ingest_sobject("My_Custom_Object__c", fields=["Id", "Name", "Custom_Field__c"])
# Raw SOQL GÇö pagination is handled automatically
data = ingestor.ingest_query("""
SELECT Id, Name, StageName, Amount
FROM Opportunity
WHERE IsClosed = false
ORDER BY CloseDate ASC
""")
print(f"Open opportunities: {data.row_count}")
```
### Document Export
```python
documents = ingestor.export_as_documents(
data,
id_field="Id", # Salesforce 18-char record Id
text_fields=["Name", "Description"],
)
# Each document: {"id": "001...", "text": "...", "metadata": {"source": "salesforce", ...}}
```
### Convenience Function
```python
from semantica.ingest import ingest_salesforce
# Fetch records
data = ingest_salesforce(
method="sobject",
sobject_name="Account",
fields=["Id", "Name"],
limit=500,
)
# Ingest and export as documents in one call
docs = ingest_salesforce(
method="documents",
sobject_name="Account",
text_fields=["Name", "Description"],
)
# Using the unified dispatcher
from semantica.ingest import ingest
result = ingest(None, source_type="salesforce", method="sobject",
sobject_name="Account", fields=["Id", "Name"])
data = result["data"]
```
See [Salesforce Integration](https://docs.getsemantica.ai/integrations/salesforce) for full documentation including sandbox, schema discovery, pagination details, and troubleshooting.
## MCP Server Ingestion
**IMPORTANT**: This implementation supports **ONLY Python-based MCP servers and FastMCP servers**. Users can bring their own Python or FastMCP MCP servers via URL connections. JavaScript, TypeScript, C#, Java, and other language implementations are **NOT supported**.
@@ -1613,3 +1703,54 @@ for source_type, source_list in sources.items():
for batch in process_in_batches(large_dataset, batch_size=1000):
result = ingest(batch)
```
## SAP OData Ingestion
`SAPIngestor` reads an Entity Set from a SAP OData service — S/4HANA Cloud,
SuccessFactors, or an on-prem NetWeaver Gateway over its REST surface. It
follows OData v2/v4 server-driven pagination and flattens each record into a
document dict via `export_as_documents()`.
Install with `pip install 'semantica[ingest-sap]'`.
### Connector Construction & Authentication
```python
from semantica.ingest import SAPIngestor
# OAuth2 client-credentials (BTP / S/4HANA Cloud)
ing = SAPIngestor(
base_url="https://my-sap.example.com/sap/opu/odata/sap/API_BUSINESS_PARTNER",
client_id="...", client_secret="...",
token_url="https://my-sap.example.com/oauth/token",
)
# On-prem NetWeaver often uses Basic auth instead — swap the block above for:
# ing = SAPIngestor(base_url="...", username="erp_user", password="...")
```
### Entity-Set Ingestion & Document Export
```python
# 1. Discover entity sets + field types from $metadata
sets = ing.discover_service()
# 2. Page-walk an Entity Set (v2/v4 next links handled automatically)
partners = ing.ingest_entity_set(
entity_set="A_BusinessPartnerSet",
select="BusinessPartner,BusinessPartnerFullName",
top=1000,
)
# 3. Flatten to document dicts that GraphBuilder can consume directly
docs = ing.export_as_documents(partners)
```
- Use `expand="to_Item"` on a sales-order header set to pull nested line items
in one request — handy for modeling order → line-item → material relations.
- Every outbound request, including the OAuth2 token exchange, is routed through
the SSRF guard, and pagination never follows a next link that points to a
different host than the service root.
> **Security Note:** Never hardcode credentials (`client_secret`, `password`);
> pass them via environment variables (`SAP_CLIENT_SECRET`, `SAP_PASSWORD`) or a
> secrets manager.
+218
View File
@@ -203,6 +203,7 @@ if TYPE_CHECKING:
from .ontology_ingestor import OntologyData
from .parquet_ingestor import ParquetData
from .public_api_ingestor import PublicAPIDetection
from .salesforce_ingestor import SalesforceData
from .stream_ingestor import StreamProcessor
from .web_ingestor import WebContent
from .xml_ingestor import XMLIngestionData
@@ -1126,6 +1127,213 @@ def ingest_database(
raise
def ingest_salesforce(
source: Optional[Dict[str, Any]] = None,
method: str = "sobject",
**kwargs,
) -> Union["SalesforceData", List[Dict[str, Any]], Dict[str, Any]]:
"""Ingest data from Salesforce CRM (convenience function).
A user-friendly wrapper around :class:`~semantica.ingest.SalesforceIngestor`
that connects, ingests, and returns data in a single call.
Args:
source: Optional credential/configuration dictionary. Keys mirror the
:class:`~semantica.ingest.SalesforceConnector` constructor:
``username``, ``password``, ``security_token``, ``domain``
(``"login"`` for production, ``"test"`` for sandbox),
``instance_url``, ``session_id``, ``api_version``.
When ``None``, credentials are read from environment variables
(``SALESFORCE_USERNAME`` / ``SALESFORCE_PASSWORD`` /
``SALESFORCE_SECURITY_TOKEN`` etc.).
method: Ingestion method:
* ``"sobject"`` *(default)* fetch records from a named sObject
(requires ``sobject_name`` kwarg).
* ``"query"`` execute a raw SOQL query string (requires
``soql`` kwarg).
* ``"list_sobjects"`` return a sorted list of accessible sObject
API names.
* ``"schema"`` return field metadata for a named sObject
(requires ``sobject_name`` kwarg).
* ``"documents"`` ingest an sObject and convert to the Semantica
document format in one step (requires ``sobject_name`` kwarg;
optional ``text_fields`` and ``id_field`` kwargs).
**kwargs: Additional options forwarded to the ingestor method.
Common kwargs for ``"sobject"`` / ``"documents"``:
* ``sobject_name`` Salesforce sObject API name (e.g.
``"Account"``, ``"My_Custom__c"``).
* ``fields`` list of field API names to select. When omitted
all selectable fields are fetched via ``describe()``.
* ``where`` SOQL ``WHERE`` clause fragment (trusted input only).
* ``order_by`` SOQL ``ORDER BY`` clause fragment.
* ``limit`` maximum number of records.
For ``"query"``:
* ``soql`` full SOQL query string.
For ``"schema"``:
* ``sobject_name`` sObject to describe.
Returns:
* ``"sobject"`` / ``"query"`` :class:`~semantica.ingest.SalesforceData`
* ``"documents"`` ``List[Dict[str, Any]]`` (Semantica document format)
* ``"list_sobjects"`` ``List[str]``
* ``"schema"`` ``Dict[str, Any]``
Raises:
:class:`~semantica.utils.exceptions.ConfigurationError`: If
``simple-salesforce`` is not installed.
:class:`~semantica.utils.exceptions.ValidationError`: If credentials
are incomplete or an sObject / field name is invalid.
:class:`~semantica.utils.exceptions.ProcessingError`: If the
Salesforce API call fails.
Examples::
>>> from semantica.ingest import ingest_salesforce
>>> # Fetch Account records (credentials from env vars)
>>> data = ingest_salesforce(
... method="sobject",
... sobject_name="Account",
... fields=["Id", "Name", "Industry"],
... limit=500,
... )
>>> # Execute a raw SOQL query (credentials from environment variables)
>>> data = ingest_salesforce(
... method="query",
... soql="SELECT Id, Name FROM Contact WHERE IsActive = true",
... )
>>> # Ingest and export as documents for GraphBuilder in one step
>>> docs = ingest_salesforce(
... method="documents",
... sobject_name="Account",
... text_fields=["Name", "Description"],
... limit=1000,
... )
>>> # List all accessible sObjects in the connected org
>>> sobject_names = ingest_salesforce(method="list_sobjects")
>>> # Use sandbox org
>>> data = ingest_salesforce(
... method="sobject",
... sobject_name="Account",
... ) # set SALESFORCE_DOMAIN=test in environment for sandbox
"""
# Registry hook — allows callers to register a custom "salesforce" method
custom_method = method_registry.get("salesforce", method)
if custom_method and custom_method != ingest_salesforce:
fallback = kwargs.pop("fallback_on_custom_error", False)
result = call_custom_method(
logger, method, custom_method, source,
fallback_on_custom_error=fallback, **kwargs,
)
if result is not CUSTOM_METHOD_FELL_BACK:
return result
try:
from .salesforce_ingestor import SalesforceIngestor
except ModuleNotFoundError as exc:
if _is_missing_dependency(exc, "simple_salesforce"):
raise _missing_optional_dependency(
"Salesforce ingestion", "simple-salesforce"
) from exc
raise
# Unpack credential dict (if given); everything else stays in kwargs.
creds: Dict[str, Any] = {}
if source is not None:
if not isinstance(source, dict):
raise ProcessingError(
"ingest_salesforce() source must be a credential dict or None. "
"Pass sobject_name / soql as keyword arguments."
)
creds = dict(source)
# Merge any ingest_config method config under "salesforce".
# get_method_config() now returns a copy, so this dict is safe to mutate.
# We build the final connector config in order of increasing priority:
# 1. base method config (lowest — global defaults set by operator)
# 2. per-call credential dict supplied via `source`
# 3. per-call connector params supplied as kwargs
# Credentials are extracted from kwargs and removed so they don't also
# flow into the ingest method call (which doesn't understand them).
_CONNECTOR_PARAMS = frozenset({
"username", "password", "security_token", "domain",
"instance_url", "session_id", "api_version",
})
connector_kwargs = {k: v for k, v in kwargs.items() if k in _CONNECTOR_PARAMS}
for k in _CONNECTOR_PARAMS:
kwargs.pop(k, None)
# Build a fresh per-call config dict — never mutate the global store.
config: Dict[str, Any] = {
**ingest_config.get_method_config("salesforce"), # base (already a copy)
**creds, # source dict credentials
**connector_kwargs, # kwarg credentials
}
ingestor = SalesforceIngestor(**config)
if method == "sobject":
sobject_name = kwargs.pop("sobject_name", None)
if not sobject_name:
raise ProcessingError(
"ingest_salesforce() with method='sobject' requires "
"sobject_name keyword argument."
)
return ingestor.ingest_sobject(sobject_name, **kwargs)
elif method == "query":
soql = kwargs.pop("soql", None)
if not soql:
raise ProcessingError(
"ingest_salesforce() with method='query' requires "
"soql keyword argument."
)
return ingestor.ingest_query(soql, **kwargs)
elif method == "list_sobjects":
return ingestor.list_sobjects()
elif method == "schema":
sobject_name = kwargs.pop("sobject_name", None)
if not sobject_name:
raise ProcessingError(
"ingest_salesforce() with method='schema' requires "
"sobject_name keyword argument."
)
return ingestor.get_sobject_schema(sobject_name)
elif method == "documents":
sobject_name = kwargs.pop("sobject_name", None)
if not sobject_name:
raise ProcessingError(
"ingest_salesforce() with method='documents' requires "
"sobject_name keyword argument."
)
id_field = kwargs.pop("id_field", "Id")
text_fields = kwargs.pop("text_fields", None)
data = ingestor.ingest_sobject(sobject_name, **kwargs)
return ingestor.export_as_documents(data, id_field=id_field,
text_fields=text_fields)
else:
raise ProcessingError(
f"Unknown ingest_salesforce method: {method!r}. "
"Valid methods: 'sobject', 'query', 'list_sobjects', 'schema', "
"'documents'."
)
def ingest_mcp(
source: Union[str, Dict[str, Any]],
method: str = "resources",
@@ -1306,6 +1514,7 @@ def ingest(
- "ontology": Ontology ingestion
- "parquet": Apache Parquet file or directory ingestion
- "xml": XML file or directory ingestion
- "salesforce": Salesforce CRM ingestion (pass credentials via kwargs)
method: Optional specific ingestion method
**kwargs: Additional options passed to ingestor
@@ -1428,6 +1637,9 @@ def ingest(
return {"ontology": ingest_ontology(sources, method=method or "file", **kwargs)}
elif source_type == "mcp":
return {"data": ingest_mcp(sources, method=method or "resources", **kwargs)}
elif source_type == "salesforce":
return {"data": ingest_salesforce(sources,
method=method or "sobject", **kwargs)}
else:
raise ProcessingError(f"Unknown source type: {source_type}")
@@ -1540,3 +1752,9 @@ method_registry.register("ontology", "file", ingest_ontology)
method_registry.register("ontology", "directory", ingest_ontology)
method_registry.register("ingest", "default", ingest)
method_registry.register("ingest", "unified", ingest)
method_registry.register("salesforce", "default", ingest_salesforce)
method_registry.register("salesforce", "sobject", ingest_salesforce)
method_registry.register("salesforce", "query", ingest_salesforce)
method_registry.register("salesforce", "list_sobjects", ingest_salesforce)
method_registry.register("salesforce", "schema", ingest_salesforce)
method_registry.register("salesforce", "documents", ingest_salesforce)
+17 -6
View File
@@ -40,7 +40,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import rdflib
from rdflib import RDF, RDFS, OWL, Graph
from rdflib import RDF, RDFS, OWL, Dataset, Graph
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
@@ -106,15 +106,24 @@ class OntologyIngestor:
raise ValidationError(f"File not found: {file_path}")
self.progress.update_tracking(tracking_id, message="Parsing RDF graph...")
g = Graph()
# `Dataset`, not `Graph`: a JSON-LD document with a top-level `@id` *and*
# `@graph` places its terms in a NAMED graph. `Graph.parse()` loads only the
# default graph and discards the rest without an error, so every class and
# property in such a document was dropped while the load reported success.
# Same migration #757 made for JenaStore; the ingest path was not covered by it.
# `default_union=True` makes the Dataset itself present triples from every
# graph as one merged view (it is an rdflib.Graph subclass, so it satisfies
# _convert_to_dict()'s Graph-typed contract directly) instead of copying every
# quad into a second in-memory Graph.
ds = Dataset(default_union=True)
# Use provided format or let rdflib guess based on extension
parse_kwargs = kwargs.copy()
if format:
parse_kwargs['format'] = format
try:
g.parse(file_path, **parse_kwargs)
ds.parse(file_path, **parse_kwargs)
except Exception as e:
# Fallback: try to guess format from extension if not provided and initial parse failed
if not format:
@@ -130,12 +139,14 @@ class OntologyIngestor:
guessed_fmt = fmt_map.get(ext)
if guessed_fmt:
self.logger.info(f"Retrying with guessed format: {guessed_fmt}")
g.parse(file_path, format=guessed_fmt, **kwargs)
ds.parse(file_path, format=guessed_fmt, **kwargs)
else:
raise e
else:
raise e
g = ds
self.progress.update_tracking(tracking_id, message="Converting to internal format...")
# Determine format for metadata
+1
View File
@@ -66,6 +66,7 @@ class MethodRegistry:
"parquet": {},
"arrow": {},
"xml": {},
"salesforce": {},
"ingest": {},
}
File diff suppressed because it is too large Load Diff
+617
View File
@@ -0,0 +1,617 @@
"""SAP OData ingestion module.
Pulls an Entity Set from a SAP OData service (S/4HANA and on-prem NetWeaver
REST surfaces) and flattens it into document dicts that the pipeline can feed
to ``GraphBuilder``.
Why this exists
---------------
Semantica ingests from many sources; SAP is the ERP backbone of finance and
regulated industries, and its master/transactional data (customers, vendors,
sales orders) is exactly the "context" a Context Graph wants. SAP exposes that
data over OData (v2 on many on-prem NetWeaver systems, v4 on BTP / S/4HANA
Cloud). This connector speaks the REST surface of OData only.
Design notes
------------
Three classes, matching the Snowflake/Databricks ingestors:
- ``SAPODataEntity``: a collection fetch from one Entity Set (``records``,
``count``, ``service``, ``metadata``), flattened to document dicts for
``GraphBuilder`` via ``export_as_documents``.
- ``SAPODataConnector``: auth (OAuth2 client-credentials or Basic) + the
shared, SSRF-guarded :mod:`requests` session. *Every* outbound request,
including the OAuth2 token exchange, goes through
``request_with_ssrf_guard`` so user-supplied endpoints can not reach
private/loopback/link-local address space.
- ``SAPIngestor``: the three methods the issue requested
``discover_service``, ``ingest_entity_set`` and ``export_as_documents``
(plus ``close`` for symmetry with the SQL connectors).
EDMX
----
``$metadata`` is plain CSDL XML in *both* OData v2 and v4, so we hand-roll a
minimal parser with :mod:`xml.etree` instead of pulling in ``pyodata``. That
keeps phase 1 ``requests``-only, exactly as scoped in the issue.
Pagination
----------
OData uses a server-driven "next link": OData v2 surfaces it as the atom
``__next`` element, OData v4 as the ``@odata.nextLink`` field on the JSON
payload. ``ingest_entity_set`` follows whichever it sees until the set is
exhausted.
"""
from __future__ import annotations
import base64
import os
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import urljoin, urlparse
import requests
from requests.adapters import HTTPAdapter
try:
from urllib3.util.retry import Retry
except (ImportError, OSError): # pragma: no cover - old urllib3 layout
from requests.packages.urllib3.util.retry import Retry # type: ignore
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from .ssrf import parse_bool, request_with_ssrf_guard
__all__ = [
"SAPODataEntity",
"SAPODataConnector",
"SAPIngestor",
]
_logger = get_logger("sap_ingestor")
def _prop_is_nullable(prop: Any) -> bool:
"""CSDL structural properties default to nullable=True when omitted."""
val = prop.get("Nullable", prop.get("nullable"))
return True if val is None else val.strip().lower() == "true"
def _match(elem: Any, localname: str) -> bool:
"""True if *elem* has the given local name in any namespace."""
return elem.tag.rsplit("}", 1)[-1] == localname
@dataclass
class SAPODataEntity:
"""A collection fetch from a SAP OData Entity Set.
Holds the rows pulled from one Entity Set (all paging fan-in'd), with the
shape the issue specifies: ``records`` (the row data), ``count``,
``service`` (the resolved service root), optional ``metadata`` (entity-set
schema from ``$metadata``) and ``ingested_at``.
"""
records: List[Dict[str, Any]]
entity_set: str
count: int
service: str
metadata: Optional[Dict[str, Any]] = None
ingested_at: datetime = field(default_factory=datetime.now)
def to_documents(self) -> List[Dict[str, Any]]:
"""Flatten each record to a document dict ``GraphBuilder`` can consume.
GraphBuilder only treats a dict as an entity when it carries
``id``/``entity_id``/``name`` (or ``text``+``type``); SAP records have
none of those, so they would be silently dropped. We inject an
identifier resolved from each record's primary-key-like field, falling
back to ``entity_set:index``, and expose it under both ``id`` and
``name``.
"""
docs: List[Dict[str, Any]] = []
for index, record in enumerate(self.records):
doc = dict(record)
key_value = self._id_value(record)
doc.setdefault("id", key_value or f"{self.entity_set}:{index}")
doc.setdefault("name", key_value or self.entity_set)
doc.setdefault("source", self.service)
docs.append(doc)
return docs
@staticmethod
def _id_value(record: Dict[str, Any]) -> str:
for key, value in record.items():
if "id" in key.lower() and value not in (None, ""):
return str(value)
return ""
class SAPODataConnector:
"""Connection + authentication management for a SAP OData REST service.
Supports the two auth landscapes called out in the issue:
- **OAuth2 client-credentials** (BTP / S/4HANA Cloud). The token URL is
user supplied; both the token exchange *and* every subsequent data
request are validated through the SSRF guard.
- **Basic** (on-prem NetWeaver). Username/password passed through as an
``Authorization: Basic`` header, also through the guard.
Example usage::
>>> connector = SAPODataConnector(
... base_url="https://myhost/sap/opu/odata/sap/",
... token_url="https://myhost/oauth/token",
... client_id="cid", client_secret="secret",
... )
>>> session = connector.get_session()
"""
def __init__(
self,
base_url: Optional[str] = None,
*,
auth: Optional[str] = None,
token_url: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
allow_private_ips: bool = False,
**config: Any,
) -> None:
"""Initialize the SAP OData connector.
Args:
base_url: Base OData service URL, e.g. ``https://host/sap/opu
/odata/sap/``. The issue's ``service`` value.
auth: Explicit auth flow, ``"oauth2"`` or ``"basic"``. When
omitted, the flow is inferred from which credentials are set.
token_url: OAuth2 token endpoint. Required only for OAuth2 flow.
client_id: OAuth2 client id (OAuth2 flow).
client_secret: OAuth2 client secret (OAuth2 flow).
username: Basic-auth username (on-prem flow).
password: Basic-auth password (on-prem flow).
allow_private_ips: Opt into private/loopback/link-local endpoints.
Defaults to False (SSRF-safe).
**config: Extra options, notably ``timeout``, ``max_retries``,
``backoff_factor``, ``headers``.
"""
self.logger = _logger
self.base_url = base_url or os.getenv("SAP_BASE_URL")
self.auth = (auth or os.getenv("SAP_AUTH") or "").lower()
self.token_url = token_url or os.getenv("SAP_TOKEN_URL")
self.client_id = client_id or os.getenv("SAP_CLIENT_ID")
self.client_secret = client_secret or os.getenv("SAP_CLIENT_SECRET")
self.username = username or os.getenv("SAP_USERNAME")
self.password = password or os.getenv("SAP_PASSWORD")
self.allow_private_ips = parse_bool(
config.pop("allow_private_ips", allow_private_ips), default=False
)
self.config = config
if not self.base_url:
raise ValidationError(
"SAP base_url is required. Provide via 'base_url' or "
"SAP_BASE_URL environment variable."
)
oauth_configured = bool(self.client_id or self.client_secret or self.token_url)
if self.auth in ("oauth2", "oauth"):
if not (self.client_id and self.client_secret and self.token_url):
raise ValidationError(
"SAP OAuth2 flow requires client_id, client_secret and "
"token_url all set."
)
elif self.auth == "basic":
if not (self.username and self.password):
raise ValidationError("SAP Basic flow requires username and password.")
elif oauth_configured:
if not (self.client_id and self.client_secret and self.token_url):
raise ValidationError(
"SAP OAuth2 flow requires client_id, client_secret and "
"token_url all set."
)
elif not self.username:
raise ValidationError(
"SAP authentication requires either (username/password) or "
"(client_id/client_secret + token_url)."
)
self.session = requests.Session()
retry_strategy = Retry(
total=self.config.get("max_retries", 3),
backoff_factor=self.config.get("backoff_factor", 1),
status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
default_headers = self.config.get("headers", {})
if default_headers:
self.session.headers.update(default_headers)
self._token: Optional[str] = None
self.logger.debug(
"SAP OData connector initialized (base_url=%s, allow_private_ips=%s)",
self.base_url,
self.allow_private_ips,
)
def get_session(self) -> requests.Session:
"""Return an authenticated session for data requests.
For the Basic flow the credentials are attached eagerly; for the
OAuth2 flow a token is fetched (and cached) on first use. The token
is never refreshed, so a job that runs past the token TTL (typically
3600s on SAP) will fail with 401 -- re-create the connector instead.
"""
if self.username:
self.session.headers["Authorization"] = "Basic " + self._basic_header()
return self.session
if self._token is None:
self._token = self._fetch_token()
self.session.headers["Authorization"] = "Bearer " + self._token
return self.session
def _basic_header(self) -> str:
pair = f"{self.username}:{self.password or ''}".encode("utf-8")
return base64.b64encode(pair).decode("ascii")
def _fetch_token(self) -> str:
"""Perform the OAuth2 client-credentials token exchange (SSRF-guarded)."""
if not self.token_url or not self.client_id:
raise ProcessingError("OAuth2 flow requires token_url and client_id.")
body = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret or "",
}
resp = request_with_ssrf_guard(
"POST",
self.token_url,
session=self.session,
allow_private_ips=self.allow_private_ips,
data=body,
timeout=self.config.get("timeout", 30),
)
try:
resp.raise_for_status()
except requests.exceptions.RequestException as exc:
raise ProcessingError(f"SAP OAuth2 token exchange failed: {exc}") from exc
try:
payload = resp.json()
except ValueError as exc:
raise ProcessingError(
"SAP OAuth2 token endpoint did not return JSON."
) from exc
token = payload.get("access_token")
if not token:
raise ProcessingError("SAP OAuth2 token response missing 'access_token'.")
return str(token)
def close(self) -> None:
"""Close the underlying :mod:`requests` session."""
self.session.close()
class SAPIngestor:
"""Ingest an Entity Set from a SAP OData service.
Example usage::
>>> from semantica.ingest import SAPIngestor
>>> ing = SAPIngestor(
... base_url="https://host/sap/opu/odata/sap/",
... username="u", password="p", # or client_id/client_secret/token_url
... )
>>> sets = ing.discover_service()
... # -> [{"name": "SalesOrderSet", "fields": [...]}, ...]
>>> docs = ing.export_as_documents(
... ing.ingest_entity_set(entity_set="SalesOrderSet", expand="to_Item"))
"""
def __init__(
self,
base_url: Optional[str] = None,
connector: Optional[SAPODataConnector] = None,
**config: Any,
) -> None:
"""Initialize the SAP ingestor.
Args:
base_url: Base OData service URL. Mutually exclusive with
``connector``; ignored if a connector is given.
connector: An existing :class:`SAPODataConnector`. When provided,
its session and base URL are reused.
**config: Passed to :class:`SAPODataConnector` when one is created.
"""
self.logger = _logger
self.connector = connector or SAPODataConnector(base_url=base_url, **config)
# urljoin() replaces the last path segment unless the base ends in '/',
# so normalize once here: .../API_BUSINESS_PARTNER -> .../$metadata would
# silently drop the service segment.
self._base_url = self.connector.base_url
if not self._base_url.endswith("/"):
self._base_url += "/"
def discover_service(self, service: Optional[str] = None) -> List[Dict[str, Any]]:
"""Fetch and parse ``$metadata`` into the service's entity sets.
Args:
service: Service root URL (absolute) or path suffix resolved
against the base URL. Defaults to the base URL. ``$metadata``
is appended automatically same meaning as in
:meth:`ingest_entity_set`.
Returns:
List of dicts, one per EntitySet, each with ``name`` and ``fields``
(a list of ``{name, type, nullable}`` parsed from the CSDL).
"""
metadata_url = self._metadata_url(service)
session = self.connector.get_session()
resp = request_with_ssrf_guard(
"GET",
metadata_url,
session=session,
allow_private_ips=self.connector.allow_private_ips,
headers={"Accept": "application/xml"},
timeout=self.connector.config.get("timeout", 30),
)
try:
resp.raise_for_status()
except requests.exceptions.RequestException as exc:
self.logger.error("Failed to fetch SAP metadata %s: %s", metadata_url, exc)
raise ProcessingError(f"Failed to fetch SAP $metadata: {exc}") from exc
return self._parse_metadata(resp.text)
def _metadata_url(self, service: Optional[str]) -> str:
"""Build the ``$metadata`` URL for a service root.
``service`` has the same meaning as in :meth:`ingest_entity_set`
a service root (absolute URL or path suffix resolved against the
base URL). ``$metadata`` is appended here, so callers pass the root
the same way for both discovery and ingestion. A value already
ending in ``$metadata`` is used as-is.
"""
if not service:
return urljoin(self._base_url, "$metadata")
if "://" not in service:
service = urljoin(self._base_url, service)
if service.endswith("$metadata"):
return service
if not service.endswith("/"):
service += "/"
return urljoin(service, "$metadata")
def _parse_metadata(self, metadata_xml: str) -> List[Dict[str, Any]]:
"""Minimal CSDL/EDMX parser -> entity set name + property fields.
Element local names (``Schema``/``EntitySet``/``EntityType``/
``Property``) are stable across OData v2 (Microsoft ns) and v4 (OASIS
ns), so we match them by local name instead of hard-coding one
namespace. Entity-Type references are resolved per-schema, so
same-named types in different schemas cannot bleed fields into each
other.
"""
try:
root = ET.fromstring(metadata_xml)
except ET.ParseError as exc:
raise ProcessingError(f"SAP $metadata is not valid XML: {exc}") from exc
# Index fully-qualified type name -> property fields, per schema.
schema_types: Dict[str, List[Dict[str, Any]]] = {}
for schema in (e for e in root.iter() if _match(e, "Schema")):
ns = (schema.get("Namespace") or schema.get("namespace") or "").rstrip(".")
for entity_type in (e for e in schema.iter() if _match(e, "EntityType")):
tname = entity_type.get("Name") or entity_type.get("name")
if not tname:
continue
fq = f"{ns}.{tname}" if ns else tname
schema_types[fq] = [
{
"name": prop.get("Name") or prop.get("name"),
"type": prop.get("Type") or prop.get("type"),
"nullable": _prop_is_nullable(prop),
}
for prop in (e for e in entity_type.iter() if _match(e, "Property"))
]
entity_sets: List[Dict[str, Any]] = []
for schema in (e for e in root.iter() if _match(e, "Schema")):
ns = (schema.get("Namespace") or schema.get("namespace") or "").rstrip(".")
for entity_set in (e for e in schema.iter() if _match(e, "EntitySet")):
name = entity_set.get("Name") or entity_set.get("name")
ref = entity_set.get("EntityType") or entity_set.get("entityType") or ""
qualified = ref if "." in ref else (f"{ns}.{ref}" if ns else ref)
fields = schema_types.get(qualified) or schema_types.get(ref) or []
entity_sets.append({"name": name, "fields": fields})
return entity_sets
def ingest_entity_set(
self,
service: Optional[str] = None,
entity_set: Optional[str] = None,
*,
select: Optional[str] = None,
filter: Optional[str] = None,
expand: Optional[str] = None,
top: Optional[int] = None,
skip: Optional[int] = None,
batch_size: int = 100,
) -> SAPODataEntity:
"""Fetch pages of *entity_set* from the OData service.
Args:
service: Service root URL (absolute) or path suffix resolved
against the base URL. Defaults to the base URL. Same meaning
as in :meth:`discover_service`.
entity_set: Entity set name, e.g. ``"SalesOrderSet"``.
select: Optional ``$select`` comma string.
filter: Optional ``$filter`` expression.
expand: Optional ``$expand`` expression (e.g. ``"to_Item"`` for
use case 2's sales-order headers -> line items).
top: Maximum number of rows to return.
skip: Number of leading rows to skip.
batch_size: ``$top`` pagination size per request.
Returns:
A single :class:`SAPODataEntity` holding every fetched record
(server-driven pagination is followed to completion).
"""
if service is None:
base = self._base_url
elif "://" in service:
base = service
else:
base = urljoin(self._base_url, service)
if not base.endswith("/"):
base += "/"
if not entity_set:
raise ValidationError("SAP 'entity_set' is required.")
session = self.connector.get_session()
records: List[Dict[str, Any]] = []
next_link: Optional[str] = urljoin(base, entity_set)
params = self._query_params(select, filter, expand, top, skip, batch_size)
if top is not None and top < 0:
raise ValidationError("SAP 'top' must be >= 0 (got %r)" % top)
if top == 0:
return SAPODataEntity(
records=[], entity_set=entity_set, count=0, service=base
)
original_host = (urlparse(base).hostname or "").lower()
while next_link:
# Server-provided next links may point anywhere; never send the
# session credentials (Basic/Bearer) to a different origin than
# the service root. Legit SAP pagination stays on the same host.
next_host = (urlparse(next_link).hostname or "").lower()
if next_host != original_host:
raise ProcessingError(
f"SAP next link '{next_link}' points to a different host "
f"than service root '{base}'"
)
resp = request_with_ssrf_guard(
"GET",
next_link,
session=session,
allow_private_ips=self.connector.allow_private_ips,
headers={"Accept": "application/json"},
params=params,
timeout=self.connector.config.get("timeout", 30),
)
try:
resp.raise_for_status()
except requests.exceptions.RequestException as exc:
self.logger.error(
"Failed to fetch SAP entity set %s: %s", entity_set, exc
)
raise ProcessingError(
f"Failed to fetch SAP entity set {entity_set}: {exc}"
) from exc
payload = self._parse_page(resp)
rows, next_link = payload["rows"], payload["next_link"]
for raw_row in rows:
records.append(self._flatten_row(raw_row))
self.logger.debug(
"Fetched %d rows from %s (next=%s)",
len(rows),
entity_set,
bool(next_link),
)
if top is not None and len(records) >= top:
break
params = None # query params already baked into the server next link
# Refresh next_link against base in case it's a relative pointer.
if next_link and not next_link.startswith("http"):
next_link = urljoin(resp.url, next_link)
return SAPODataEntity(
records=records,
entity_set=entity_set,
count=len(records),
service=base,
)
def _query_params(
self,
select: Optional[str],
filter: Optional[str],
expand: Optional[str],
top_value: Optional[int],
skip: Optional[int],
batch_size: int,
) -> Dict[str, str]:
params: Dict[str, str] = {}
if batch_size > 0:
if top_value is not None:
params["$top"] = str(min(batch_size, top_value))
else:
params["$top"] = str(batch_size)
if select:
params["$select"] = select
if filter:
params["$filter"] = filter
if expand:
params["$expand"] = expand
if skip is not None:
params["$skip"] = str(skip)
return params
def _parse_page(self, resp: requests.Response) -> Dict[str, Any]:
try:
payload = resp.json()
except ValueError as exc:
raise ProcessingError(f"SAP OData response is not JSON: {exc}") from exc
rows: Any
next_link: Optional[str] = None
if isinstance(payload, list):
rows = payload
elif isinstance(payload, dict):
d = payload.get("d")
if isinstance(d, dict):
# OData v2 atom: {"d": {"results": [...], "__next": ...}}
rows = d.get("results")
nxt = d.get("__next") or payload.get("@odata.nextLink")
else:
# OData v4 JSON: {"value": [...], "@odata.nextLink": ...}
rows = payload.get("value", d)
nxt = payload.get("@odata.nextLink")
if isinstance(nxt, dict):
nxt = nxt.get("__deferred", {}).get("uri")
next_link = nxt
else:
rows = None
if not isinstance(rows, list):
raise ProcessingError(
"SAP OData payload has no list of rows (got %s)" % type(rows).__name__
)
return {"rows": rows, "next_link": next_link}
def _flatten_row(self, row: Any) -> Dict[str, Any]:
if isinstance(row, dict):
# v2 wraps items in "__metadata"; keep it but expose plain keys.
return {k: v for k, v in row.items() if k != "__metadata"}
return {"value": row}
def export_as_documents(self, data: SAPODataEntity) -> List[Dict[str, Any]]:
"""Convert an ingested entity set to flat document dicts.
Normalizes every records held by ``data`` into a list of dicts with an
injected ``id``/``name``/``source``, ready to hand to ``GraphBuilder``.
"""
return data.to_documents()
def close(self) -> None:
"""Close the underlying connector's session."""
self.connector.close()
+6 -6
View File
@@ -106,7 +106,7 @@ class KGConfig:
if value:
try:
if type_func == bool:
self._configs[config_key] = value.lower() in (
self._configs[config_key] = value.strip().lower() in (
"true",
"1",
"yes",
@@ -123,8 +123,8 @@ class KGConfig:
if key.startswith(env_prefix) and key not in env_mappings:
config_key = key[len(env_prefix) :].lower()
# Try to convert to appropriate type
if value.lower() in ("true", "false"):
self._configs[config_key] = value.lower() == "true"
if value.strip().lower() in ("true", "false"):
self._configs[config_key] = value.strip().lower() == "true"
elif value.isdigit():
self._configs[config_key] = int(value)
else:
@@ -149,12 +149,12 @@ class KGConfig:
if value:
try:
# Try to convert to appropriate type
if isinstance(default, int):
if isinstance(default, bool):
return value.strip().lower() in ("true", "1", "yes", "on")
elif isinstance(default, int):
return int(value)
elif isinstance(default, float):
return float(value)
elif isinstance(default, bool):
return value.lower() in ("true", "1", "yes", "on")
return value
except (ValueError, TypeError):
pass
+6 -5
View File
@@ -54,10 +54,11 @@ Version: 1.0.0
"""
from typing import Any, Dict, List, Optional
from datetime import datetime
import uuid
import time
from ..utils.helpers import utc_now_iso
class GraphBuilderWithProvenance:
"""
@@ -103,7 +104,7 @@ class GraphBuilderWithProvenance:
def build(self, sources, **kwargs):
"""Build graph with provenance tracking."""
activity_started_at_time = datetime.utcnow().isoformat()
activity_started_at_time = utc_now_iso()
# Track the build operation (recorded before the build runs, so it
# has no end time yet — this is the "in progress" marker).
if self.provenance and self._prov_manager:
@@ -124,7 +125,7 @@ class GraphBuilderWithProvenance:
)
result = self._builder.build(sources, **kwargs)
activity_ended_at_time = datetime.utcnow().isoformat()
activity_ended_at_time = utc_now_iso()
# Track individual entities and relationships if available
if self.provenance and self._prov_manager and hasattr(result, 'get'):
@@ -180,7 +181,7 @@ class GraphBuilderWithProvenance:
def build_single_source(self, kg_data, **kwargs):
"""Build graph from single source with provenance tracking."""
activity_started_at_time = datetime.utcnow().isoformat()
activity_started_at_time = utc_now_iso()
# Track the build operation (recorded before the build runs, so it
# has no end time yet — this is the "in progress" marker).
if self.provenance and self._prov_manager:
@@ -200,7 +201,7 @@ class GraphBuilderWithProvenance:
)
result = self._builder.build_single_source(kg_data, **kwargs)
activity_ended_at_time = datetime.utcnow().isoformat()
activity_ended_at_time = utc_now_iso()
# Track entities and relationships if available
if self.provenance and self._prov_manager and isinstance(result, dict):
+272 -19
View File
@@ -62,6 +62,13 @@ logging.basicConfig(stream=sys.stderr, level=_log_level,
format="%(asctime)s [semantica-mcp] %(levelname)s %(message)s")
log = logging.getLogger("semantica.mcp_server")
# MCP stdio framing IS stdout: a progress bar or other console renderer writing
# to stdout would interleave with the JSON-RPC stream and hang every client
# (observed 2026-08-20: export_graph over MCP timed out at 300s while the same
# call returned in <1s directly). Force the progress trackers off for this
# process — stdout is not a console here.
os.environ["SEMANTICA_DISABLE_PROGRESS"] = "1"
# ── lazy graph session ──────────────────────────────────────────────────────
_graph: Any = None
@@ -74,7 +81,7 @@ def _get_graph():
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path and os.path.exists(kg_path):
try:
_graph.load(kg_path)
_graph.load_from_file(kg_path)
log.info("Loaded graph from %s", kg_path)
except Exception as exc:
log.warning("Could not load graph from %s: %s", kg_path, exc)
@@ -86,30 +93,57 @@ def _get_graph():
# ══════════════════════════════════════════════════════════════════════════════
def _tool_extract_entities(args: dict) -> dict:
"""Extract named entities from text."""
"""Extract named entities from text.
Optional ``model`` (spaCy pipeline, e.g. ``zh_core_web_sm`` for Chinese)
and ``language`` allow non-English NER; defaults to the Semantica English
pipeline when omitted. ``method`` defaults to ``ml`` (spaCy); other
options are ``huggingface``, ``llm``, ``pattern``.
"""
text = args.get("text", "")
if not text:
return {"error": "text is required"}
from semantica.semantic_extract import NamedEntityRecognizer
entities = NamedEntityRecognizer().extract_entities(text)
init_kwargs = {}
for k in ("model", "language", "confidence_threshold"):
if args.get(k) is not None:
init_kwargs[k] = args[k]
method = args.get("method", "ml")
ner = NamedEntityRecognizer(methods=[method], **init_kwargs)
entities = ner.extract_entities(text)
return {
"entities": [
{"label": getattr(e, "label", str(e)),
"type": getattr(e, "type", None),
"start": getattr(e, "start", None),
"end": getattr(e, "end", None)}
{"text": getattr(e, "text", ""),
"label": getattr(e, "label", ""),
"type": getattr(e, "label", None),
"start": getattr(e, "start_char", getattr(e, "start", None)),
"end": getattr(e, "end_char", getattr(e, "end", None)),
"confidence": getattr(e, "confidence", 1.0)}
for e in (entities or [])
]
}
def _tool_extract_relations(args: dict) -> dict:
"""Extract relations and triplets from text."""
"""Extract relations and triplets from text.
Optional ``model``/``language`` enable non-English extraction.
``method`` defaults to ``pattern``; ``dependency`` uses spaCy syntactic
parsing (requires a spaCy model, e.g. ``zh_core_web_sm``).
"""
text = args.get("text", "")
if not text:
return {"error": "text is required"}
from semantica.semantic_extract import RelationExtractor, TripletExtractor
relations = RelationExtractor().extract_relations(text)
from semantica.semantic_extract import NamedEntityRecognizer, RelationExtractor, TripletExtractor
rel_kwargs = {}
ner_kwargs = {}
for k in ("model", "language"):
if args.get(k) is not None:
rel_kwargs[k] = args[k]
ner_kwargs[k] = args[k]
method = args.get("method", "pattern")
entities = NamedEntityRecognizer(methods=["ml"], **ner_kwargs).extract_entities(text) or []
relations = RelationExtractor(method=method, **rel_kwargs).extract_relations(text, entities)
triplets = TripletExtractor().extract_triplets(text)
return {
"relations": [
@@ -156,10 +190,12 @@ def _tool_query_decisions(args: dict) -> dict:
graph = _get_graph()
try:
if query:
results = graph.find_similar_decisions(query, max_results=limit)
results = graph.find_similar_decisions(query, max_results=limit, min_similarity=0.05)
elif category:
nodes = graph.find_nodes(node_type="decision")
results = [n for n in nodes if n.get("category") == category][:limit]
results = [n for n in nodes
if n.get("category") == category
or n.get("metadata", {}).get("category") == category][:limit]
else:
results = graph.find_nodes(node_type="decision")[:limit]
return {"decisions": results if isinstance(results, list) else list(results)}
@@ -175,7 +211,9 @@ def _tool_find_precedents(args: dict) -> dict:
max_results = int(args.get("max_results", 5))
graph = _get_graph()
try:
precedents = graph.find_similar_decisions(scenario, max_results=max_results)
min_similarity = float(args.get("min_similarity", 0.05))
precedents = graph.find_similar_decisions(
scenario, max_results=max_results, min_similarity=min_similarity)
return {"precedents": precedents if isinstance(precedents, list) else list(precedents)}
except Exception as exc:
return {"error": str(exc), "precedents": []}
@@ -260,16 +298,28 @@ def _tool_get_graph_analytics(args: dict) -> dict:
return {"error": str(exc)}
_EXPORT_GRAPH_FORMATS = ("turtle", "ttl", "nt", "xml", "json-ld", "json")
def _tool_export_graph(args: dict) -> dict:
"""Export the current knowledge graph to a serialised format."""
fmt = args.get("format", "json-ld")
if fmt not in _EXPORT_GRAPH_FORMATS:
return {
"error": f"Unsupported format '{fmt}'. Supported: {', '.join(_EXPORT_GRAPH_FORMATS)}"
}
graph = _get_graph()
try:
from semantica.export import RDFExporter, JSONExporter
from semantica.export import RDFExporter
# The exporters consume the canonical kg dict, not the ContextGraph
# object (regression: the old code passed the object straight through,
# so every branch failed — JSONExporter.export() with no file_path on
# the json branch, AttributeError on the RDF branches).
kg = graph.to_kg_dict()
if fmt in ("turtle", "ttl", "nt", "xml", "json-ld"):
result = RDFExporter().export_to_rdf(graph, format=fmt)
result = RDFExporter().export_to_rdf(kg, format=fmt)
else:
result = JSONExporter().export(graph)
result = json.dumps(kg, indent=2, ensure_ascii=False)
return {"format": fmt, "data": result}
except Exception as exc:
return {"error": str(exc)}
@@ -290,6 +340,160 @@ def _tool_get_graph_summary(args: dict) -> dict:
return {"error": str(exc), "graph_ready": False}
def _tool_update_node(args: dict) -> dict:
"""Update properties of an existing node and persist to SEMANTICA_KG_PATH.
Common use: mark an action node's status (todo/doing/done) with an
optional note. The graph is mutated in-memory then saved back to the
file it was loaded from, so changes survive server restarts.
"""
node_id = args.get("node_id", "")
if not node_id:
return {"error": "node_id is required"}
properties = args.get("properties", {})
if not isinstance(properties, dict) or not properties:
return {"error": "properties (non-empty object) is required"}
graph = _get_graph()
try:
if not graph.find_node(node_id):
return {"error": f"node '{node_id}' not found"}
graph.add_node_attribute(node_id, properties)
# Persist back to disk so the change survives restarts
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path:
graph.save_to_file(kg_path)
persisted = True
else:
persisted = False
updated = graph.find_node(node_id)
return {
"status": "updated",
"node_id": node_id,
"properties": {k: (updated.get("metadata") or {}).get(k) for k in properties},
"persisted": persisted,
}
except Exception as exc:
return {"error": str(exc)}
def _tool_delete_node(args: dict) -> dict:
"""Archive a node (soft delete) and persist to SEMANTICA_KG_PATH.
The node is kept in the graph for history but marked status='archived'.
Use to retire an action you no longer actively track.
"""
node_id = args.get("node_id", "")
if not node_id:
return {"error": "node_id is required"}
graph = _get_graph()
try:
if not graph.find_node(node_id):
return {"error": f"node '{node_id}' not found"}
graph.add_node_attribute(node_id, {"status": "archived"})
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path:
graph.save_to_file(kg_path)
return {"status": "archived", "node_id": node_id, "persisted": bool(kg_path)}
except Exception as exc:
return {"error": str(exc)}
def _tool_query_graph(args: dict) -> dict:
"""Query the live knowledge graph: node detail, neighbours, or keyword search.
mode:
- "node" : get one node by id (needs node_id)
- "neighbors": traverse up to `depth` hops from node_id (default depth=1)
- "search" : keyword search over node id+content (needs query)
"""
graph = _get_graph()
mode = args.get("mode", "neighbors")
try:
if mode == "node":
node_id = args.get("node_id", "")
if not node_id:
return {"error": "node_id is required"}
node = graph.find_node(node_id)
return {"node": node}
if mode == "neighbors":
node_id = args.get("node_id", "")
if not node_id:
return {"error": "node_id is required"}
depth = int(args.get("depth", 1))
rel_types = args.get("relationship_types")
if isinstance(rel_types, str):
rel_types = [rel_types]
rel_set = set(rel_types) if rel_types else None
limit = args.get("limit")
limit = int(limit) if limit is not None else None
depth = min(max(depth, 1), 5)
# Out-edges (multi-hop) via get_neighbors
nb = graph.get_neighbors(
node_id, hops=depth, relationship_types=rel_types, limit=limit,
)
out = [
{"id": n.get("id"), "type": n.get("type"),
"content": n.get("content"),
"relationship": n.get("relationship"),
"direction": "out", "hop": n.get("hop", 1)}
for n in (nb or [])
]
# In-edges (1-hop): scan edges whose target == node_id.
# Deduplicate by source node so that multiple edges between the
# same pair of nodes (different edge types) produce one entry.
# Stop early once we have already collected `limit` inbound results
# (if a limit is set) to avoid scanning the full edge list.
inb = []
seen_inbound = set()
for e in graph.find_edges():
if e.get("target") != node_id:
continue
if rel_set is not None and e.get("type") not in rel_set:
continue
src_id = e.get("source")
if src_id in seen_inbound:
continue
seen_inbound.add(src_id)
src = graph.find_node(src_id) or {}
inb.append({"id": src_id, "type": src.get("type"),
"content": src.get("content"),
"relationship": e.get("type"),
"direction": "in", "hop": 1})
# Early-exit: we already have `limit` inbound results; the
# combined list will be truncated to `limit` anyway.
if limit is not None and len(inb) >= limit:
break
neighbors = out + inb
# Apply final limit. Use ``is not None`` so limit=0 (zero results)
# is honoured correctly; ``if limit:`` would treat 0 as falsy.
if limit is not None:
neighbors = neighbors[:limit]
return {"node_id": node_id, "depth": depth, "neighbors": neighbors}
if mode == "search":
q = (args.get("query") or "").lower()
if not q:
return {"error": "query is required"}
node_type = args.get("node_type")
limit = int(args.get("limit", 50))
nodes = graph.find_nodes(node_type=node_type) if node_type else graph.find_nodes()
hits = []
for n in nodes:
# Check limit BEFORE appending so limit=0 returns empty.
if len(hits) >= limit:
break
blob = f"{n.get('id','')} {n.get('content','')}".lower()
if q in blob:
hits.append({"id": n.get("id"), "type": n.get("type"),
"content": n.get("content")})
return {"query": q, "results": hits, "total": len(hits)}
return {"error": f"unknown mode '{mode}': use node|neighbors|search"}
except Exception as exc:
return {"error": str(exc)}
# ══════════════════════════════════════════════════════════════════════════════
# MCP protocol tables
# ══════════════════════════════════════════════════════════════════════════════
@@ -301,7 +505,11 @@ TOOLS = [
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Input text to extract entities from"}
"text": {"type": "string", "description": "Input text to extract entities from"},
"model": {"type": "string", "description": "spaCy model name, e.g. 'zh_core_web_sm' for Chinese, 'en_core_web_sm' for English. Defaults to English pipeline."},
"language": {"type": "string", "description": "Language code, e.g. 'zh', 'en'."},
"method": {"type": "string", "description": "Extraction method: 'ml' (spaCy, default), 'huggingface', 'llm', 'pattern'."},
"confidence_threshold": {"type": "number", "description": "Minimum confidence 0-1 (default 0.5)."}
},
"required": ["text"],
},
@@ -313,7 +521,10 @@ TOOLS = [
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Input text to extract relations from"}
"text": {"type": "string", "description": "Input text to extract relations from"},
"model": {"type": "string", "description": "spaCy model name for dependency parsing, e.g. 'zh_core_web_sm'."},
"language": {"type": "string", "description": "Language code, e.g. 'zh'."},
"method": {"type": "string", "description": "Extraction method: 'pattern' (default), 'dependency', 'cooccurrence', 'huggingface', 'llm'."}
},
"required": ["text"],
},
@@ -441,7 +652,7 @@ TOOLS = [
"properties": {
"format": {
"type": "string",
"enum": ["turtle", "ttl", "nt", "xml", "json-ld", "json"],
"enum": list(_EXPORT_GRAPH_FORMATS),
"description": "Export format (default: json-ld)",
}
},
@@ -454,6 +665,48 @@ TOOLS = [
"inputSchema": {"type": "object", "properties": {}},
"_handler": _tool_get_graph_summary,
},
{
"name": "query_graph",
"description": "Query the live knowledge graph: get a node, traverse its neighbours (up to 5 hops), or keyword-search nodes by id+content.",
"inputSchema": {
"type": "object",
"properties": {
"mode": {"type": "string", "description": "node | neighbors | search (default: neighbors)"},
"node_id": {"type": "string", "description": "Node id (required for node/neighbors mode)"},
"depth": {"type": "integer", "description": "Hop depth for neighbors (1-5, default 1)"},
"relationship_types": {"type": "array", "items": {"type": "string"}, "description": "Optional filter by edge type(s)"},
"query": {"type": "string", "description": "Keyword for search mode (matched against node id+content)"},
"node_type": {"type": "string", "description": "Optional node_type filter for search mode"},
"limit": {"type": "integer", "description": "Max results for neighbors/search"}
},
},
"_handler": _tool_query_graph,
},
{
"name": "update_node",
"description": "Update properties of an existing node (e.g. mark an action todo/doing/done with a note) and persist to SEMANTICA_KG_PATH.",
"inputSchema": {
"type": "object",
"properties": {
"node_id": {"type": "string", "description": "Node id to update"},
"properties": {"type": "object", "description": "Property key-values to merge onto the node, e.g. {\"status\":\"done\",\"updated_at\":\"2026-08-13\",\"note\":\"...\"}"}
},
"required": ["node_id", "properties"],
},
"_handler": _tool_update_node,
},
{
"name": "delete_node",
"description": "Archive a node (soft delete: marks status='archived', keeps it for history) and persist to SEMANTICA_KG_PATH. Use to retire an action you no longer track.",
"inputSchema": {
"type": "object",
"properties": {
"node_id": {"type": "string", "description": "Node id to delete"}
},
"required": ["node_id"],
},
"_handler": _tool_delete_node,
},
]
RESOURCES = [
+6 -6
View File
@@ -98,7 +98,7 @@ class NormalizeConfig:
if value:
try:
if type_func == bool:
self._configs[config_key] = value.lower() in (
self._configs[config_key] = value.strip().lower() in (
"true",
"1",
"yes",
@@ -113,8 +113,8 @@ class NormalizeConfig:
for key, value in os.environ.items():
if key.startswith(env_prefix) and key not in env_mappings:
config_key = key[len(env_prefix) :].lower()
if value.lower() in ("true", "false"):
self._configs[config_key] = value.lower() == "true"
if value.strip().lower() in ("true", "false"):
self._configs[config_key] = value.strip().lower() == "true"
elif value.isdigit():
self._configs[config_key] = int(value)
else:
@@ -136,12 +136,12 @@ class NormalizeConfig:
value = os.getenv(env_key)
if value:
try:
if isinstance(default, int):
if isinstance(default, bool):
return value.strip().lower() in ("true", "1", "yes", "on")
elif isinstance(default, int):
return int(value)
elif isinstance(default, float):
return float(value)
elif isinstance(default, bool):
return value.lower() in ("true", "1", "yes", "on")
return value
except (ValueError, TypeError):
pass
+6 -6
View File
@@ -99,7 +99,7 @@ class OntologyConfig:
if value:
try:
if type_func == bool:
self._configs[config_key] = value.lower() in (
self._configs[config_key] = value.strip().lower() in (
"true",
"1",
"yes",
@@ -114,8 +114,8 @@ class OntologyConfig:
for key, value in os.environ.items():
if key.startswith(env_prefix) and key not in env_mappings:
config_key = key[len(env_prefix) :].lower()
if value.lower() in ("true", "false"):
self._configs[config_key] = value.lower() == "true"
if value.strip().lower() in ("true", "false"):
self._configs[config_key] = value.strip().lower() == "true"
elif value.isdigit():
self._configs[config_key] = int(value)
else:
@@ -137,12 +137,12 @@ class OntologyConfig:
value = os.getenv(env_key)
if value:
try:
if isinstance(default, int):
if isinstance(default, bool):
return value.strip().lower() in ("true", "1", "yes", "on")
elif isinstance(default, int):
return int(value)
elif isinstance(default, float):
return float(value)
elif isinstance(default, bool):
return value.lower() in ("true", "1", "yes", "on")
return value
except (ValueError, TypeError):
pass
+73 -22
View File
@@ -309,13 +309,15 @@ class OntologyGenerator:
concepts[entity_type] = {"instances": [], "relationships": []}
concepts[entity_type]["instances"].append(entity)
entity_aliases = self._build_entity_aliases(entities)
# Extract relationships
normalized_relationships = []
for rel_item in relationships:
# Normalize relationship to dictionary
rel = None
if isinstance(rel_item, dict):
rel = rel_item
rel = dict(rel_item)
elif hasattr(rel_item, "subject") and hasattr(rel_item, "predicate") and hasattr(rel_item, "object"):
# Handle Relation object (subject, predicate, object)
rel = {
@@ -356,26 +358,12 @@ class OntologyGenerator:
if not rel:
continue
rel_type = rel.get("type") or rel.get("relationship_type", "relatedTo")
source_type = rel.get("source_type")
target_type = rel.get("target_type")
# Try to resolve source/target types if not provided
if not source_type or source_type == "Entity":
# Look up source in entities list to find its type
source_name = rel.get("source")
for ent in entities:
if ent.get("name") == source_name or ent.get("text") == source_name:
source_type = ent.get("type") or ent.get("entity_type")
break
if not target_type or target_type == "Entity":
# Look up target in entities list to find its type
target_name = rel.get("target")
for ent in entities:
if ent.get("name") == target_name or ent.get("text") == target_name:
target_type = ent.get("type") or ent.get("entity_type")
break
source_type = self._resolve_relationship_endpoint_type(
rel, "source", entity_aliases
)
target_type = self._resolve_relationship_endpoint_type(
rel, "target", entity_aliases
)
# Update rel with resolved types
rel["source_type"] = source_type
@@ -392,6 +380,60 @@ class OntologyGenerator:
"relationships": normalized_relationships,
}
@staticmethod
def _build_entity_aliases(entities: List[Dict[str, Any]]) -> Dict[str, set]:
"""Build an unambiguous alias-to-type index for relationship endpoints."""
aliases: Dict[str, set] = {}
for entity in entities:
entity_type = entity.get("type") or entity.get("entity_type")
if not entity_type:
continue
for key in ("id", "entity_id", "name", "text", "label"):
if key not in entity or entity[key] is None or entity[key] == "":
continue
aliases.setdefault(str(entity[key]), set()).add(entity_type)
return aliases
@staticmethod
def _get_relationship_endpoint(rel: Dict[str, Any], endpoint: str) -> Any:
"""Return an endpoint value from either ID or legacy relationship fields."""
for key in (f"{endpoint}_id", endpoint):
if key not in rel:
continue
value = rel[key]
if value is None or value == "":
continue
if isinstance(value, dict):
for alias_key in ("id", "entity_id", "name", "text", "label"):
if alias_key not in value:
continue
alias_value = value[alias_key]
if alias_value is not None and alias_value != "":
return alias_value
continue
return value
return None
def _resolve_relationship_endpoint_type(
self, rel: Dict[str, Any], endpoint: str, aliases: Dict[str, set]
) -> Optional[str]:
"""Resolve an endpoint type without treating missing fields as aliases."""
explicit_type = rel.get(f"{endpoint}_type")
if explicit_type and explicit_type != "Entity":
return explicit_type
endpoint_value = self._get_relationship_endpoint(rel, endpoint)
if endpoint_value is not None:
candidates = aliases.get(str(endpoint_value), set())
if len(candidates) == 1:
return next(iter(candidates))
return explicit_type
def _stage2_yaml_to_definition(
self, semantic_network: Dict[str, Any], **options
) -> Dict[str, Any]:
@@ -840,7 +882,16 @@ class SHACLGenerator:
"""
self.logger = get_logger("ontology_shacl")
self.progress_tracker = get_progress_tracker()
self.base_uri = base_uri.rstrip("/") + "/"
# Preserve an RDF namespace that already ends in `#` (the common
# convention for vocabularies): `...manufacturing#` must not become
# `...manufacturing#/`, or every generated URI lands in the wrong
# namespace and SHACL validation silently targets nothing. Matches
# the `#`-aware normalization in `generate()`. Slash-terminated
# bases are collapsed to a single trailing `/` so redundant runs
# (`.../ns////`) cannot leak a different namespace into emitted IRIs.
self.base_uri = (
base_uri if base_uri.endswith("#") else base_uri.rstrip("/") + "/"
)
self.shapes_uri = shapes_uri or (self.base_uri + "shapes")
self.include_inherited = include_inherited
self.severity = severity
+106 -12
View File
@@ -117,6 +117,8 @@ class PropertyGenerator:
data_properties = self._infer_data_properties(entities, classes, **options)
properties.extend(data_properties)
properties = self._coalesce_normalized_properties(properties)
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
@@ -193,27 +195,87 @@ class PropertyGenerator:
return properties
def _coalesce_normalized_properties(
self, properties: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Merge same-kind properties that normalize to the same name."""
property_kinds = defaultdict(set)
for prop in properties:
property_kinds[prop["name"]].add(prop.get("type"))
collisions = {
name: sorted(kind for kind in kinds if kind is not None)
for name, kinds in property_kinds.items()
if len({kind for kind in kinds if kind is not None}) > 1
}
if collisions:
raise ValidationError(
"Normalized property names cannot be shared by object and "
"data properties.",
validation_context={"property_kind_collisions": collisions},
)
merged: Dict[tuple, Dict[str, Any]] = {}
result = []
for prop in properties:
key = (prop.get("type"), prop["name"])
existing = merged.get(key)
if existing is None:
merged[key] = prop
result.append(prop)
continue
existing["domain"] = self._merge_property_values(
existing.get("domain", []), prop.get("domain", [])
)
if prop.get("type") == "object":
existing["range"] = self._merge_property_values(
existing.get("range", []), prop.get("range", [])
)
existing_metadata = existing.setdefault("metadata", {})
existing_metadata["occurrence_count"] = (
existing_metadata.get("occurrence_count", 0)
+ prop.get("metadata", {}).get("occurrence_count", 0)
)
elif existing.get("range") != prop.get("range"):
existing["range"] = self._get_more_general_type(
existing["range"], prop["range"]
)
return result
@staticmethod
def _merge_property_values(current: Any, incoming: Any) -> List[Any]:
"""Merge scalar-or-list property values while preserving input order."""
values = list(current) if isinstance(current, list) else [current]
incoming_values = (
incoming if isinstance(incoming, list) else [incoming]
)
for value in incoming_values:
if value not in values:
values.append(value)
return [value for value in values if value is not None]
def _infer_data_properties(
self, entities: List[Dict[str, Any]], classes: List[Dict[str, Any]], **options
) -> List[Dict[str, Any]]:
"""Infer data properties from entity attributes."""
# Group entities by type
entity_types = defaultdict(list)
# Group entities by their inferred class so normalized class names remain
# aligned with the class definitions emitted by ClassInferrer.
class_entities = defaultdict(list)
class_lookup = self._build_class_type_lookup(classes)
for entity in entities:
entity_type = entity.get("type") or entity.get("entity_type", "Entity")
entity_types[entity_type].append(entity)
class_def = self._find_class_for_entity_type(entity_type, class_lookup)
if not class_def:
continue
class_name = class_def["name"]
class_entities[class_name].append(entity)
# Extract data properties for each class
properties = []
for entity_type, type_entities in entity_types.items():
# Find corresponding class
class_def = next(
(cls for cls in classes if cls["name"] == entity_type), None
)
if not class_def:
continue
for class_name, type_entities in class_entities.items():
# Extract data properties
data_props = self._extract_data_properties(type_entities)
@@ -233,7 +295,7 @@ class PropertyGenerator:
else None,
"label": normalized_name,
"comment": f"Data property for {prop_name}",
"domain": [entity_type],
"domain": [class_name],
"range": prop_type,
"metadata": {"inferred_from": prop_name},
}
@@ -242,6 +304,38 @@ class PropertyGenerator:
return properties
def _build_class_type_lookup(
self, classes: List[Dict[str, Any]]
) -> Dict[str, Dict[str, Any]]:
"""Build a lookup for raw, normalized, and recorded source type names."""
lookup: Dict[str, Dict[str, Any]] = {}
for class_def in classes:
class_name = class_def.get("name")
if class_name:
lookup.setdefault(str(class_name), class_def)
lookup.setdefault(
self.naming_conventions.normalize_class_name(str(class_name)),
class_def,
)
inferred_from = class_def.get("metadata", {}).get("inferred_from")
if inferred_from is not None:
lookup.setdefault(str(inferred_from), class_def)
lookup.setdefault(
self.naming_conventions.normalize_class_name(str(inferred_from)),
class_def,
)
return lookup
def _find_class_for_entity_type(
self, entity_type: Any, class_lookup: Dict[str, Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
"""Find a class using the precomputed type lookup."""
raw_type = str(entity_type)
normalized_type = self.naming_conventions.normalize_class_name(raw_type)
return class_lookup.get(raw_type) or class_lookup.get(normalized_type)
def _extract_data_properties(
self, entities: List[Dict[str, Any]]
) -> Dict[str, str]:
+90 -5
View File
@@ -70,7 +70,8 @@ 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.""" ;
modelled one; in the JSON-LD export the whole mapping is written as one
rdf:JSON literal so caller keys never expand into this namespace (#1146).""" ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
# ── Relationship terms (JSON-LD export) ──────────────────────────────────────
@@ -96,10 +97,10 @@ sem:target a owl:ObjectProperty ;
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:comment """The entity or 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. Emitted for both entities and relationships, so the
domain is left open rather than tied to sem:Relationship.""" ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
@@ -135,6 +136,90 @@ JSONExporter.export_to_jsonld in export/json_exporter.py.""" ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
# ── Metadata carried through from the graph builder ──────────────────────────
#
# The keys GraphBuilder and the Neo4j loader write into "metadata". Declared
# here because the RDF serializers emit them (#1154); a caller-supplied key is
# not declared here and is not emitted, because which namespace it belongs in
# is #1146.
sem:numEntities a owl:DatatypeProperty ;
rdfs:label "number of entities" ;
rdfs:comment """Count of entities in the graph as built, from
GraphBuilder.build_graph. A count of what was built, not a constraint on what
the graph contains: an export filtered after the fact will disagree with it.""" ;
rdfs:range xsd:integer ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:numRelationships a owl:DatatypeProperty ;
rdfs:label "number of relationships" ;
rdfs:comment "Count of relationships in the graph as built." ;
rdfs:range xsd:integer ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:temporalEnabled a owl:DatatypeProperty ;
rdfs:label "temporal enabled" ;
rdfs:comment """True when the builder was configured to track valid time.
False does not mean the graph is untimed; it means no temporal bounds were
recorded for it.""" ;
rdfs:range xsd:boolean ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:entityResolutionApplied a owl:DatatypeProperty ;
rdfs:label "entity resolution applied" ;
rdfs:comment """True when a resolver ran over the extracted entities, so a
consumer knows whether two nodes with the same surface text were ever
considered for merging.""" ;
rdfs:range xsd:boolean ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:builtAt a owl:DatatypeProperty ;
rdfs:label "built at" ;
rdfs:comment """When the graph was built, as GraphBuilder recorded it.
The range is xsd:string, deliberately, and not xsd:dateTime. GraphBuilder
stamps with a timezone-naive datetime.now(), and #1114 is the demonstration of
what typing such a value as xsd:dateTime costs: a timezone-qualified SPARQL
filter over it raises an indeterminate comparison and silently drops the row.
#1121 swept the export and provenance modules to an explicit UTC offset and
deliberately left kg/ alone, because the context and vector-store modules
compare against naive values already on disk. Until that sweep reaches
GraphBuilder this value is a string that looks like a timestamp, and saying so
is more useful than a type that invites arithmetic it cannot support.""" ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:snapshotAt a owl:DatatypeProperty ;
rdfs:label "snapshot at" ;
rdfs:comment """The point in time a snapshot represents, from
GraphBuilder.create_snapshot. A string for the same reason as sem:builtAt.""" ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:sourceSystem a owl:DatatypeProperty ;
rdfs:label "source system" ;
rdfs:comment """The system a graph was loaded from, currently the literal
"neo4j" written by GraphBuilder.load_from_neo4j.
Named sourceSystem rather than source because sem:source is already the
ObjectProperty carrying the subject of a reified relationship. The metadata key
is still "source"; the exporter maps the key to this term.""" ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:sourceUri a owl:ObjectProperty ;
rdfs:label "source URI" ;
rdfs:comment """The address of the system a graph was loaded from. The one
metadata term whose value is a node rather than a literal, because it names a
thing rather than describing one.""" ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:sourceDatabase a owl:DatatypeProperty ;
rdfs:label "source database" ;
rdfs:comment "The database within the source system a graph was loaded from." ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
# ── Temporal term (OWL-Time export) ──────────────────────────────────────────
sem:openEndedInterval a owl:DatatypeProperty ;
+6 -6
View File
@@ -98,7 +98,7 @@ class ParseConfig:
if value:
try:
if type_func == bool:
self._configs[config_key] = value.lower() in (
self._configs[config_key] = value.strip().lower() in (
"true",
"1",
"yes",
@@ -113,8 +113,8 @@ class ParseConfig:
for key, value in os.environ.items():
if key.startswith(env_prefix) and key not in env_mappings:
config_key = key[len(env_prefix) :].lower()
if value.lower() in ("true", "false"):
self._configs[config_key] = value.lower() == "true"
if value.strip().lower() in ("true", "false"):
self._configs[config_key] = value.strip().lower() == "true"
elif value.isdigit():
self._configs[config_key] = int(value)
else:
@@ -136,12 +136,12 @@ class ParseConfig:
value = os.getenv(env_key)
if value:
try:
if isinstance(default, int):
if isinstance(default, bool):
return value.strip().lower() in ("true", "1", "yes", "on")
elif isinstance(default, int):
return int(value)
elif isinstance(default, float):
return float(value)
elif isinstance(default, bool):
return value.lower() in ("true", "1", "yes", "on")
return value
except (ValueError, TypeError):
pass
+1
View File
@@ -33,6 +33,7 @@ License: MIT
"""
import email
import email.message
from dataclasses import dataclass, field
from email import message_from_bytes, message_from_string
from email.header import decode_header
+26 -2
View File
@@ -143,15 +143,39 @@ class PDFParser:
)
pages.append(page_data)
full_text = "\n\n".join(page.text for page in pages)
# Scanned/image-only PDFs have no text layer; warn so the
# failure surfaces at parse time instead of downstream.
# Check any() over page texts to avoid a temporary stripped
# copy of the full concatenation for large documents.
no_text = (
options.get("extract_text", True)
and pages
and not any(page.text.strip() for page in pages)
)
if no_text:
self.logger.warning(
f"PDF {file_path.name}: parsed {len(pages)} page(s) but "
f"extracted no text. This is likely a scanned "
f"(image-only) PDF. Retry with "
f"parse_pdf(..., method='docling', enable_ocr=True) "
f"for OCR-based extraction."
)
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message=f"Parsed {len(pages)} pages",
message=(
f"Parsed {len(pages)} page(s) (no text layer detected)"
if no_text
else f"Parsed {len(pages)} page(s)"
),
)
return {
"metadata": metadata.__dict__,
"pages": [page.__dict__ for page in pages],
"full_text": "\n\n".join(page.text for page in pages),
"full_text": full_text,
"total_pages": len(pdf.pages),
}
+370 -60
View File
@@ -32,8 +32,10 @@ Author: Semantica Contributors
License: MIT
"""
import copy
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
@@ -59,6 +61,14 @@ class PipelineStatus(Enum):
STOPPED = "stopped"
class _ParallelResultContractError(ProcessingError):
"""Raised when a parallel step violates the dict-result contract.
Contract violations are deterministic: re-running the handler cannot
change its return type, so the retry loop must be skipped entirely.
"""
@dataclass
class ExecutionResult:
"""Pipeline execution result."""
@@ -242,11 +252,67 @@ class ExecutionEngine:
return ExecutionResult(success=False, output=None, errors=[str(e)])
def _execute_steps(self, pipeline: Pipeline, data: Any, **options) -> Any:
"""Execute pipeline steps."""
# Sort steps by dependencies (topological sort)
sorted_steps = self._topological_sort(pipeline.steps)
"""
Execute pipeline steps.
Steps are grouped into dependency layers. When a pipeline is
configured with parallelism > 1, a layer whose steps are all marked
``parallel_safe`` and whose input is a dict is executed concurrently
(bounded by the effective parallelism); every other layer runs
sequentially, preserving the default serial behaviour.
"""
effective_parallelism = self._get_effective_parallelism(pipeline)
if effective_parallelism <= 1:
return self._execute_steps_sequential(pipeline, data, **options)
layers = self._group_steps_by_dependency_level(pipeline.steps)
current_data = data
for layer in layers:
if self.pipeline_status.get(pipeline.name) == PipelineStatus.STOPPED:
break
# Wait if paused
while self.pipeline_status.get(pipeline.name) == PipelineStatus.PAUSED:
time.sleep(0.1)
if self._can_run_layer_in_parallel(layer, current_data):
merged = self._execute_parallel_group(
layer,
current_data,
effective_parallelism,
pipeline_name=pipeline.name,
**options,
)
if merged is None:
# Input isolation failed before any handler started;
# run this layer sequentially instead.
current_data = self._execute_steps_sequential(
pipeline, current_data, steps=layer, **options
)
else:
current_data = merged
else:
current_data = self._execute_steps_sequential(
pipeline, current_data, steps=layer, **options
)
return current_data
def _execute_steps_sequential(
self,
pipeline: Pipeline,
data: Any,
steps: Optional[List[PipelineStep]] = None,
**options,
) -> Any:
"""Execute steps sequentially following dependency order."""
if steps is None:
sorted_steps = self._topological_sort(pipeline.steps)
else:
sorted_steps = list(steps)
# Execute steps
current_data = data
total_steps = len(sorted_steps)
@@ -258,76 +324,320 @@ class ExecutionEngine:
while self.pipeline_status.get(pipeline.name) == PipelineStatus.PAUSED:
time.sleep(0.1)
# Track step execution
step_tracking_id = self.progress_tracker.start_tracking(
module="pipeline",
submodule=step.step_type or step.name,
message=f"Step {step_idx + 1}/{total_steps}: {step.name}",
current_data = self._execute_step_with_retries(
step,
current_data,
step_label=f"Step {step_idx + 1}/{total_steps}: {step.name}",
pipeline_name=pipeline.name,
**options,
)
try:
# Execute step
step.status = StepStatus.RUNNING
step_result = self._execute_step(step, current_data, **options)
step.status = StepStatus.COMPLETED
step.result = step_result
current_data = step_result
return current_data
def _execute_step_with_retries(
self,
step: PipelineStep,
data: Any,
step_label: Optional[str] = None,
pipeline_name: Optional[str] = None,
require_dict_result: bool = False,
**options,
) -> Any:
"""
Execute a single step with retry handling.
Shared by the sequential and parallel execution paths so that retry
policies, step status tracking and progress reporting behave
identically. Returns the step result, or raises the final error
after retries are exhausted.
When ``require_dict_result`` is set (parallel layers), a handler
returning a non-dict raises ProcessingError before the step is
marked completed, so status and progress reporting stay consistent.
Such contract violations are never retried: the handler's return
type cannot change between attempts.
"""
step_tracking_id = self.progress_tracker.start_tracking(
module="pipeline",
# Pipeline identity + step name keep tracking IDs unique so
# concurrent steps of the same step_type cannot overwrite each
# other's progress records.
submodule=(
f"{pipeline_name or 'pipeline'}:"
f"{step.step_type or 'step'}:{step.name}"
),
message=step_label or f"Executing step: {step.name}",
)
try:
step.status = StepStatus.RUNNING
step_result = self._execute_step(step, data, **options)
if require_dict_result and not isinstance(step_result, dict):
raise _ParallelResultContractError(
f"Step '{step.name}' is marked parallel_safe and must "
f"return a dict so parallel results can be merged, got "
f"{type(step_result).__name__}"
)
step.status = StepStatus.COMPLETED
step.result = step_result
self.progress_tracker.stop_tracking(
step_tracking_id,
status="completed",
message=f"Completed step: {step.name}",
)
return step_result
except Exception as e:
step.status = StepStatus.FAILED
step.error = e
# Contract violations are deterministic failures: re-running
# the handler cannot change its return type, so never consult
# the retry policy for them.
if isinstance(e, _ParallelResultContractError):
self.progress_tracker.stop_tracking(
step_tracking_id, status="failed", message=str(e)
)
raise
# Retry loop respecting max_retries from the policy
retry_policy = self.failure_handler.get_retry_policy(step.step_type)
max_retries = retry_policy.max_retries if retry_policy else 0
retry_count = 0
success = False
while retry_count < max_retries:
recovery_result = self.failure_handler.handle_step_failure(step, e)
if not recovery_result.get("retry", False):
break
retry_delay = recovery_result.get("retry_delay", 0.0)
if retry_delay > 0:
time.sleep(retry_delay)
self.progress_tracker.update_tracking(
step_tracking_id,
status="running",
message=f"Retrying step: {step.name} (attempt {retry_count + 1})",
)
step.status = StepStatus.RUNNING
try:
step_result = self._execute_step(step, data, **options)
if require_dict_result and not isinstance(
step_result, dict
):
raise _ParallelResultContractError(
f"Step '{step.name}' is marked parallel_safe "
f"and must return a dict so parallel results "
f"can be merged, got "
f"{type(step_result).__name__}"
)
step.status = StepStatus.COMPLETED
step.result = step_result
success = True
break
except Exception as retry_e:
step.status = StepStatus.FAILED
step.error = retry_e
e = retry_e
retry_count += 1
if success:
self.progress_tracker.stop_tracking(
step_tracking_id,
status="completed",
message=f"Completed step: {step.name}",
message=f"Retry successful: {step.name}",
)
return step_result
else:
self.progress_tracker.stop_tracking(
step_tracking_id, status="failed", message=str(e)
)
raise e
except Exception as e:
step.status = StepStatus.FAILED
step.error = e
def _get_effective_parallelism(self, pipeline: Pipeline) -> int:
"""Return the parallelism actually used for this pipeline."""
configured = pipeline.config.get("parallelism", 1)
if not isinstance(configured, int) or configured <= 0:
return 1
return min(configured, self.parallelism_manager.max_workers)
# Retry loop respecting max_retries from the policy
retry_policy = self.failure_handler.get_retry_policy(step.step_type)
max_retries = retry_policy.max_retries if retry_policy else 0
retry_count = 0
success = False
def _group_steps_by_dependency_level(
self, steps: List[PipelineStep]
) -> List[List[PipelineStep]]:
"""
Group steps into dependency layers, preserving declaration order.
while retry_count < max_retries:
recovery_result = self.failure_handler.handle_step_failure(step, e)
if not recovery_result.get("retry", False):
break
retry_delay = recovery_result.get("retry_delay", 0.0)
if retry_delay > 0:
time.sleep(retry_delay)
self.progress_tracker.update_tracking(
step_tracking_id,
status="running",
message=f"Retrying step: {step.name} (attempt {retry_count + 1})",
Circular or unknown dependencies raise ValidationError so the
parallel path fails deterministically, matching the validation
behaviour of the serial topological sort.
"""
step_map = {step.name: step for step in steps}
levels: Dict[str, int] = {}
visiting: set = set()
for step in steps:
for dep in step.dependencies:
if dep not in step_map:
raise ValidationError(
f"Step '{step.name}' depends on unknown step '{dep}'"
)
step.status = StepStatus.RUNNING
try:
step_result = self._execute_step(step, current_data, **options)
step.status = StepStatus.COMPLETED
step.result = step_result
current_data = step_result
success = True
break
except Exception as retry_e:
step.status = StepStatus.FAILED
step.error = retry_e
e = retry_e
retry_count += 1
if success:
self.progress_tracker.stop_tracking(
step_tracking_id,
status="completed",
message=f"Retry successful: {step.name}",
)
def get_level(step_name: str) -> int:
if step_name in levels:
return levels[step_name]
if step_name in visiting:
raise ValidationError(
"Circular dependency detected in pipeline "
f"(cycle passes through step '{step_name}')"
)
visiting.add(step_name)
step = step_map[step_name]
if not step.dependencies:
level = 0
else:
level = max(get_level(dep) for dep in step.dependencies) + 1
visiting.discard(step_name)
levels[step_name] = level
return level
for step in steps:
get_level(step.name)
grouped: Dict[int, List[PipelineStep]] = {}
for step in steps:
grouped.setdefault(levels[step.name], []).append(step)
return [grouped[level] for level in sorted(grouped)]
def _can_run_layer_in_parallel(self, layer: List[PipelineStep], data: Any) -> bool:
"""Check whether a dependency layer can safely run in parallel."""
if len(layer) <= 1:
return False
if not isinstance(data, dict):
return False
for step in layer:
# Strict boolean check: truthy non-bool values (e.g. the
# string "false") must never opt a step into concurrency.
if getattr(step, "parallel_safe", False) is not True:
return False
if getattr(step, "delta_mode", False):
return False
return True
def _execute_parallel_group(
self,
layer: List[PipelineStep],
data: Any,
effective_parallelism: int,
pipeline_name: Optional[str] = None,
**options,
) -> Optional[Any]:
"""
Execute a dependency layer concurrently.
Per-step inputs are deep-copied before any handler starts so that
parallel steps do not share mutable state. Returns the merged dict
result, or None when input isolation failed (before any handler
ran) and the layer should fall back to sequential execution.
"""
# Isolate per-step inputs before starting any handler
try:
step_inputs = {step.name: copy.deepcopy(data) for step in layer}
except Exception as e:
self.logger.warning(
f"Falling back to sequential execution: input for parallel "
f"layer could not be isolated ({e})"
)
return None
step_results: Dict[str, Any] = {}
failure: Optional[BaseException] = None
max_workers = min(effective_parallelism, len(layer))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
self._execute_step_with_retries,
step,
step_inputs[step.name],
# Validate the dict-result contract inside the shared
# lifecycle path, before completion is reported.
pipeline_name=pipeline_name,
require_dict_result=True,
**options,
): step
for step in layer
}
for future in as_completed(futures):
step = futures[future]
try:
step_result = future.result()
except Exception as e:
if failure is None:
failure = e
# Cancel steps that have not started yet
for pending in futures:
pending.cancel()
else:
self.progress_tracker.stop_tracking(
step_tracking_id, status="failed", message=str(e)
step_results[step.name] = step_result
if failure is not None:
raise failure
return self._merge_parallel_results(data, layer, step_results)
def _merge_parallel_results(
self,
base: Dict[str, Any],
layer: List[PipelineStep],
step_results: Dict[str, Any],
) -> Dict[str, Any]:
"""
Merge the results of a parallel layer into a single dict.
Steps are processed in declaration order (never by completion
order). Keys whose values are unchanged from the shared base input
are skipped (handlers commonly return complete dicts such as
``{**data, ...}``); only added or changed keys count as branch
writes. Keys written with equal values by multiple steps are
allowed; conflicting values for the same key raise a
ProcessingError naming the key and both steps. Ambiguous equality
comparisons count as changed, never as unchanged.
"""
merged = dict(base)
key_sources: Dict[str, str] = {}
for step in layer:
step_result = step_results.get(step.name)
if step_result is None:
continue
for key, value in step_result.items():
if key in base and self._values_equal(base[key], value):
# Echo of the shared input: not a branch write, so it
# cannot conflict with a sibling that changes the key.
continue
if key in key_sources and not self._values_equal(
merged.get(key), value
):
raise ProcessingError(
f"Conflicting values for key '{key}' in parallel step "
f"results: step '{step.name}' produced {value!r}, but "
f"step '{key_sources[key]}' previously produced "
f"{merged.get(key)!r}"
)
raise e
key_sources[key] = step.name
merged[key] = value
return merged
@staticmethod
def _values_equal(left: Any, right: Any) -> bool:
"""Safely compare two values; ambiguous comparisons count as conflicts."""
try:
return bool(left == right)
except (TypeError, ValueError):
return False
return current_data
def _execute_step(self, step: PipelineStep, data: Any, **options) -> Any:
"""
@@ -379,7 +689,7 @@ class ExecutionEngine:
data = delta_result
if step.handler:
if step.handler is not None:
return step.handler(data, **step.config, **options)
else:
return data
+82 -4
View File
@@ -67,6 +67,7 @@ class PipelineStep:
delta_mode: bool = False
base_version_id: Optional[str] = None
target_version_id: Optional[str] = None
parallel_safe: bool = False
@dataclass
@@ -131,16 +132,27 @@ class PipelineBuilder:
delta_mode = config.pop("delta_mode", False)
base_version_id = config.pop("base_version_id", None)
target_version_id = config.pop("target_version_id", None)
dependencies = config.pop("dependencies", [])
handler = config.pop("handler", None)
parallel_safe = config.pop("parallel_safe", False)
if not isinstance(parallel_safe, bool):
raise ValidationError(
f"parallel_safe must be a boolean, got "
f"{type(parallel_safe).__name__} for step '{step_name}'"
)
if handler is None:
handler = self.step_registry.get(step_type)
step = PipelineStep(
name=step_name,
step_type=step_type,
config=config,
dependencies=config.get("dependencies", []),
handler=config.get("handler"),
dependencies=dependencies,
handler=handler,
delta_mode = delta_mode,
base_version_id=base_version_id,
target_version_id=target_version_id,
parallel_safe=parallel_safe,
)
self.steps.append(step)
@@ -182,6 +194,14 @@ class PipelineBuilder:
Returns:
Self for method chaining
"""
if (
isinstance(level, bool)
or not isinstance(level, int)
or level <= 0
):
raise ValidationError(
f"Parallelism level must be a positive integer, got {level!r}"
)
self.pipeline_config["parallelism"] = level
return self
@@ -272,7 +292,29 @@ class PipelineBuilder:
step_name = step_config.get("name")
step_type = step_config.get("type")
if step_name and step_type:
self.add_step(step_name, step_type, **step_config.get("config", {}))
step = self.add_step(
step_name, step_type, **step_config.get("config", {})
)
step.dependencies = list(
step_config.get("dependencies", step.dependencies)
)
step.delta_mode = step_config.get("delta_mode", step.delta_mode)
step.base_version_id = step_config.get(
"base_version_id", step.base_version_id
)
step.target_version_id = step_config.get(
"target_version_id", step.target_version_id
)
raw_parallel_safe = step_config.get(
"parallel_safe", step.parallel_safe
)
if not isinstance(raw_parallel_safe, bool):
raise ValidationError(
"parallel_safe must be a boolean for step "
f"'{step_name}', got "
f"{type(raw_parallel_safe).__name__}"
)
step.parallel_safe = raw_parallel_safe
# Set parallelism if specified
if "parallelism" in pipeline_config:
@@ -328,6 +370,7 @@ class PipelineBuilder:
"type": step.step_type,
"config": step.config,
"dependencies": step.dependencies,
"parallel_safe": step.parallel_safe,
}
for step in self.steps
],
@@ -398,18 +441,35 @@ class PipelineSerializer:
Returns:
Serialized pipeline
Notes:
Step handlers are runtime callables and are intentionally omitted from
the serialized representation. They must be rebound after deserialization.
"""
reserved_config_keys = {
"handler",
"dependencies",
"delta_mode",
"base_version_id",
"target_version_id",
"parallel_safe",
}
pipeline_data = {
"name": pipeline.name,
"steps": [
{
"name": step.name,
"type": step.step_type,
"config": step.config,
"config": {
key: value
for key, value in step.config.items()
if key not in reserved_config_keys
},
"dependencies": step.dependencies,
"delta_mode": getattr(step, "delta_mode", False),
"base_version_id": getattr(step, "base_version_id", None),
"target_version_id": getattr(step, "target_version_id", None),
"parallel_safe": getattr(step, "parallel_safe", False),
}
for step in pipeline.steps
],
@@ -445,6 +505,24 @@ class PipelineSerializer:
else:
pipeline_data = serialized_pipeline
# Runtime handlers are process-local and cannot be reconstructed safely
# from serialized data. Copy before sanitizing so dict inputs are not mutated.
pipeline_data = dict(pipeline_data)
sanitized_steps = []
for step_data in pipeline_data.get("steps", []):
sanitized_step = dict(step_data)
step_config = dict(sanitized_step.get("config", {}))
step_config.pop("handler", None)
sanitized_step["config"] = step_config
sanitized_steps.append(sanitized_step)
pipeline_data["steps"] = sanitized_steps
# Reapply pipeline-level config (e.g. parallelism) at the top level
# so build_pipeline picks it up
serialized_config = pipeline_data.pop("config", None) or {}
for key, value in serialized_config.items():
pipeline_data.setdefault(key, value)
# Reconstruct pipeline
builder = PipelineBuilder(**self.config)
pipeline = builder.build_pipeline(pipeline_data, **options)
+13
View File
@@ -8,6 +8,13 @@ and native Datalog evaluation.
"""
from .reasoner import Reasoner, InferenceResult, Rule, Fact, RuleType
from .reasoner import (
Action,
AssertAction,
RetractAction,
CallAction,
EmitEventAction,
)
from .graph_reasoner import GraphReasoner
from .explanation_generator import (
Explanation,
@@ -37,6 +44,12 @@ __all__ = [
"Rule",
"Fact",
"RuleType",
# Rule-driven actions
"Action",
"AssertAction",
"RetractAction",
"CallAction",
"EmitEventAction",
# Rete engine
"ReteEngine",
"ReteNode",
+424 -19
View File
@@ -7,13 +7,16 @@ supported by the Semantica framework. It serves as a facade for different reason
import re
import uuid
from collections.abc import Mapping, Sequence, Set as AbstractSet
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Dict, List, Optional, Set, Tuple, Union, Callable
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
class RuleType(Enum):
"""Rule types."""
IMPLICATION = "implication"
@@ -21,6 +24,306 @@ class RuleType(Enum):
CONSTRAINT = "constraint"
TRANSFORMATION = "transformation"
def _substitute_variables(template: str, bindings: Dict[str, str]) -> str:
"""Substitute ``?var`` placeholders with their bound values, token-aware.
A naive ``str.replace(f"?{var}", value)`` corrupts placeholders that share
a prefix -- e.g. binding ``?x`` would also rewrite the ``?x`` inside ``?xy``.
We replace every ``?word`` token in a single regex pass so that only whole
variable names are matched (``\\w+`` never partially matches a longer name),
leaving unbound placeholders untouched.
"""
if not bindings:
return template
def _replace(match: "re.Match") -> str:
var_name = match.group(1)
# Preserve unbound placeholders verbatim.
return str(bindings[var_name]) if var_name in bindings else match.group(0)
return re.sub(r"\?(\w+)", _replace, template)
def _canonicalize_activation_value(
value: Any, active_containers: Optional[Dict[int, int]] = None
) -> Tuple[Any, ...]:
"""Convert nested activation data into a deterministic, hashable value."""
if active_containers is None:
active_containers = {}
value_type = (type(value).__module__, type(value).__qualname__)
is_mapping = isinstance(value, Mapping)
is_sequence = isinstance(value, Sequence) and not isinstance(
value, (str, bytes, bytearray)
)
is_set = isinstance(value, AbstractSet) and not isinstance(
value, (str, bytes, bytearray)
)
if not (is_mapping or is_sequence or is_set):
return ("scalar", value_type, repr(value))
object_id = id(value)
if object_id in active_containers:
return ("reference", active_containers[object_id])
active_containers[object_id] = len(active_containers)
try:
if is_mapping:
keyed_items = [
(
_canonicalize_activation_value(key, active_containers),
item,
)
for key, item in value.items()
]
keyed_items.sort(key=lambda entry: repr(entry[0]))
entries = tuple(
(
key,
_canonicalize_activation_value(item, active_containers),
)
for key, item in keyed_items
)
return ("mapping", value_type, entries)
if is_sequence:
return (
"sequence",
value_type,
tuple(
_canonicalize_activation_value(item, active_containers)
for item in value
),
)
items = tuple(
sorted(
(
_canonicalize_activation_value(item, active_containers)
for item in value
),
key=repr,
)
)
return ("set", value_type, items)
finally:
del active_containers[object_id]
def _make_activation_key(
rule_id: str, bindings: Dict[str, Any], fact_tokens: List[Any]
) -> Tuple[Any, ...]:
"""Return a stable identity for one concrete rule activation."""
canonical_bindings = tuple(
sorted(
(str(name), _canonicalize_activation_value(value))
for name, value in bindings.items()
)
)
return (
rule_id,
canonical_bindings,
tuple(
sorted(
(_canonicalize_activation_value(token) for token in fact_tokens),
key=repr,
)
),
)
def _parse_fact(fact: str) -> Optional[Tuple[str, List[str]]]:
"""Parse a ``Predicate(arg1, arg2, ...)`` fact string.
Returns ``(predicate, [args])`` or ``None`` when the fact is not in the
canonical predicate form (e.g. a bare atom). Whitespace around args is
stripped and empty arg lists are supported (``Foo()`` -> ``("Foo", [])``).
"""
match = re.match(r"^\s*([^()\s]+)\s*\((.*)\)\s*$", fact)
if not match:
return None
predicate = match.group(1)
inner = match.group(2).strip()
if not inner:
return predicate, []
args = [arg.strip() for arg in inner.split(",")]
return predicate, args
def _write_fact_to_graph(graph: Any, fact: str, *, retract: bool = False) -> None:
"""Persist (or remove) a fact against a knowledge-graph-like target.
Write-back follows an explicit, ordered protocol so that
``AssertAction(write_back=True)`` never silently no-ops:
1. If the target exposes an explicit fact API (``add_fact`` / ``assert_fact``
for asserts, ``remove_fact`` / ``retract_fact`` / ``discard_fact`` for
retracts), that is used verbatim.
2. Otherwise, if the target looks like the canonical
:class:`~semantica.kg.knowledge_graph.KnowledgeGraph` (has ``entities``
and ``relationships`` lists), the fact is translated into a node
(single-arg predicate) or relationship (two-arg predicate) and
added/removed accordingly.
3. Any other target, or a fact that cannot be translated, raises
:class:`ValueError` so the failure surfaces instead of being swallowed.
"""
if retract:
for method_name in ("retract_fact", "remove_fact", "discard_fact"):
method = getattr(graph, method_name, None)
if callable(method):
method(fact)
return
else:
for method_name in ("add_fact", "assert_fact"):
method = getattr(graph, method_name, None)
if callable(method):
method(fact)
return
entities = getattr(graph, "entities", None)
relationships = getattr(graph, "relationships", None)
if isinstance(entities, list) and isinstance(relationships, list):
parsed = _parse_fact(fact)
if parsed is None:
raise ValueError(
f"Cannot translate fact {fact!r} into graph node/relationship: "
"expected canonical Predicate(args) form."
)
predicate, args = parsed
if len(args) == 1:
node = {"id": args[0], "type": predicate}
if retract:
_remove_matching(
entities,
lambda e: e.get("id") == args[0] and e.get("type") == predicate,
)
elif node not in entities:
entities.append(node)
return
if len(args) == 2:
rel = {"source": args[0], "target": args[1], "type": predicate}
if retract:
_remove_matching(
relationships,
lambda r: r.get("source") == args[0]
and r.get("target") == args[1]
and r.get("type") == predicate,
)
elif rel not in relationships:
relationships.append(rel)
return
raise ValueError(
f"Cannot write fact {fact!r} to graph: only unary (node) and binary "
"(relationship) predicates are supported by the default adapter."
)
raise ValueError(
f"knowledge_graph target {type(graph).__name__!r} does not expose a "
"supported write-back API (add_fact/assert_fact or entities/relationships)."
)
def _remove_matching(items: List[Dict[str, Any]], predicate: Callable[[Dict[str, Any]], bool]) -> None:
"""Remove in place every dict in ``items`` for which ``predicate`` is True."""
items[:] = [item for item in items if not predicate(item)]
class Action:
"""Base class for an action fired when a rule matches.
Actions turn the reasoner from a pure inference engine into a
production-rule system: when a rule's conditions match, its actions run
with the match's variable bindings, allowing side effects (asserting or
retracting facts, calling external tools, emitting events) rather than
only deriving a new fact.
Subclasses implement :meth:`execute`, which receives the substituted
``bindings`` and the owning ``reasoner`` and returns an optional
description of what happened (used for provenance / explanation).
"""
def execute(self, bindings: Dict[str, str], reasoner: "Reasoner") -> Optional[str]:
raise NotImplementedError
@staticmethod
def _substitute(template: str, bindings: Dict[str, str]) -> str:
return _substitute_variables(template, bindings)
@dataclass
class AssertAction(Action):
"""Assert a new fact when the rule fires.
``fact`` may contain ``?var`` placeholders that are substituted with the
match bindings. If ``write_back`` is set and the reasoner exposes a
knowledge graph, the fact is also written there.
"""
fact: str
write_back: bool = False
def execute(self, bindings: Dict[str, str], reasoner: "Reasoner") -> Optional[str]:
concrete = self._substitute(self.fact, bindings)
reasoner.facts.add(concrete)
if self.write_back and getattr(reasoner, "knowledge_graph", None) is not None:
_write_fact_to_graph(reasoner.knowledge_graph, concrete)
return f"assert {concrete}"
@dataclass
class RetractAction(Action):
"""Retract a fact when the rule fires (basic truth maintenance).
If ``write_back`` is set and the reasoner exposes a knowledge graph, the
fact is also removed there using the graph's delete semantics (mirroring
:class:`AssertAction`'s write-back).
"""
fact: str
write_back: bool = False
def execute(self, bindings: Dict[str, str], reasoner: "Reasoner") -> Optional[str]:
concrete = self._substitute(self.fact, bindings)
reasoner.facts.discard(concrete)
if self.write_back and getattr(reasoner, "knowledge_graph", None) is not None:
_write_fact_to_graph(reasoner.knowledge_graph, concrete, retract=True)
return f"retract {concrete}"
@dataclass
class CallAction(Action):
"""Call an external function/tool when the rule fires.
Wraps an arbitrary callable, which is invoked as ``func(bindings,
reasoner)``. This is the structured replacement for the previously
unused ``Rule.handler`` callback.
"""
func: Callable
name: str = "call"
def execute(self, bindings: Dict[str, str], reasoner: "Reasoner") -> Optional[str]:
self.func(bindings, reasoner)
return f"call {self.name}"
@dataclass
class EmitEventAction(Action):
"""Emit an event to the reasoner's registered event sink when fired.
The event name may contain ``?var`` placeholders. Events are delivered to
any callable registered via :meth:`Reasoner.on_event`.
"""
event: str
payload: Dict[str, Any] = field(default_factory=dict)
def execute(self, bindings: Dict[str, str], reasoner: "Reasoner") -> Optional[str]:
concrete = self._substitute(self.event, bindings)
sink = getattr(reasoner, "_event_sink", None)
if callable(sink):
sink(concrete, {**self.payload, "bindings": dict(bindings)})
return f"emit {concrete}"
@dataclass
class Rule:
"""Simplified rule definition."""
@@ -32,6 +335,7 @@ class Rule:
confidence: float = 1.0
priority: int = 0
handler: Optional[Callable] = None
actions: List[Action] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
@@ -79,7 +383,70 @@ class Reasoner:
self.rules: List[Rule] = []
self.facts: Set[str] = set()
self.rule_counter = 0
self._fired_activations: Set[Tuple[Any, ...]] = set()
# Optional knowledge graph for AssertAction(write_back=True) targets.
self.knowledge_graph = kwargs.get("knowledge_graph")
# Optional event sink for EmitEventAction; register via on_event().
self._event_sink: Optional[Callable] = None
# When True, action-induced fact changes are recorded for provenance
# via _record_action() -> self.action_log.
self.provenance: bool = bool(kwargs.get("provenance", False))
self.action_log: List[Dict[str, Any]] = []
def on_event(self, sink: Callable) -> None:
"""Register a callable ``sink(event_name, payload)`` for EmitEventAction."""
self._event_sink = sink
def _record_action(
self, rule: "Rule", action: "Action", description: Optional[str], bindings: Dict[str, str]
) -> None:
"""Record a fired action for provenance / explanation when enabled.
Each entry is a structured dict carrying an ISO-8601 ``timestamp`` and a
parsed ``operation``/``fact`` split (when the description follows the
``"<op> <fact>"`` convention used by the built-in actions) so that
downstream consumers such as :class:`ExplanationGenerator` and the
provenance layer can reason about *what changed* without re-parsing the
free-text description.
"""
if not self.provenance or description is None:
return
operation, _, subject = description.partition(" ")
self.action_log.append(
{
"action_id": uuid.uuid4().hex[:8],
"rule_id": rule.rule_id,
"action": type(action).__name__,
"operation": operation or None,
"fact": subject or None,
"description": description,
"bindings": dict(bindings),
"confidence": rule.confidence,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
)
def _fire_actions(self, rule: "Rule", bindings: Dict[str, str]) -> None:
"""Run a fired rule's actions (and legacy handler) with match bindings.
Backward compatible: a rule with an old-style ``handler`` but no
``actions`` still has its handler invoked, so pre-existing rules keep
working while new rules use the structured Action layer.
"""
actions = list(rule.actions)
if rule.handler is not None:
actions.append(CallAction(rule.handler, name=f"handler:{rule.rule_id}"))
for action in actions:
try:
description = action.execute(bindings, self)
self._record_action(rule, action, description, bindings)
except Exception as exc: # noqa: BLE001
self.logger.error(
f"Error executing action {type(action).__name__} "
f"for rule '{rule.rule_id}': {exc}"
)
def add_rule(self, rule_def: Union[str, Rule]) -> Rule:
"""Add a rule to the reasoner.
@@ -161,6 +528,20 @@ class Reasoner:
Returns:
List of inferred facts (conclusions)
"""
return [result.conclusion for result in self.infer_with_results(facts, rules)]
def infer_with_results(
self,
facts: Union[List[Any], Dict[str, Any]],
rules: Optional[List[Union[str, Rule]]] = None,
) -> List[InferenceResult]:
"""Infer new facts and return the full :class:`InferenceResult` objects.
Unlike :meth:`infer_facts` (which returns only conclusion strings for
backward compatibility), this preserves each result's ``rule_used``,
``premises`` and ``confidence`` so callers such as the provenance
wrapper can record real confidence values instead of ``None``.
"""
tracking_id = self.progress_tracker.start_tracking(
module="reasoning",
submodule="Reasoner",
@@ -180,17 +561,14 @@ class Reasoner:
# Perform inference
results = self.forward_chain()
# Extract conclusions from results
inferred_facts = [result.conclusion for result in results]
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message=f"Inferred {len(inferred_facts)} new facts"
message=f"Inferred {len(results)} new facts"
)
return inferred_facts
return results
except Exception as e:
self.progress_tracker.stop_tracking(
@@ -213,7 +591,15 @@ class Reasoner:
new_facts_added = True
max_iterations = self.config.get("max_iterations", 50)
iteration = 0
# Activations (rule + concrete bindings + matched facts) whose actions
# have already fired. Actions are side-effecting and must
# fire exactly once per distinct match, decoupled from whether the
# rule's *conclusion* is new. This fixes two failure modes:
# * A valid binding whose conclusion is already known (or duplicated
# within a pass) previously never fired its actions.
# * A RetractAction that removes a premise of its own rule previously
# re-fired every pass, iterating to max_iterations. Recording the
# activation means it fires once and stops driving iterations.
while new_facts_added and iteration < max_iterations:
new_facts_added = False
iteration += 1
@@ -236,7 +622,21 @@ class Reasoner:
pass_results: Dict[str, InferenceResult] = {}
for rule in self.rules:
for conclusion, matched_facts in self._match_rule(rule):
for conclusion, matched_facts, bindings in self._match_rule(rule):
# Fire this activation's actions exactly once, independent
# of the conclusion-dedup below. Keyed by rule id + the
# concrete bindings so distinct matches each fire, but a
# repeated match (same bindings across passes) does not.
if rule.actions or rule.handler is not None:
activation_key = _make_activation_key(
rule.rule_id,
bindings,
matched_facts,
)
if activation_key not in self._fired_activations:
self._fired_activations.add(activation_key)
self._fire_actions(rule, bindings)
if conclusion in pass_results:
# Another derivation of a conclusion already produced
# earlier in this same pass: merge premises, dedup.
@@ -372,14 +772,17 @@ class Reasoner:
conclusion=conclusion_str.strip()
)
def _match_rule(self, rule: Rule) -> List[Tuple[str, List[str]]]:
def _match_rule(self, rule: Rule) -> List[Tuple[str, List[str], Dict[str, str]]]:
"""
Match rule conditions against facts and return instantiated conclusions
paired with the facts that satisfied each condition.
paired with the facts that satisfied each condition and the variable
bindings that produced them.
Returns:
List of (conclusion, matched_facts) tuples, where matched_facts is
the ordered list of facts bound to this rule's conditions.
List of (conclusion, matched_facts, bindings) tuples, where
matched_facts is the ordered list of facts bound to this rule's
conditions and bindings maps variable name -> matched value (used
to fire the rule's actions).
"""
if not rule.conditions:
return []
@@ -410,7 +813,7 @@ class Reasoner:
results = []
for bindings, matched_facts in bindings_list:
instantiated_conclusion = self._substitute(rule.conclusion, bindings)
results.append((instantiated_conclusion, matched_facts))
results.append((instantiated_conclusion, matched_facts, bindings))
return results
@@ -456,16 +859,18 @@ class Reasoner:
def _substitute(self, pattern: str, bindings: Dict[str, str]) -> str:
"""Substitute variables in a pattern with bound values."""
result = pattern
for var, value in bindings.items():
result = result.replace(f"?{var}", value)
return result
return _substitute_variables(pattern, bindings)
def reset_action_history(self) -> None:
"""Allow previously fired rule activations to execute their actions again."""
self._fired_activations.clear()
def clear(self) -> None:
"""Clear facts and rules."""
"""Clear facts, rules, and action activation history."""
self.facts.clear()
self.rules.clear()
self.rule_counter = 0
self.reset_action_history()
def reset(self) -> None:
"""Alias for clear()."""
+24 -9
View File
@@ -13,9 +13,9 @@ Author: Semantica Contributors
License: MIT
"""
from typing import Any, Optional
from datetime import datetime
import uuid
from datetime import datetime
from typing import Any, Optional
class ReasoningEngineWithProvenance:
@@ -28,10 +28,10 @@ class ReasoningEngineWithProvenance:
is_automated: bool = True,
**config,
):
from .reasoning_engine import ReasoningEngine
from .reasoner import Reasoner
self.provenance = provenance
self._engine = ReasoningEngine(**config)
self._engine = Reasoner(provenance=provenance, **config)
self._prov_manager = None
self._agent_id = agent_id or self.__class__.__name__
self._is_automated = is_automated
@@ -43,12 +43,27 @@ class ReasoningEngineWithProvenance:
except ImportError:
self.provenance = False
def infer(self, premises: Any, source: str = None, **kwargs):
"""Perform inference with provenance tracking."""
def infer(self, premises: Any, source: str = None, rules: Any = None):
"""Perform inference with provenance tracking.
Only the reasoner's real parameters (``premises`` and ``rules``) are
forwarded to the underlying engine; arbitrary keyword arguments are no
longer passed through (they previously reached
``Reasoner.infer_facts`` -- which accepts only ``facts``/``rules`` --
and raised ``TypeError``).
"""
activity_started_at_time = datetime.utcnow().isoformat()
result = self._engine.infer(premises, **kwargs)
results = self._engine.infer_with_results(premises, rules)
activity_ended_at_time = datetime.utcnow().isoformat()
# Aggregate confidence across the derived results (min = weakest link);
# None only when nothing was inferred.
confidence = (
min(r.confidence for r in results) if results else None
)
# Preserve the historical return shape: a list of conclusion strings.
inferred = [r.conclusion for r in results]
if self.provenance and self._prov_manager:
self._prov_manager.track_entity(
entity_id=f"inference_{uuid.uuid4().hex[:8]}",
@@ -61,11 +76,11 @@ class ReasoningEngineWithProvenance:
activity_ended_at_time=activity_ended_at_time,
metadata={
"premises_count": len(premises) if hasattr(premises, '__len__') else 1,
"confidence": getattr(result, 'confidence', None)
"confidence": confidence,
}
)
return result
return inferred
def __getattr__(self, name):
return getattr(self._engine, name)
+117 -14
View File
@@ -33,14 +33,75 @@ Author: Semantica Contributors
License: MIT
"""
from collections import defaultdict
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, Tuple
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from .reasoner import Fact, Rule
from .reasoner import Fact, Rule, _make_activation_key
def _extract_bindings(condition: Any, fact: Fact) -> Dict[str, Any]:
"""Extract ``?var`` bindings by matching a condition pattern against a fact.
``condition`` is the pattern stored on the alpha node (typically a string
like ``"Person(?x)"``); ``fact`` is the working-memory :class:`Fact`. The
fact's canonical string form (``Predicate(arg1, arg2, ...)``) is matched
against the pattern using the same ``?\\w+`` placeholder convention as the
Reasoner, so downstream actions receive real bindings (e.g. ``{"x": "John"}``)
instead of the empty dict that previously left ``?x`` placeholders
unsubstituted.
Returns an empty dict when the condition is not a string pattern or does
not match -- callers treat that as "no bindings extracted".
"""
if not isinstance(condition, str):
return {}
segments = re.split(r"(\?\w+)", condition)
seen_vars: Set[str] = set()
p_regex = ""
for seg in segments:
if seg.startswith("?"):
var_name = seg[1:]
if var_name in seen_vars:
p_regex += f"(?P={var_name})"
else:
p_regex += f"(?P<{var_name}>.+?)"
seen_vars.add(var_name)
else:
p_regex += re.escape(seg)
p_regex = f"^{p_regex}$"
try:
match = re.match(p_regex, str(fact))
except re.error:
return {}
if not match:
return {}
return {k: v for k, v in match.groupdict().items() if v is not None}
def _bindings_for_rule(rule: Rule, facts: List[Fact]) -> Dict[str, Any]:
"""Merge ``?var`` bindings from matching a rule's conditions against facts.
Each fact is matched against every condition of the rule; the first
condition that yields bindings for a fact contributes them. Bindings from
all facts are merged so multi-condition (joined) rules receive the full
variable environment. Later conflicting values do not overwrite earlier
ones, preserving the binding that a join already validated.
"""
bindings: Dict[str, Any] = {}
for fact in facts:
for condition in rule.conditions:
extracted = _extract_bindings(condition, fact)
if not extracted:
continue
for key, value in extracted.items():
bindings.setdefault(key, value)
break
return bindings
@dataclass
@@ -58,7 +119,7 @@ class ReteNode:
def __init__(self, node_id: str):
self.node_id = node_id
self.children: List["ReteNode"] = []
self.children: List[ReteNode] = []
class AlphaNode(ReteNode):
@@ -151,6 +212,17 @@ class ReteEngine:
self.facts: List[Fact] = []
self.fact_counter = 0
self.node_counter = 0
self._executed_activations: Set[Tuple[Any, ...]] = set()
# Optional Reasoner used to fire rule-driven actions on match. When
# set, execute_matches() runs each matched rule's ``actions`` (and any
# legacy ``handler``) through the Reasoner's action machinery so that
# Rete-based matching benefits from the same production-rule behaviour
# as forward_chain(). Left None keeps the pure-matching mode.
self.reasoner: Optional[Any] = self.config.get("reasoner")
def bind_reasoner(self, reasoner: Any) -> None:
"""Attach a Reasoner so matched rules can fire their actions."""
self.reasoner = reasoner
def build_network(self, rules: List[Rule]) -> None:
"""
@@ -166,6 +238,7 @@ class ReteEngine:
)
try:
self.reset_action_history()
self.network.clear()
self.progress_tracker.update_tracking(
@@ -252,15 +325,24 @@ class ReteEngine:
# Propagate to children
for grandchild in child.children:
if isinstance(grandchild, TerminalNode):
facts = [left_fact, fact]
match = Match(
rule=grandchild.rule,
facts=[left_fact, fact],
facts=facts,
bindings=_bindings_for_rule(
grandchild.rule, facts
),
confidence=1.0,
)
grandchild.activate(match)
elif isinstance(child, TerminalNode):
# Direct activation
match = Match(rule=child.rule, facts=[fact], confidence=1.0)
match = Match(
rule=child.rule,
facts=[fact],
bindings=_bindings_for_rule(child.rule, [fact]),
confidence=1.0,
)
child.activate(match)
def match_patterns(self, facts: Optional[List[Fact]] = None) -> List[Match]:
@@ -276,7 +358,7 @@ class ReteEngine:
tracking_id = self.progress_tracker.start_tracking(
module="reasoning",
submodule="ReteEngine",
message=f"Matching patterns using Rete algorithm",
message="Matching patterns using Rete algorithm",
)
try:
@@ -339,10 +421,28 @@ class ReteEngine:
)
results = []
for match in matches:
# Conclusions are the pure inference result and remain
# independent from optional side-effect execution below.
results.append(match.rule.conclusion)
try:
# Execute rule
result = match.rule.conclusion
results.append(result)
# Fire the rule's actions (and any legacy handler) through
# the bound Reasoner so Rete matching produces the same
# side effects / provenance as forward_chain(). Falls back
# to just recording the conclusion when no Reasoner is bound.
if self.reasoner is not None and (
match.rule.actions or match.rule.handler is not None
):
activation_key = _make_activation_key(
match.rule.rule_id,
match.bindings,
[
(fact.fact_id, fact.predicate, fact.arguments)
for fact in match.facts
],
)
if activation_key not in self._executed_activations:
self._executed_activations.add(activation_key)
self.reasoner._fire_actions(match.rule, match.bindings)
except Exception as e:
self.logger.error(f"Error executing match: {e}")
@@ -359,13 +459,16 @@ class ReteEngine:
)
raise
def reset_action_history(self) -> None:
"""Allow previously executed activations to fire their actions again."""
self._executed_activations.clear()
def reset(self) -> None:
"""Reset Rete engine."""
"""Reset Rete working memory and action activation history."""
self.facts.clear()
self.reset_action_history()
for node in self.network.values():
if isinstance(node, AlphaNode):
node.matches.clear()
elif isinstance(node, BetaNode):
if isinstance(node, AlphaNode) or isinstance(node, BetaNode):
node.matches.clear()
elif isinstance(node, TerminalNode):
node.activations.clear()
+2 -6
View File
@@ -482,8 +482,8 @@ class SeedDataManager:
List of loaded data records as dictionaries
Raises:
ProcessingError: If API request fails, response parsing fails, or
requests library is not available
ProcessingError: If the API request fails (connection error,
timeout, non-2xx status) or the response cannot be parsed
Example:
>>> records = manager.load_from_api(
@@ -559,10 +559,6 @@ class SeedDataManager:
self.logger.info(f"Loaded {len(records)} records from API: {full_url}")
return records
except (ImportError, OSError):
raise ProcessingError(
"requests library not available. Install with: pip install requests"
)
except Exception as e:
raise ProcessingError(f"Failed to load from API: {e}") from e
+50 -92
View File
@@ -140,6 +140,47 @@ _result_cache = ExtractionCache(
if not config.get("cache_enabled", True):
_result_cache.enabled = False
# Generation kwargs that affect provider output and must therefore be part of
# the cache key. This is the union of every generation-affecting parameter
# read across providers.py, including params picked up outside _add_if_set
# (e.g. AnthropicProvider's manual pass-through loop). Sensitive values
# (api_key, token, etc.) are already filtered out by
# ExtractionCache._generate_key, so they need not be excluded here.
_GENERATION_CACHE_KEYS = frozenset({
"max_tokens",
"max_completion_tokens",
"temperature",
"top_p",
"top_k",
"seed",
"frequency_penalty",
"presence_penalty",
"stop",
"stop_sequences", # Anthropic/Gemini spelling of "stop"
"logit_bias",
"user",
"system", # Anthropic system prompt
"metadata", # Anthropic request metadata
"candidate_count", # Gemini
"repeat_penalty", # Ollama
"num_ctx", # Ollama
"context_window", # Ollama alias for num_ctx
})
def _generation_cache_params(kwargs: dict) -> dict:
"""Return the subset of *kwargs* that affects generation output.
Only keys listed in ``_GENERATION_CACHE_KEYS`` are included so that
irrelevant or sensitive caller kwargs do not pollute the cache key.
Values that are ``None`` are omitted; a caller passing
``temperature=None`` is equivalent to not passing it at all.
"""
return {
k: v for k, v in kwargs.items()
if k in _GENERATION_CACHE_KEYS and v is not None
}
# Try to import spaCy
from ..utils.helpers import safe_import
@@ -957,6 +998,7 @@ def extract_entities_llm(
"max_text_length": max_text_length,
"structured_output_mode": structured_output_mode,
"entity_types": kwargs.get("entity_types"),
**_generation_cache_params(kwargs),
}
cached_result = _result_cache.get("entities", text, **cache_params)
if cached_result is not None:
@@ -1124,47 +1166,6 @@ Text to extract from:
return []
def _parse_entity_result(result: Any, provider: str, model: Optional[str]) -> List[Entity]:
"""Helper to parse raw LLM result into Entity objects."""
entities = []
items = []
if isinstance(result, list):
items = result
elif isinstance(result, dict):
# Handle cases where LLM wraps the list in a key
for key in ["entities", "data", "results"]:
if key in result and isinstance(result[key], list):
items = result[key]
break
if not items and "text" in result: # Single object instead of list
items = [result]
for item in items:
if not isinstance(item, dict):
continue
text = item.get("text", "")
if not text:
continue
entities.append(
Entity(
text=text,
label=item.get("label", "UNKNOWN"),
start_char=item.get("start", 0),
end_char=item.get("end", 0),
confidence=item.get("confidence", 0.9),
metadata={
"provider": provider,
"model": model,
"extraction_method": "llm",
},
)
)
return entities
def _extract_entities_chunked(
text: str,
provider: str,
@@ -1747,7 +1748,8 @@ def extract_relations_llm(
"relation_types": kwargs.get("relation_types"),
"extract_temporal_bounds": extract_temporal_bounds,
# Include entities hash/str in cache key implicitly via **cache_params
"entities_hash": hash(tuple(sorted([e.text for e in entities]))) if entities else 0
"entities_hash": hash(tuple(sorted([e.text for e in entities]))) if entities else 0,
**_generation_cache_params(kwargs),
}
cached_result = _result_cache.get("relations", text, **cache_params)
if cached_result is not None:
@@ -1947,13 +1949,10 @@ Entities found in text: {entities_str}"""
"[methods.extract_relations_llm] Calling llm.generate_typed (%s/%s)...",
provider, model,
)
# Only forward minimal, safe parameters to provider calls
call_kwargs = {}
if "temperature" in kwargs:
call_kwargs["temperature"] = kwargs["temperature"]
if "verbose" in kwargs:
call_kwargs["verbose"] = kwargs["verbose"]
# Forward all caller-supplied generation kwargs so they reach
# generate_typed and the underlying provider API. max_retries is
# always set from the explicit parameter.
call_kwargs = kwargs.copy()
call_kwargs["max_retries"] = max_retries
# Select schema based on whether temporal extraction is requested
@@ -2405,7 +2404,8 @@ def extract_triplets_llm(
"triplet_types": kwargs.get("triplet_types"),
# Include entities/relations hash in cache key implicitly via **cache_params
"entities_hash": hash(tuple(sorted([e.text for e in entities]))) if entities else 0,
"relations_hash": hash(tuple(sorted([str(r) for r in relations]))) if relations else 0
"relations_hash": hash(tuple(sorted([str(r) for r in relations]))) if relations else 0,
**_generation_cache_params(kwargs),
}
cached_result = _result_cache.get("triplets", text, **cache_params)
if cached_result is not None:
@@ -2559,48 +2559,6 @@ Text to extract from:
return []
def _parse_triplet_result(result: Any, provider: str, model: Optional[str]) -> List[Triplet]:
"""Helper to parse raw LLM result into Triplet objects."""
triplets = []
items = []
if isinstance(result, list):
items = result
elif isinstance(result, dict):
for key in ["triplets", "data", "results"]:
if key in result and isinstance(result[key], list):
items = result[key]
break
if not items and "subject" in result:
items = [result]
for item in items:
if not isinstance(item, dict):
continue
subject = item.get("subject", "")
predicate = item.get("predicate", "")
obj = item.get("object", "")
if not subject or not predicate or not obj:
continue
triplets.append(
Triplet(
subject=str(subject),
predicate=str(predicate),
object=str(obj),
confidence=item.get("confidence", 0.9),
metadata={
"provider": provider,
"model": model,
"extraction_method": "llm",
},
)
)
return triplets
def _extract_triplets_chunked(
text: str,
provider: str,
+6 -40
View File
@@ -139,17 +139,19 @@ class NERExtractor:
if not self.progress_tracker.enabled:
self.progress_tracker.enabled = True
# Initialize spaCy model if ML method is used
self.nlp = None
# Validate the spaCy runtime up front if ML method is used. The model
# itself is loaded lazily by extract_entities_ml() through the
# process-level cache in methods.py; this instance only tracks whether
# ML dispatch should be attempted at all.
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
# module-level import graph and routes validation 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)
load_spacy_model(self.model_name)
except OSError:
self.logger.warning(
f"spaCy model {self.model_name} not found. ML method will fallback."
@@ -535,42 +537,6 @@ class NERExtractor:
return processed
def _extract_with_spacy(
self, text: str, min_confidence: float, entity_types: Optional[List[str]]
) -> List[Entity]:
"""Extract entities using spaCy."""
entities = []
doc = self.nlp(text)
for ent in doc.ents:
# Filter by entity types if specified
if entity_types and ent.label_ not in entity_types:
continue
# Get confidence if available
confidence = 1.0
if hasattr(ent, "confidence"):
confidence = ent.confidence
elif hasattr(ent, "score"):
confidence = ent.score
if confidence >= min_confidence:
entities.append(
Entity(
text=ent.text,
label=ent.label_,
start_char=ent.start_char,
end_char=ent.end_char,
confidence=confidence,
metadata={
"lemma": ent.lemma_ if hasattr(ent, "lemma_") else ent.text
},
)
)
return entities
def _extract_fallback(self, text: str) -> List[Entity]:
"""Fallback entity extraction using simple patterns."""
entities = []
+3 -3
View File
@@ -123,12 +123,12 @@ class SplitConfig:
if value:
try:
# Try to convert to appropriate type
if isinstance(default, int):
if isinstance(default, bool):
return value.strip().lower() in ("true", "1", "yes", "on")
elif isinstance(default, int):
return int(value)
elif isinstance(default, float):
return float(value)
elif isinstance(default, bool):
return value.lower() in ("true", "1", "yes", "on")
return value
except (ValueError, TypeError):
pass
-20
View File
@@ -101,7 +101,6 @@ from .triplet_store import TripletStore
# Global store registry
_global_stores: Dict[str, TripletStore] = {}
_default_store_id: Optional[str] = None
_global_query_engine: Optional[QueryEngine] = None
_global_bulk_loader: Optional[BulkLoader] = None
@@ -131,25 +130,6 @@ def _get_store(store_id: Optional[str] = None) -> TripletStore:
return _global_stores[target_id]
def _get_query_engine() -> QueryEngine:
"""Get or create global QueryEngine instance."""
global _global_query_engine
if _global_query_engine is None:
# We need a store backend for the engine, but QueryEngine in this module
# seems to be initialized with config in the old code.
# In the new code, TripletStore has its own query_engine.
# If we use this standalone function, we might need to rely on the store's engine.
# But let's keep a standalone one if needed, or better, delegate to store.
config = triplet_store_config.get_all()
# QueryEngine now expects a backend, but we can initialize it without one
# if we pass the backend at execution time?
# Checking QueryEngine implementation... it takes `store_backend` in __init__.
# So we can't easily have a global one without a store.
# We'll rely on the store's engine.
pass
return None # Deprecated use of global engine
def _get_bulk_loader() -> BulkLoader:
"""Get or create global BulkLoader instance."""
global _global_bulk_loader
+52 -7
View File
@@ -42,6 +42,10 @@ class OxigraphStore:
ProcessingError: If the store cannot be opened.
"""
self.logger = get_logger("oxigraph_store")
# Accept storage_path as an alias for path (matches the convention used
# by other Semantica stores). Pop it so it isn't left in self.config.
if path is None and "storage_path" in config:
path = config.pop("storage_path")
self.config = config
self.path = path if path is not None else config.get("path")
@@ -74,24 +78,65 @@ class OxigraphStore:
) from exc
def add_triplet(self, triplet: Triplet, **options) -> Dict[str, Any]:
"""Add one triplet to the default graph or ``options['graph']``."""
return self.add_triplets([triplet], **options)
"""Add one triplet to the default graph or ``options['graph']``.
def add_triplets(self, triplets: List[Triplet], **options) -> Dict[str, Any]:
"""Add triplets in one native Oxigraph batch."""
The write is committed to the store's in-memory state immediately.
pyoxigraph's background threads will persist it to disk shortly
afterward; call :meth:`flush` explicitly if you need a synchronous
durability guarantee before reopening or crashing.
"""
try:
graph_name = self._graph_name(options.get("graph"))
quads = [self._to_quad(triplet, graph_name) for triplet in triplets]
self.store.extend(quads)
self.store.extend([self._to_quad(triplet, graph_name)])
return {
"success": True,
"triplets_loaded": len(triplets),
"triplets_loaded": 1,
"graph": options.get("graph"),
}
except Exception as exc:
self.logger.error(f"Oxigraph load failed: {exc}")
raise ProcessingError(f"Oxigraph load failed: {exc}") from exc
def add_triplets(self, triplets: List[Triplet], **options) -> Dict[str, Any]:
"""Add triplets in one native Oxigraph batch.
The batch is written transactionally and then explicitly flushed to
disk before returning. This makes the full batch durable without
requiring a separate :meth:`flush` call. In-memory stores skip the
flush (there is nothing to sync).
For high-volume imports the :class:`~.bulk_loader.BulkLoader` splits
work into chunks and calls this method once per chunk, so each chunk
lands as one atomic, durable unit.
"""
try:
graph_name = self._graph_name(options.get("graph"))
quads = [self._to_quad(triplet, graph_name) for triplet in triplets]
self.store.extend(quads)
except Exception as exc:
self.logger.error(f"Oxigraph load failed: {exc}")
raise ProcessingError(f"Oxigraph load failed: {exc}") from exc
# Flush is kept outside the write try/except so that a flush I/O error
# does not produce a misleading "load failed" message when extend()
# already committed the batch successfully.
if self.path is not None:
try:
self.flush()
except OSError as exc:
self.logger.warning(
f"Oxigraph flush failed after successful write: {exc}"
)
raise ProcessingError(
f"Oxigraph flush failed after successful write: {exc}"
) from exc
return {
"success": True,
"triplets_loaded": len(triplets),
"graph": options.get("graph"),
}
def bulk_load(self, triplets: List[Triplet], **options) -> Dict[str, Any]:
"""Load a batch of triplets using Oxigraph's native bulk operation."""
return self.add_triplets(triplets, **options)
+7 -6
View File
@@ -28,7 +28,7 @@ License: MIT
import re
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from urllib.parse import quote, urlparse
import requests
from rdflib import Graph, Literal
@@ -67,7 +67,8 @@ class RDF4JStore:
self.progress_tracker.enabled = True
self.endpoint = endpoint.rstrip("/")
self.repository_id = config.get("repository_id", "default")
self.repository_id = repository_id or config.get("repository_id", "default")
self._encoded_repository_id = quote(self.repository_id, safe="")
self.username = config.get("username")
self.password = config.get("password")
self.timeout = config.get("timeout", 30)
@@ -79,7 +80,7 @@ class RDF4JStore:
"""Connect to RDF4J server."""
try:
# Test connection
test_url = f"{self.endpoint}/repositories/{self.repository_id}"
test_url = f"{self.endpoint}/repositories/{self._encoded_repository_id}"
response = requests.get(
test_url,
timeout=self.timeout,
@@ -100,11 +101,11 @@ class RDF4JStore:
def _get_sparql_endpoint(self) -> str:
"""Get SPARQL query endpoint."""
return f"{self.endpoint}/repositories/{self.repository_id}"
return f"{self.endpoint}/repositories/{self._encoded_repository_id}"
def _get_update_endpoint(self) -> str:
"""Get SPARQL Update endpoint."""
return f"{self.endpoint}/repositories/{self.repository_id}/statements"
return f"{self.endpoint}/repositories/{self._encoded_repository_id}/statements"
def _is_construct_query(self, query: str) -> bool:
"""
@@ -163,7 +164,7 @@ class RDF4JStore:
"""
# RDF4J transaction support
transaction_url = (
f"{self.endpoint}/repositories/{self.repository_id}/transactions"
f"{self.endpoint}/repositories/{self._encoded_repository_id}/transactions"
)
try:
+34 -5
View File
@@ -64,6 +64,29 @@ def _progress_disabled_from_env() -> bool:
"on",
)
def _progress_forced_from_env() -> bool:
"""Return whether console progress is forced on despite a non-interactive stdout."""
return os.getenv("SEMANTICA_FORCE_PROGRESS", "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
def _stdout_is_tty() -> bool:
"""Return whether stdout is an interactive terminal.
Replacement streams do not always implement ``isatty`` and closed streams can
raise, so both cases are treated as non-interactive.
"""
try:
return bool(sys.stdout is not None and sys.stdout.isatty())
except (AttributeError, ValueError):
return False
# Try to import IPython for Jupyter support
try:
from IPython import get_ipython
@@ -1040,18 +1063,24 @@ class ProgressTracker:
# Create displays
self.displays: List[ProgressDisplay] = []
# Console output only suits an interactive stdout. When output is piped or
# redirected (scripts, CI logs) the progress bars and their escape
# sequences would otherwise drown the program's own output.
console_ok = _stdout_is_tty() or self.is_jupyter or _progress_forced_from_env()
# Always try Jupyter first if available, fallback to console
if IPYTHON_AVAILABLE:
# Try to detect Jupyter - if available, use it
if self.is_jupyter and not self.disable_jupyter_progress:
self.displays.append(JupyterProgressDisplay(use_emoji=use_emoji))
# Also add console as fallback for immediate feedback
self.displays.append(
ConsoleProgressDisplay(
use_emoji=use_emoji, update_interval=update_interval
if console_ok:
self.displays.append(
ConsoleProgressDisplay(
use_emoji=use_emoji, update_interval=update_interval
)
)
)
else:
elif console_ok:
self.displays.append(
ConsoleProgressDisplay(
use_emoji=use_emoji, update_interval=update_interval
+3 -3
View File
@@ -5,11 +5,11 @@ This module provides the worker process for the Semantica framework,
enabling distributed and background task processing.
"""
import time
import signal
import sys
from .utils.logging import get_logger, setup_logging
import time
from .core.orchestrator import Semantica
from .utils.logging import get_logger, setup_logging
# Initialize logging
setup_logging()

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