Compare commits

..
Author SHA1 Message Date
Zohaib Hassnain c6ccc3bd74 fix(ci): hash-pin pgvector install, scope PR trigger to main, drop dead test cleanup, fix vacuous UUID assert, fail loud on unreachable explicit TEST_PGVECTOR_URL 2026-09-02 00:36:10 +05:00
Zohaib Hassnain 1634a5ffd2 test(vector_store): fix two pgvector tests the first live CI run exposed 2026-09-01 23:17:20 +05:00
Zohaib Hassnain 2126e74738 ci: fail the integration job when the live suite skips, drop the hardcoded credential 2026-09-01 23:17:20 +05:00
Zohaib Hassnain 081be8871b ci: add integration workflow with a live pgvector service 2026-09-01 23:17:20 +05:00
e040d84d59 feat(context): add ErasureCoordinator for cross-store entity erasure (#1027)
* feat(context): add ErasureCoordinator for cross-store entity erasure

purge_node() is graph-scope by design (#957), so an entity removed from the
graph can survive verbatim as an AgentMemory item and as an embedding. The
changelog names GDPR Article 17 as purge's motivation, and an Article 17
erasure the vector store can still answer queries from is not an erasure --
it is worse than none, because purge_node() returns True and writes a
tombstone attesting the content is gone.

ErasureCoordinator composes the existing public APIs to drive the cascade and
returns an ErasureReceipt recording what each store reported. Nothing in
context_graph.py or agent_memory.py changes behaviorally; ContextGraph keeps
its documented graph-scope contract instead of acquiring references that would
invert the dependency.

Honest partial reporting is the point. Stores report erased / not_found /
not_configured / unsupported / failed, and complete is False when any store
reports unsupported or failed. FAISS, Milvus and Weaviate expose no delete at
all, so erasure genuinely cannot be completed on them today -- the receipt
says so rather than reporting a success it did not achieve.

Erasure runs outward-in (vectors, memory, graph). The tombstone is the durable
attestation, so writing it first would let a crash mid-cascade leave a record
claiming more than happened; erasing the graph last leaves a partial failure
recoverable and honest.

The memory sweep pages until dry and re-queries afterwards rather than
trusting one find_by_entity() call, whose limit=10 default silently truncates
the very check a caller uses to decide the erasure is done. Unsupported vector
backends are detected by probing the wrapped backend, since the VectorStore
facade declares delete_vectors() for every backend and only raises
NotImplementedError once called.

27 tests against real ContextGraph/AgentMemory instances, including the
25-items-on-one-entity regression that fails against a naive single-call
sweep. Full tests/context/ suite: 596 passed.

Closes #1018

* docs(context): document ErasureCoordinator in the context API reference

* test(context): exercise ErasureCoordinator against a real VectorStore

The vector-leg tests asserted the three backend shapes the coordinator
expects -- delete_vectors / delete / neither -- against fakes, which is worth
exactly as much as the assumption that a real store looks like one of them.
VectorStore(backend="inmemory") runs without external services, so it can
hold that assumption to account.

Adds the end-to-end case the receipt actually attests to: a real ContextGraph,
AgentMemory and VectorStore, where the embedding is written by
AgentMemory.store() and has to be gone afterwards. That exercises the memory
leg's own delete_memory() vector cascade rather than the coordinator's model
of it.

The real backend also pins a limit worth knowing before trusting the receipt:
it pops the ids and returns True whether or not they were there, and no
backend offers a portable existence check, so `erased` on the vectors leg
means the store accepted the delete for the ids given -- not that embeddings
were really removed. The memory leg re-queries to confirm and so is the
stronger claim. Documented on STATUS_ERASED, _erase_vectors(), and both status
tables.

tests/context/: 599 passed.

* fix(context): address review findings on ErasureCoordinator

Timestamp drift (high). erase_entity() resolved erased_at up front but passed
the caller's original `at` down to purge_node(), so on the default at=None
path the coordinator and the graph each took their own now() and the receipt
attested to a different instant than the tombstone it points at -- breaking
the invariant this module states most loudly. The resolved value is now what
the graph receives. The existing test passed only because it supplied an
explicit `at`, which hides the drift; the regression test covers at=None,
which is what callers actually use.

Backend delete results. The vectors leg treated anything other than the
literal False as success, but no in-repo backend returns a bool -- Qdrant
returns {"status": <UpdateStatus>} and Pinecone {"deleted": True}, so every
dict read as success and the backend's own account of the delete was thrown
away. Results are now interpreted by shape and the payload is kept in the
receipt as backend_result, stringified so it stays JSON-serializable as an
audit record. Bool markers match by identity so a 0 count isn't read as
False; string markers match as substrings so an enum rendering as
"UpdateStatus.FAILED" isn't read as success.

Falsey vector store. The "at least one store" guard used `not vector_store`,
rejecting a valid store whose __bool__/__len__ makes an empty instance falsey
and then reporting vector_store=None when an object had been passed. It now
separates None (absent) from False (deliberately disabled) from provided, and
echoes what it received.

`at` annotations. Widened to int/float, matching the ContextGraph normalizer
they delegate to, so the coordinator stops advertising less than the API it
wraps.

tests/context/: 608 passed.

* fix(context): report memory-owned vectors that survive erasure (#1018)

A receipt could read complete while an embedding was still in the vector
store. The vector leg deleted `vector_ids` or `[entity_id]`, and the memory
leg relied on `AgentMemory.delete_memory()` to cascade to the vectors each
item owns. That cascade is best-effort: `_delete_vector_ids()` raises when a
backend returns False, `delete_memory()` catches it, logs a warning, and
still returns True. So `batch_delete` counted the item, the residual re-query
found no items, the memory leg reported `erased`, and nothing in the receipt
recorded that the embedding was refused.

Reproduced with a store that deletes the entity-keyed id and refuses the
memory-owned one: `receipt.complete` was True with the embedding still live.
That is the failure mode this module exists to prevent -- a receipt is a
compliance artifact, and one that overstates is worse than none.

Fix by deleting memory-owned vector ids through the coordinator's own vector
leg, which reports honestly, instead of trusting the memory leg's cascade.
The ids are collected before anything is deleted, while the items still exist
to be enumerated, and are unioned with any caller-supplied ids rather than
replacing them.

This needs one addition to AgentMemory: `vector_ids_for(memory_id)`, a
read-only accessor mirroring the fallback in `delete_memory` (an item stored
without tracked ids is keyed by its own memory id). Reaching into
`_vector_ids` from the coordinator would have been the internals-access
pattern this repo keeps getting bitten by. No existing AgentMemory behaviour
changes -- `delete_memory()` still cascades best-effort, so other callers are
unaffected; the coordinator simply no longer depends on that being reliable.
It does mean the vectors are attempted twice, which is a no-op on a working
store and only ever costs a log line.

Note this deviates from the PR's stated "nothing in agent_memory.py changes"
constraint. The constraint could not hold: with `_vector_ids` private and no
portable way to ask a vector store what it still holds, the coordinator had
no way to make the claim truthful without it.

Four tests: the refused-vector case (receipt must be incomplete), that
memory-owned ids reach the store, that explicit `vector_ids` do not displace
them, and the accessor's fallback. The first three were confirmed to fail
against the previous coordinator, on the `receipt.complete` assertion rather
than incidentally. 655 tests pass across tests/context and the agno
integration.

* fix(context): make erasure receipt vector failures honest

* fix(context): optimize erasure pagination handling

* fix(context): use one timestamp for batch erasure

* style: strip trailing whitespace from erasure.py and test file

* docs(changelog): correct test counts to 48 / 738 after review rounds

---------

Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
Co-authored-by: Sameer6305 <sskadam6305@gmail.com>
2026-09-01 23:07:02 +05:30
Mohd Kaif 2eab7ab876 fix(ci): drop --ignore from Safety check, filter accepted CVEs in jq instead (#1371)
* fix(ci): drop --ignore from Safety check, filter accepted CVEs in jq instead

The follow-up to #1370: adding `--ignore SFTY-20260120-40557` to the
`safety check` invocation reintroduced the exact crash #1131/#1157 had
just fixed - "Unhandled exception happened: 'cuda-toolkit'" - but only
once Safety actually has a live vulnerability match to apply the ignore
against (the plain, un-ignored scan against the same requirements-ci.txt
had already succeeded and correctly reported that same match on main,
per the run right before this one).

I couldn't reproduce this locally: my local Safety installation doesn't
surface the live cuda-toolkit CVE match at all (its open-source
vulnerability DB appears to lag CI's), so --ignore never had a real
match to crash on in my testing. That's on me - I should have caught
that my "0 vulnerabilities" local result meant the DB hadn't even seen
the finding yet, not that the fix worked.

Since I can't safely iterate against Safety's own --ignore path without
live-DB access, this moves the "should we still fail on ID X" decision
out of Safety entirely: run the plain scan (the one path an actual CI
run has now proven doesn't crash), then filter the accepted vulnerability
ID out of the report ourselves in jq before counting/printing. Verified
the jq expression directly against a synthetic report shaped like a real
one (id present + one other unrelated id): filters exactly the intended
entry, and - as a bonus - iterating over a null/missing "vulnerabilities"
key with jq now raises inside jq the way the existing guard comment always
assumed it did, rather than silently coming back as 0.

* fix(ci): apply the accepted-CVE exclusion list to the PR comment too

Qodo caught a real gap on this PR: the jq-based exclusion I added only
covers the CI gate (the VULNS count and the failure-path detail print).
The "Comment PR with Security Results" step reads safety-report.json
independently in its own JS, with no filtering at all, so a PR touching
only the accepted cuda-toolkit CVE would still get a comment saying
"Found 1" even though the gate itself correctly treats it as
non-actionable and passes.

Export IGNORED_VULN_IDS via $GITHUB_ENV from the shell step so the JS
step can read the same list, and filter data.vulnerabilities there
before rendering - with a footnote naming what was excluded and why,
so the comment stays transparent about the accepted finding rather than
just silently hiding it.

Verified the JS logic standalone against two synthetic reports: one with
the accepted CVE plus an unrelated real one (shows only the real one,
plus the footnote), and one with only the accepted CVE (shows "No
findings" plus the footnote, rather than misleadingly looking identical
to a clean scan with no explanation).
2026-09-01 19:45:36 +05:30
Mohd Kaif d8822198cf fix(ci): ignore CVE-2025-33228 in cuda-toolkit - unfixable transitive pin, unreachable code path (#1370)
Merging #1357 surfaced a real (not crashed) Safety finding: cuda-toolkit
13.0.3.0 < 13.1.0 is affected by SFTY-20260120-40557 / CVE-2025-33228.

This can't be fixed with a version bump on our end: torch 2.13.0 (the
latest release on PyPI - there is no newer one) hard-pins
`cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,
nvjitlink,nvrtc,nvtx]==13.0.3` on Linux via its own METADATA, not a loose
transitive requirement we control.

The underlying CVE is OS command injection in NVIDIA Nsight Systems'
gfx_hotspot recipe (process_nsys_rep_cli.py), which requires a human to
manually invoke that script with an attacker-supplied string. It isn't
reachable from any Semantica code path, and Nsight Systems isn't even
part of the extras torch requests here (cublas/cudart/cufft/cufile/
cupti/curand/cusolver/cusparse/nvjitlink/nvrtc/nvtx - no Nsight extra
among them).

Ignoring this one vulnerability ID only (not the whole package or a
blanket policy) so CI reflects actionable risk. Re-evaluate once torch
ships a release that pins a patched cuda-toolkit.
2026-09-01 19:14:21 +05:30
Mohd Kaif e6409217dd Merge pull request #1357 from taoche/fix/cookbook-07-graph-mapping
docs(cookbook): correct graph mapping and deduplication in notebook 07
2026-09-01 18:41:18 +05:30
taoche 3ed31b9182 docs(cookbook): make notebook 07 setup deterministic 2026-09-01 19:45:39 +08:00
Mohd Kaif 7300fb41b1 Merge pull request #1363 from 7487/fix/plugin-manifest-agents-array
fix(plugins): declare agents as an array of file paths in plugin.json
2026-09-01 16:57:52 +05:30
Mohd Kaif 218e5a33f3 Merge branch 'main' into fix/plugin-manifest-agents-array 2026-09-01 16:38:36 +05:30
Mohd Kaif 635f6e52f4 Merge pull request #1332 from semantica-agi/test/backend-facade-contract
Pin facade contract gaps for cloud backends
2026-09-01 15:20:08 +05:30
Mohd Kaif 9240a1b1f7 Merge branch 'main' into test/backend-facade-contract 2026-09-01 15:08:35 +05:30
3254b9be80 Serve the RDF export formats the MCP tool already offers (#1131) (#1157)
* feat(explorer): serve the RDF export formats the MCP tool already offers (#1131)

`POST /api/export` accepted only `json` and `csv` and answered 422 for everything
else, while the MCP `export_graph` tool resolved Turtle, N-Triples, RDF/XML,
JSON-LD, GraphML and Parquet through `semantica.export`. Two surfaces of one
product disagreeing about what the product can do — and for an RDF-native project,
a graph that loads as JSON-LD and cannot be exported as RDF is a one-way door.

The route now reaches the same exporters the MCP tool uses. Nothing is
reimplemented: `RDFExporter.export_to_rdf` and `GraphMLExporter.export` receive the
dict `session.build_graph_dict()` already builds.

The alias table is a copy of `mcp/tools/export.py::_FORMAT_ALIASES` plus the
spellings the issue mentioned (`ntriples`, `rdf-xml`), and a test asserts the two
tables agree — if either drifts, the formats a caller can use would depend on which
door they came through.

Media types and extensions per serializer, so a Turtle export is `text/turtle` and
not `application/json` with a `.json` name.

The 422 message now names what IS supported. The old one said only that the format
was unsupported, which reads as "this format does not exist" rather than "this door
does not open it" — that is what sent me looking through the library.

Parquet is left out on purpose: `ParquetExporter.export` writes a file and returns a
path, so serving it over HTTP is a different shape of change and deserves its own
review.

Tests, in `TestImportExport`: the seven RDF spellings, each **parsed with rdflib**
rather than asserted on strings — a response that merely looks like Turtle is what
lets this class of gap survive a suite. Plus the alias-agreement canary and the
error message. Three mutations (rejecting RDF again, breaking one alias, emptying
the message) each turn the matching tests red.

110 tests in `tests/explorer/test_explorer_api.py` pass.

* fix(explorer): complete RDF export support

* fix(explorer): secure GraphML temporary file handling

* fix(ci): scan declared dependencies with Safety

---------

Co-authored-by: 13g4d0 <13g4d0@users.noreply.github.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-01 15:01:13 +05:30
Guofang.Tang dc81acaefd fix(ontology): reject normalized class name collisions (#1230)
ClassInferrer.infer_classes() groups entities by their type string, then
normalizes each group name (PascalCase + singularize) when it builds the
ontology class. Two source types that only differ in casing or plurality,
like Person and person, both normalize to the same class name. Nothing
caught that, so the second type's entities silently got treated as
instances of the first type's class, and property inference downstream
picked up whichever properties happened to win.

Added a pass right after entities get grouped by type: normalize every
type name that meets min_occurrences, and if two different source types
land on the same normalized name, raise ValidationError before any class
gets built. The check reuses the exact same min_occurrences filter the
real class-emission loop uses, so it only fires on collisions that would
actually produce duplicate classes, not on types that get filtered out
anyway.

The error carries validation_context with the normalized name mapped to
every source type that collided into it, so whoever's calling this can
see exactly what to rename instead of just getting a generic message.

Added test_class_inference_rejects_normalized_type_collisions covering
Person/person landing on the same class.

Follow-up to #1171.
2026-09-01 14:25:54 +05:00
7487 f44020c742 fix(plugins): declare agents as an array of file paths in plugin.json
Claude Code's plugin schema rejects "agents": "./agents" (a bare
directory string) with:

    Validation errors: agents: Invalid input

so the bundled plugin has never been installable. Unlike "skills",
which accepts a directory string, "agents" must be an array of .md
file paths.

Replaced the string with the explicit list of the three agent files.
Verified with `claude plugin validate plugins` (2.1.231): fails on the
old manifest with the error above, passes after this change.

Added tests/test_plugin_manifest.py to guard the manifest shape: agents
is a non-empty array of existing .md paths that stays in sync with
plugins/agents/, and the skills directory exists.

Fixes #1350
2026-09-01 17:02:52 +08:00
taocheandClaude Fable 5 abb65feff0 docs(cookbook): build notebook 07 graph edges from real relation endpoints
The knowledge-graph lesson fabricated relationship endpoints from loop
indices, hid the corruption behind count-only output, and displayed
only merged duplicate groups as the deduplicated result. Rework the
notebook so that:

- graph edges come from Relation.subject/Relation.object mapped
  through a mention-span -> graph-ID table
- the sample text keeps two separate "Apple Inc." mentions without the
  sentence-boundary merge edge case
- entity resolution shows which mentions merged (merged_from) and
  remaps relationship endpoints onto the canonical entity
- deduplication reports merge operations separately from the complete
  deduplicated set (merged + untouched entities)
- each stage prints its transformed records, and lightweight
  assertions pin the expected canonical entities and edges

Closes #1287

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:39:25 +08:00
Wei TaoandSameer Kadam 8b125d6476 fix(explorer): keep small Full Graph relationships readable (#1277)
* fix(explorer): keep small full graphs readable

* perf(explorer): avoid redundant realtime edge sync

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-01 11:11:53 +05:30
Mohd Kaif f3c540cfd2 docs(readme): reposition Semantica as the semantic/context layer (#1348)
Lead the README with Semantica's identity as a semantic/context/knowledge
layer (Context Graph, KG, ontology and vocabulary governance via OWL/SHACL/SKOS),
with decision provenance and audit trails framed as a property of that
structure rather than the flagship pitch.
2026-08-31 22:11:13 +05:30
Sameer Kadam 46b18fbee3 fix(docs): use GraphStore facade in Neo4j quickstart (#1340)
The Neo4j example in the persistent graph store section was passing a raw
Neo4jStore straight into GraphBuilder. GraphBuilder calls add_nodes() and
add_edges() on whatever it's given, and those only exist on the GraphStore
facade, not on Neo4jStore itself. Anyone who copied the example got:

AttributeError: 'Neo4jStore' object has no attribute 'add_nodes'

Swapped the import and construction to GraphStore(backend="neo4j", uri=...,
user=..., password=...), which wraps Neo4jStore internally and actually has
the methods GraphBuilder needs.

Added a test in tests/kg/test_graph_builder_with_graph_store.py that builds
a small graph through GraphBuilder with a mocked GraphStore and checks
add_nodes/add_edges get called. Also kept a test for GraphBuilder without a
graph_store at all, so that path doesn't regress either.

Closes #1135
2026-08-31 20:34:04 +05:00
Mohd Kaif b0679d4f67 fix(ci): stop checkov's suppressed checks from reopening as new alerts (#1346)
* fix(ci): drop unpinnable benchmarks/requirements.txt install

Scorecard flagged this pip install as unpinned-by-hash (#6082). Can't
hash-pin it - benchmarks/requirements.txt doesn't exist in this repo, so
there's nothing to compile a lockfile from. Dropping it instead of
leaving it unpinned: the job already fails on the next real step
(benchmarks/benchmarks_runner.py, also missing), so this line wasn't
doing anything useful to begin with.

* fix(ci): hash-pin the spacy model download in benchmark.yml

Qodo review on this PR: dropping the benchmarks/requirements.txt install
(the previous failure point) let the job actually reach
`python -m spacy download en_core_web_sm`, which fetches an unpinned,
unhashed wheel from spacy-models' GitHub releases - undoing the point of
this PR by exposing a real unpinned-install path instead of a dead one.

Replaced with a hash-pinned direct-URL entry in benchmark-extra.in/.txt
for en_core_web_sm-3.8.0 (matches the spacy==3.8.15 already pinned in
base-deps.txt). uv independently computed the same sha256 I got via a
manual curl+sha256 of the release asset, and a --require-hashes dry-run
install verifies clean.

* fix(ci): stop checkov's suppressed checks from reopening as new alerts

Root cause found, not just worked around: checkov's SARIF exporter
includes every evaluated check as an ordinary result, including ones it
internally marked SKIPPED via the inline # checkov:skip= comments and
checkov.io/skipN annotations already on the Helm chart. It never uses
SARIF's own `suppressions` field and never drops them - so the exact same
already-suppressed finding reopens as a brand-new code scanning alert
number on every single run, forever (#6035/#6036, #6112-6115,
#6128-6131 are all the same 4 findings, manually dismissed 3 times now).

checkov's JSON output *does* correctly record which checks were skipped.
Added .github/scripts/filter_checkov_skipped.py, which cross-references
the JSON's skipped_checks against the SARIF's results (matched by check
ID + the last two path segments, since the two outputs use different path
roots) and drops anything checkov itself already decided to suppress,
before upload. Verified locally against a real checkov+helm run: removed
exactly the 4 known-suppressed helm chart results, left the 2 genuinely
real findings (deploy/gcp/cloudrun-service.yaml, deploy/kubernetes/
deployment.yaml) untouched.
2026-08-31 19:29:24 +05:30
Mohd Kaif d135ad185f fix(ci): drop unpinnable benchmarks/requirements.txt install (#1345)
* fix(ci): drop unpinnable benchmarks/requirements.txt install

Scorecard flagged this pip install as unpinned-by-hash (#6082). Can't
hash-pin it - benchmarks/requirements.txt doesn't exist in this repo, so
there's nothing to compile a lockfile from. Dropping it instead of
leaving it unpinned: the job already fails on the next real step
(benchmarks/benchmarks_runner.py, also missing), so this line wasn't
doing anything useful to begin with.

* fix(ci): hash-pin the spacy model download in benchmark.yml

Qodo review on this PR: dropping the benchmarks/requirements.txt install
(the previous failure point) let the job actually reach
`python -m spacy download en_core_web_sm`, which fetches an unpinned,
unhashed wheel from spacy-models' GitHub releases - undoing the point of
this PR by exposing a real unpinned-install path instead of a dead one.

Replaced with a hash-pinned direct-URL entry in benchmark-extra.in/.txt
for en_core_web_sm-3.8.0 (matches the spacy==3.8.15 already pinned in
base-deps.txt). uv independently computed the same sha256 I got via a
manual curl+sha256 of the release asset, and a --require-hashes dry-run
install verifies clean.
2026-08-31 18:57:28 +05:30
Mohd Kaif 96dbd3f0d4 fix(security): bump checkov to 3.3.16, fix aiohttp CVEs in its lockfile (#1342)
Dependabot flagged 12 aiohttp advisories (1 high, rest moderate/low - CVE
range covering request smuggling, websocket/parser bugs, cookie/redirect
issues) against aiohttp==3.13.5 pinned in checkov.txt. checkov==3.3.1
itself pinned `aiohttp<3.14.0`, which excludes every fixed release;
3.3.16 (latest) relaxes that to `<3.15.0`, so bumping checkov also lets
aiohttp resolve to 3.14.3 (fixes all of them).

Two alerts remain open, both genuinely blocked upstream rather than
something a version bump here can fix:
- asteval: checkov 3.3.16 (latest, still) hard-pins asteval==1.0.6 with
  no range; the fix (1.0.9) is unresolvable without violating checkov's
  own declared dependency - confirmed via `uv pip compile` refusing to
  solve it. Needs checkov itself to bump the pin upstream.
- ecdsa: 0.19.2 is already the latest release; the Minerva timing-attack
  advisory has no patched version, since python-ecdsa's maintainers have
  stated side-channel attacks are out of scope for the project.

Both are checkov's own transitive deps, used only for local static IaC
analysis in defender-for-devops.yml (no network signing/cloud-auth calls
that would actually exercise ecdsa's signing path) - dismissing on
GitHub with that reasoning as a separate step.
2026-08-31 18:21:36 +05:30
Mohd Kaif d4cd44e7f1 fix(docker): split explorer-extra.txt by Python version, fix broken build (#1341)
* fix(docker): split explorer-extra.txt by Python version, fix broken build

main's container-scan.yml has been failing since PR #1338 merged:

  ERROR: In --require-hashes mode, all requirements must have their
  versions pinned with ==. These do not:
      standard-aifc from .../standard_aifc-3.13.0-py3-none-any.whl
      (from audioread==3.1.0->-r explorer-extra.txt (line 30))

Root cause: explorer-extra.txt was compiled with `--python-version 3.11`
but is installed on the Dockerfile's actual python:3.13-slim interpreter.
librosa's audioread dependency needs standard-aifc/standard-sunau only
under `python_version >= "3.13"` (Python 3.13 dropped aifc/sunau from
stdlib) - a file resolved for 3.11 has no hash for those packages at all,
so --require-hashes fails outright once pip resolves against the real
3.13 environment instead of silently under-pinning.

Splits the file in two: explorer-extra-py311.txt (ci.yml, unchanged
resolution) and explorer-extra-py313.txt (Dockerfile, newly compiled for
--python-version 3.13). They aren't interchangeable and shouldn't be
recombined - documented in .github/requirements/README.md, including how
to catch this class of bug before it ships again.

* fix(ci): correct stale -o path in explorer-extra-py311.txt header

Qodo review on this PR: the autogenerated header comment still said
-o .github/requirements/explorer-extra.txt (the pre-rename path), which
would silently regenerate the wrong file if someone copy-pasted it.
2026-08-31 17:52:23 +05:30
Mohd Kaif 832412cc01 Merge pull request #1338 from semantica-agi/fix/scorecard-pinned-dependencies
fix(ci): hash-pin every pip install for Scorecard Pinned-Dependencies
2026-08-31 17:31:14 +05:30
Sameer6305 dfbe98cebd fix(ci): generate Checkov lockfile for Windows 2026-08-31 17:21:59 +05:30
Sameer6305 89c6c8a45d fix(ci): hash-pin Checkov installation 2026-08-31 17:01:16 +05:30
Sameer Kadam 4b24053851 Merge branch 'main' into fix/scorecard-pinned-dependencies 2026-08-31 16:15:42 +05:30
cxzg007andSameer Kadam c111277a1f fix(context): make auto_generate_id a real InitVar in decision models (#1153)
The six decision-model dataclasses (Decision, DecisionContext, Policy,
PolicyException, Precedent, ApprovalChain) accepted `auto_generate_id`
only as a plain `__post_init__` parameter. Since it was neither a field
nor an InitVar, the generated `__init__` never forwarded it, so the
parameter was always its `True` default and the `auto_generate_id=False`
validation branch was unreachable dead code.

Declare `auto_generate_id: InitVar[bool] = True` on each dataclass so the
generated `__init__` forwards it to `__post_init__`, restoring the
required-id contract. Serialization is unaffected because InitVar is not
a real field. Add regression tests covering the auto-generate path, the
required-id error path, and the explicit-id path.

Fixes #1152

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-31 15:50:40 +05:30
KaifAhmad1 5ed98cefbd fix(ci): hash-pin PEP 517 build isolation deps (setuptools, wheel)
Qodo review on this PR: `pip install --no-deps -e .` / `pip install
--no-deps .` still leaves PEP 517 build isolation on by default, which
fetches [build-system] requires (setuptools==84.0.0, wheel==0.48.0)
completely outside any hash checking - the --require-hashes installs
right next to it didn't cover this at all.

Adds .github/requirements/pep517-build.txt, hash-locked to the exact
pyproject.toml [build-system] requires, and installs it before every
local-source install (Dockerfile, ci.yml, benchmark.yml) with
--no-build-isolation so pip reuses those hash-verified copies instead
of fetching its own.
2026-08-31 15:43:59 +05:30
KaifAhmad1 48737629d9 fix(ci): hash-pin every pip install for Scorecard Pinned-Dependencies
Scorecard's Pinned-Dependencies check requires pip installs to be
hash-verified, not just version-pinned - our existing pkg==X.Y.Z pins
(and even pip install -r requirements-ci.txt, despite that file already
carrying hashes) still scored a 4 because the pin/hash isn't visible on
the command line itself.

Adds .github/requirements/*.txt: hash-locked files generated via
`uv pip compile --generate-hashes` for every pip target that isn't
already requirements-ci.txt, covering standalone CI tooling (build,
wheel, twine, uv, pip-audit, safety/bandit/semgrep/jq, pip/setuptools
bootstrap) and the project's own local-source installs. The latter
(`pip install -e ".[explorer]"`, `pip install -e .`) can't be hash-pinned
directly since there's nothing to hash for a local source tree; split
into `pip install --no-deps -e .` plus a separate hash-pinned install of
the actual fetched dependencies instead.

Also adds --require-hashes to every `-r requirements-ci.txt` install so
hash verification is enforced explicitly rather than only implied by the
file's own content.

Simplifies the Dockerfile in the process: it now installs from the same
pre-generated explorer-extra.txt (copied in at build time) instead of
extracting constraints from requirements-ci.txt at build time, which
also means setuptools gets its CVE-2025-47273 fix as a side effect of
the hash-pinned install rather than a separate upgrade step.

benchmark.yml: pip install -r benchmarks/requirements.txt is left
unpinned - that directory doesn't exist in this repo, so there's nothing
to generate hashes from. Pre-existing breakage, unrelated to this change.
2026-08-31 15:29:40 +05:30
Mohd Kaif 1d62217cc5 Merge pull request #1334 from semantica-agi/fix/container-scan-cves
fix(docker): resolve Trivy-flagged CVEs in the built image
2026-08-31 14:34:32 +05:30
KaifAhmad1 bf292ccbbc fix(docker): address terrascan findings, drop apt-get upgrade
Two terrascan/GHAS findings on the previous commit:
- AC_DOCKER_0052 (no apt-get upgrade in Dockerfiles): dropped it. It also
  wasn't fixing anything - Debian's openssl fix for CVE-2026-14456 is still
  in trixie-proposed-updates, not reachable via a normal upgrade. Pin both
  base images by digest instead (matches #1329's approach) so the docker
  Dependabot ecosystem bumps them once Debian ships a rebuilt image with
  the fix, and document why the QUIC DoS isn't reachable here regardless
  (HTTP-only via uvicorn).
- AC_DOCKER_0010 (pin pip package versions): setuptools was `>=78.1.1`;
  pinned to the exact 84.0.0 already used by pyproject.toml/requirements-ci.txt.

Also fixes two build breaks this introduces on its own: requirements-ci.txt
wasn't in .dockerignore's allowlist or container-scan.yml's path trigger,
so the COPY in the prior commit would have failed the image build outright.
2026-08-31 14:06:28 +05:30
KaifAhmad1 64d942503b fix(docker): extract requirements-ci.txt pins with Python instead of sed
The sed expression to strip requirements-ci.txt's line-continuation
backslash (`[\]$`) is valid POSIX/GNU sed - verified it exits 0 and
strips correctly - but it's easy to misread as broken (a bot reviewer
flagged it as an unterminated bracket expression), and the seemingly
more obvious `\$`/` \$` forms silently fail to match at all rather
than erroring. Swap to a small `re.findall` extraction so there's no
backslash-escaping judgment call left for a reader (bot or human) to
second-guess.
2026-08-31 14:06:02 +05:30
KaifAhmad1 471a420b10 fix(docker): resolve Trivy-flagged CVEs in the built image
Container Security Scan flagged five HIGH-severity findings against
semantica:scan:
- setuptools 70.3.0 (CVE-2025-47273, path traversal) - the base image's
  bundled copy, never touched by our own build. Upgraded explicitly.
- msgpack 1.1.2 (GHSA-6v7p-g79w-8964, OOB read/crash) - `pip install
  ".[explorer]"` re-resolved deps from scratch instead of reusing the
  audited, hash-pinned requirements-ci.txt (which already pins
  msgpack==1.2.1), so it landed on an unpatched transitive version. Now
  installs against a constraints file derived from requirements-ci.txt.
- openssl / libssl3t64 / openssl-provider-legacy (CVE-2026-14456, QUIC
  server DoS) - the Debian fix is still in trixie-proposed-updates, not
  yet promoted to trixie-security, so it can't be pulled via apt today.
  Added an apt upgrade step so the next image rebuild picks it up
  automatically once Debian ships it; documented why this image isn't
  actually exposed to it in the meantime (HTTP-only via uvicorn, no QUIC
  listener).
2026-08-31 14:06:02 +05:30
Mohd KaifandSameer6305 a4aa71ad87 fix(ci): unblock py3.9 install matrix and raise Scorecard pinning/signing (#1329)
* fix(ci): unblock py3.9 install matrix and raise Scorecard pinning/signing

pip install semantica failed on Python 3.9 across all three OSes because
spacy had no upper bound, so pip resolved spacy 3.8.16 whose thinc>=8.3.12
requirement has no cp39 wheels and no working sdist build path. Cap
spacy/thinc for python_version < '3.10' to the last wheel-compatible pair.

Also addresses the two OpenSSF Scorecard findings that were actually
fixable in code:
- Pinned-Dependencies: Dockerfile base images (node:26-alpine,
  python:3.13-slim) were unpinned by digest; pin both, and pin five
  previously-unversioned pip install calls in CI (build, safety, bandit,
  semgrep, jq, pip-audit).
- Signed-Releases: attest-build-provenance only publishes to the GH
  attestations API, which Scorecard doesn't inspect. Sign dist/* with
  Sigstore and attach the .sigstore.json bundles as release assets.

* fix(ci): correct Sigstore artifact inputs

---------

Co-authored-by: Sameer6305 <sskadam6305@gmail.com>
2026-08-31 14:04:28 +05:30
Zohaib Hassnain 73b14c00ba test(vector_store): make contract xfails reachable and cover the backend roster 2026-08-31 13:27:45 +05:00
Zohaib Hassnain e9a756eac2 test(vector_store): pin facade contract gaps for cloud backends 2026-08-31 12:59:40 +05:00
Mohd Kaif fa87a1a9be ci: add npm Dependabot ecosystem and container image scanning (#1286)
* ci: add npm Dependabot ecosystem and container image scanning

- dependabot.yml had no npm ecosystem entry for explorer/, so its
  lockfile was never watched - exactly why the brace-expansion/nanoid
  CVEs fixed in #1280 went undetected. Add it, mirroring the existing
  pip entry's schedule/labels/reviewer conventions.
- New container-scan.yml builds the root Dockerfile's image and scans
  it with Trivy (CRITICAL/HIGH OS+lib CVEs, SARIF to the Security tab)
  and Syft (SPDX SBOM artifact), on push to main, weekly, and manual
  dispatch. Neither the base-image scan nor an SBOM existed before -
  Dependabot's docker entry only bumps the base image tag, it doesn't
  scan built layers.
- Trivy runs report-only for now (no exit-code gate): this is its
  first run against the image, so the CRITICAL/HIGH baseline hasn't
  been triaged yet. Once reviewed, add exit-code: '1' to make it a
  hard gate, same as Safety/Bandit-HIGH in security-scan.yml.

* fix: run Trivy via digest-pinned image, not the aquasecurity/trivy-action wrapper

verify-action-pins.sh failed in CI: the aquasecurity GitHub org has an IP
allow list on its API that 403s the live tag->SHA resolution from
Actions-runner IPs (confirmed reproducible, not transient - resolves fine
from a non-blocked host). Rather than carve a skip exception into the pin
verifier for an org this script already flags as a past tag-repointing
target (see its "LiteLLM/Trivy 2026 incident" comment), pull Trivy as a
sha256-digest-pinned Docker Hub image instead. A digest is immutable and
verifiable independently of GitHub's API entirely, so it sidesteps the
IP block without weakening verification of the one action this repo
already treats as higher-risk. Confirmed the pinned digest
(aquasec/trivy@sha256:62b1e65e...) resolves live against Docker Hub's
registry API.

* fix: match container-scan.yml's push paths to what actually reaches the image

The path filter only watched explorer/package.json and package-lock.json,
but Dockerfile COPYs the whole explorer/ tree plus README.md, LICENSE, and
MANIFEST.in, and .dockerignore controls all of it. A frontend source change
or a README/LICENSE edit would change the built image without triggering a
scan, silently drifting until the next weekly run. Replace the filter with
exactly .dockerignore's opt-in list.
2026-08-30 21:39:30 +05:30
Mohd Kaif 08c78bfb40 fix(security): resolve Scorecard vulnerability and token-permission alerts (#1280)
- Bump explorer's brace-expansion (minimatch dep) 5.0.8 -> 5.0.9 and
  nanoid (postcss dep) 3.3.16 -> 3.3.18, fixing GHSA-rgw5-rvv9-x895 and
  GHSA-2v37-7h3g-55p8 (both DoS via unbounded input, both within the
  existing caret ranges declared by their parents).
- Move codeql.yml and defender-for-devops.yml's security-events: write
  (and codeql.yml's actions: read) from workflow-level down to their
  single job, matching Scorecard's Token-Permissions ideal of a
  read-only top-level default with sensitive scopes granted only where
  used.
2026-08-30 20:58:39 +05:30
Mohd Kaif 56b174781f ci: reusable install action, install-matrix, and release hardening (#1266)
Distribution and trust-signal infrastructure to make pip install semantica
frictionless in downstream CI, and to bring the release pipeline in line
with mature OSS practice.

- .github/actions/setup-semantica: reusable composite action other repos
  can call to install + verify semantica in one step
- install-matrix.yml: verifies the published package installs and imports
  cleanly across Ubuntu/macOS/Windows x Python 3.9-3.12, weekly and on
  release; backs a new README badge
- scorecard.yml: OpenSSF Scorecard analysis, weekly and on push to main,
  backing a new README badge
- release.yml: twine check gate before publish, catching a broken PyPI
  long-description render before it ships
- CITATION.cff: enables GitHub's native "Cite this repository" button
- examples/ci/: copy-paste GitHub Actions, GitLab CI, and CircleCI
  templates for projects adopting semantica
- GROWTH.md: tracked checklist of distribution channels, what's done vs
  outstanding, with guardrails against inflating metrics artificially

Fixes folded in along the way:

- Re-pinned softprops/action-gh-release to the immutable v3.0.3 tag
  instead of the floating v3, after verify-action-pins.sh caught the
  mutable tag had drifted to a newer commit
- setup-semantica now passes extras/version through env vars instead of
  interpolating ${{ inputs.* }} directly into the bash script, closing
  a script-injection vector for callers deriving these from event data
- install-matrix now triggers on the Release workflow's completion
  (workflow_run) instead of release: published, since the GitHub release
  is created before the PyPI upload runs and the old trigger could race
  the publish
- The workflow_run path derives the expected version from the triggering
  tag and passes it into setup-semantica's version input, so pip
  installs and verifies the exact release instead of whatever's latest
  on PyPI at the time
- setup-semantica's pip caching is now opt-in (default disabled), since
  actions/setup-python errors out with cache: 'pip' enabled when the
  caller repo has no requirements.txt/pyproject.toml to key on
- examples/ci/github-actions.yml pins actions/checkout and
  actions/setup-python to verified commit SHAs instead of mutable tags
- examples/ci templates guard the requirements.txt install step with
  -f requirements.txt and call out pyproject.toml/Poetry/Pipenv as
  alternatives, since not every project has a requirements.txt
2026-08-30 17:30:21 +05:00
Zohaib HassnainandSameer Kadam dfda4c561a feat(vector_store): add scan_vectors enumeration and wire up store mi… (#1264)
* feat(vector_store): add scan_vectors enumeration and wire up store migrate

* fix(vector_store): address Qodo finds

* fix(vector_store): make FAISS add_vectors idempotent for retried migrations

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-30 17:31:57 +05:30
Zohaib HassnainandSameer Kadam ea0dd17bff feat(llms): add Gemini, Ollama, DeepSeek, Novita provider wrappers (#1262)
* feat(llms): add Gemini, Ollama, DeepSeek, Novita provider wrappers

* fix(llms): address Qodo review findings on provider wrappers PR

* fix(llms): address review findings

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-30 17:00:14 +05:30
Shubham Srivastava f6cd62411b test(integrations): make crewai and langchain test dirs packages (#1252)
Both directories contain a test_degradation.py. Neither had an __init__.py,
so under pytest's default prepend import mode both modules were imported as
plain 'test_degradation' and the second collided with the first:

    import file mismatch:
    imported module 'test_degradation' has this __file__ attribute:
      tests/integrations/crewai/test_degradation.py
    which is not the same as the test file we want to collect:
      tests/integrations/langchain/test_degradation.py

That aborted collection for tests/integrations/, so the langchain
graceful-degradation tests never ran. tests/integrations/__init__.py
already exists, and most directories under tests/ carry one; these two
subpackages were simply missed.

Collection goes from 335 collected, 1 error to 337 collected.

Closes #1251
2026-08-30 15:23:43 +05:00
王林 cac6dfbe45 fix(explorer): surface server error detail in graph loading failures (#1260)
The nodes/edges fetch loops were throwing away the response body whenever the request returned a non-OK status.

Because of that, errors like a `503` caused by a missing `SEMANTICA_API_KEY` only showed up as:

`Fetch failed: 503`

even though the backend was already returning a more useful message in the response `detail`.

This change reads the JSON error body and includes `detail` in the thrown error when it's a string, so `GraphLoadingOverlay` can show the actual backend error to the user.

Closes #1256
2026-08-30 15:16:15 +05:00
Mohd Kaif 80e9737542 Merge pull request #1254 from dex0shubham/fix/1134-progress-stream-stderr
fix(utils): write console progress to stderr instead of stdout
2026-08-30 14:22:17 +05:30
Mohd Kaif 14fb975fa1 Merge branch 'main' into fix/1134-progress-stream-stderr 2026-08-30 14:12:18 +05:30
Mohd Kaif 8b0ac61afd Merge pull request #1255 from semantica-agi/feat/claude-wrapper
feat(llms): add first class Anthropic provider wrapper
2026-08-30 13:43:58 +05:30
KaifAhmad1 9d98eedaa1 fix(llms): replace retired default model and tidy Anthropic wrapper
claude-3-sonnet-20240229 was retired 2025-07-21, so the wrapper's
default model and every copy-paste doc example would fail at
generate() time out of the box. Switch to claude-sonnet-4-6
everywhere (wrapper default, __init__ docstring, docs guide, tests).

Also cleans up leftover docstring typos/spacing from the previous
review pass and adds unavailable-path test coverage for
generate_structured()/generate_typed() to match generate(), clearing
ANTHROPIC_API_KEY in those tests so they don't flake on a runner that
has a real key set.
2026-08-30 13:33:18 +05:30
Zohaib Hassnain 5ffb212a8f Merge branch 'main' into feat/claude-wrapper 2026-08-29 22:15:41 +05:00
Zohaib Hassnain ca7f743dab fix(llms): address Qodo review findings on Anthropic wrapper 2026-08-29 22:15:19 +05:00
Zohaib Hassnain 5cf59fdd88 feat(llms): add first class Anthropic provider wrapper 2026-08-29 22:01:16 +05:00
Mohd Kaif 0384a8de30 Merge pull request #1077 from cxzg007/fix/rete-pattern-matching
fix(reasoning): implement RETE alpha/beta matching with Token model (#300)
2026-08-29 21:57:15 +05:30
KaifAhmad1 f3d5932c24 Merge remote-tracking branch 'origin/main' into fix/rete-pattern-matching
Reconciles this PR's Token-based alpha/beta matching (#300) with the
rule-actions/provenance layer merged separately in #1096. That PR built
bind_reasoner()/execute_matches() action-firing/_executed_activations/
reset_action_history() on top of the still-broken always-True stubs
(via an interim _bindings_for_rule() regex re-extraction), so main and
this branch touched the same propagation code with incompatible shapes.

Kept this branch's Token(facts, bindings) model for alpha/beta
propagation (the actual fix for #300) and layered main's action/
provenance plumbing on top of it, sourcing Match.bindings directly from
Token.bindings instead of re-deriving them with _bindings_for_rule(),
which is now redundant and removed. Also fixes a 2-tuple/3-tuple
unpacking break in test_matches_reasoner_match_rule caused by
Reasoner._match_rule()'s return shape changing upstream, and drops an
unrelated encoding-only .gitignore diff.

Verified: tests/reasoning/ (106 tests) and flake8 --max-line-length=88
both clean on the merged tree.
2026-08-29 21:50:04 +05:30
Mohd Kaif a5aac7e22a Merge pull request #1232 from dex0shubham/test/1167-fastapi-collection-guards
test: guard fastapi-dependent modules so collection succeeds without the explorer extra
2026-08-29 21:20:20 +05:30
dex0shubham 3448ac0689 test(utils): restore both module bindings in the progress fixture
Importing a submodule rebinds it as an attribute of its parent package,
so restoring only the sys.modules entry left
semantica.utils.progress_tracker and
sys.modules['semantica.utils.progress_tracker'] pointing at different
objects for every test that ran afterwards.

Addresses review feedback on #1254.
2026-08-29 15:00:04 +01:00
dex0shubham 70dfbf151c fix(utils): write console progress to stderr instead of stdout
ConsoleProgressDisplay wrote every progress frame to sys.stdout. Progress
is diagnostic output, so stderr is the correct stream for it — tqdm and
most progress renderers default there for the same reason — and stdout
must stay clean for programs that carry a machine-readable protocol on
it. The stdio MCP servers put newline-delimited JSON-RPC on stdout, where
an interleaved progress bar makes a response body unparseable (#1134).

ConsoleProgressDisplay now takes an optional stream, defaulting to
stderr. The stream is resolved per write rather than captured at
construction, so a later rebinding of sys.stderr (pytest capture, for
instance) is honoured. All writes and the four bare flushes route through
it, and the emoji-capability probe now inspects that stream rather than
stdout, so a cp1252 stderr still degrades correctly.

The existing cp1252 tests in tests/deduplication/test_deduplication.py
patched sys.stdout to assert emoji auto-disabling; they now patch the
stream progress is actually written to. Their intent is unchanged.

Closes #1134 (point 1 only; the SEMANTICA_KG_PATH persistence and README
items remain with @akaszubski)
2026-08-29 13:53:48 +01:00
dex0shubham 47446ebdde test(explorer): guard the deterministic-rendering e2e module on fastapi
This module landed after the branch was opened and imports
semantica.explorer.app at module scope, so it reproduced the same
collection error on a clean [dev] install.
2026-08-29 13:31:00 +01:00
dex0shubham c8a591e89e test: scope the security-regression guard to the SPARQL class, guard the new decision-route test
Addresses review feedback on #1232.
2026-08-29 13:27:42 +01:00
dex0shubham ab86127e4e test: guard fastapi-dependent modules so collection succeeds without the explorer extra
Closes #1167
2026-08-29 13:27:42 +01:00
Mohd Kaif 30592b1285 Merge pull request #1245 from HsienW/fix/sliding-window-chunker-non-termination
fix(split): validate sliding window progress
2026-08-29 15:56:29 +05:30
Mohd Kaif aceb69a5bc Merge branch 'main' into fix/sliding-window-chunker-non-termination 2026-08-29 15:44:18 +05:30
Mohd Kaif e13c953bd8 Merge pull request #1249 from semantica-agi/fix/codeql-action-pin
ci: resync github/codeql-action pin to current v4
2026-08-29 14:12:47 +05:30
yzxcj797andSameer Kadam 85d6ccd0a5 fix(memory): find_by_entity returns all matches by default (#1024)
* fix(memory): find_by_entity returns all matches by default (limit=None, not 10)

* Address review: move find_by_entity tests to the AgentMemory area

The regression tests lived in tests/test_seed_manager.py, mixing unrelated
domains. Moved to tests/context/test_agent_memory_find_by_entity.py with a
shared fixture; the unbounded default itself is unchanged and deliberate —
it IS the fix (#1018): an erasure workflow computing what references an
entity cannot paginate, so silently truncating at 10 left live references
behind. Callers that want a page pass an explicit limit.

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-29 14:09:40 +05:30
hsien wei dfd668c206 fix(split): validate sliding window progress
- Reject non-positive stride values before chunking and validate temporary overlap overrides before mutating chunker state.

- Restore the original overlap and custom stride with `try/finally` so state remains unchanged after both successful and failed `chunk_with_overlap()` calls.

- Add regression coverage for invalid stride and overlap values, valid boundary cases, and state restoration.
2026-08-28 05:03:45 +08:00
江俊杰 e74d0a274d Merge remote-tracking branch 'upstream/main' into fix/rete-pattern-matching
# Conflicts:
#	CHANGELOG.md
2026-08-25 10:04:54 +08:00
江俊杰 b570794515 perf(reasoning): precompile alpha node condition regex
unify_condition() rebuilt a regex (re.split + concat + re.match) for
every fact tested against every alpha node. Since RETE evaluates many
facts across many alpha nodes, this repeated construction added
significant overhead.

- Extract regex construction into _build_condition_regex() (reused by
  unify_condition and AlphaNode).
- AlphaNode.__init__ now compiles its condition once (no initial
  bindings at alpha time) into self._compiled and reuses it per fact.
- On compile failure, log a WARNING and treat the node as non-matching,
  consistent with the earlier observability fix.
- Add tests for the compiled path and the compile-failure fallback.

Refs #300
2026-08-19 10:24:24 +08:00
江俊杰 a94cec3b36 fix(reasoning): log unify_condition regex errors for observability
Previously unify_condition() silently caught re.error and returned None
with no log context, unlike Reasoner._match_pattern() which logs the
pattern/regex/fact on failure. This made malformed conditions hard to
diagnose in the RETE engine.

- Add a module-level logger ("semantica.rete_engine") for the standalone
  unify_condition() helper.
- On re.error, log a WARNING including the condition pattern, compiled
  regex, and fact string before returning None.
- Also catch unexpected exceptions (noqa BLE001) with the same context,
  mirroring Reasoner._match_pattern behaviour.
- Add tests asserting both error paths log a warning and return None.

Refs #300
2026-08-19 10:24:24 +08:00
江俊杰 f9b1295d14 fix(reasoning): implement RETE alpha/beta matching with Token model (#300)
AlphaNode._matches and BetaNode._can_join were placeholder stubs that
always returned True, so the Rete network fired every rule for every
fact. Add a regex-based unify_condition (reusing Reasoner._match_pattern's
approach) that binds ?vars via named groups and enforces repeated-variable
and cross-condition binding consistency.

Rework propagation around a Token model (facts + bindings) instead of bare
facts: AlphaNode emits single-fact tokens, and BetaNode.join merges left/
right tokens, concatenating facts in condition order and returning a merged
token only when shared variables agree. This fixes a P1 chained-join defect
where rules with three or more conditions lost bindings and accumulated
wrong facts at the third join, and a conflicting third condition could
spuriously fire. Beta nodes now keep both left/right token memories and
join each new token against every token on the opposite side.

Also fix an adjacent bug where beta nodes were never wired into their
inputs' children, blocking propagation. Adds tests/reasoning/test_rete_engine.py
including a TestThreeConditionChain suite (valid match, third-level conflict
suppression, insertion-order independence, complete in-order Match.facts,
multiple left tokens joining one right fact, parity against
Reasoner._match_rule, and reset clearing all token memory).
2026-08-19 10:24:24 +08:00
125 changed files with 27854 additions and 498 deletions
+3
View File
@@ -18,6 +18,9 @@
.git/**
.github
.github/**
!.github/requirements/
!.github/requirements/explorer-extra-py313.txt
!.github/requirements/pep517-build.txt
.claude
.claude/**
.codex
@@ -0,0 +1,56 @@
name: 'Setup Semantica'
description: 'Install Python, cache pip, and install the semantica package into a workflow'
author: 'Semantica'
inputs:
python-version:
description: 'Python version to set up'
required: false
default: '3.11'
version:
description: 'Version constraint to append to the pip spec, e.g. "==0.6.7" or ">=0.6,<0.7". Leave empty for the latest release.'
required: false
default: ''
extras:
description: 'Comma-separated extras to install, e.g. "explorer,all"'
required: false
default: ''
cache:
description: 'Pip cache mode passed straight to actions/setup-python ("pip" to enable). Left empty (disabled) by default because this action is meant to run standalone in any caller repo, and actions/setup-python errors out if it cannot find a requirements.txt/pyproject.toml/setup.py/poetry.lock to key the cache on. Opt in only when the caller repo has one of those files.'
required: false
default: ''
outputs:
version:
description: 'The installed semantica version'
value: ${{ steps.verify.outputs.version }}
runs:
using: 'composite'
steps:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: ${{ inputs.python-version }}
cache: ${{ inputs.cache }}
- name: Install semantica
shell: bash
env:
SEMANTICA_EXTRAS: ${{ inputs.extras }}
SEMANTICA_VERSION: ${{ inputs.version }}
run: |
python -m pip install --upgrade pip
if [ -n "$SEMANTICA_EXTRAS" ]; then
spec="semantica[$SEMANTICA_EXTRAS]$SEMANTICA_VERSION"
else
spec="semantica$SEMANTICA_VERSION"
fi
python -m pip install -- "$spec"
- name: Verify install
id: verify
shell: bash
run: |
VERSION=$(python -c "import semantica; print(semantica.__version__)")
echo "Installed semantica $VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+23
View File
@@ -101,6 +101,29 @@ updates:
allow:
- dependency-type: "production"
# Explorer frontend (npm)
- package-ecosystem: "npm"
directory: "/explorer"
schedule:
interval: "weekly"
day: "monday"
time: "03:30" # 3:30 AM UTC (9:00 AM IST)
open-pull-requests-limit: 10
reviewers:
- "KaifAhmad1"
assignees:
- "KaifAhmad1"
commit-message:
prefix: "security"
include: "scope"
labels:
- "dependencies"
- "javascript"
- "security"
allow:
- dependency-type: "production"
- dependency-type: "development"
# Docker dependencies (if you use Docker)
- package-ecosystem: "docker"
directory: "/"
+59
View File
@@ -0,0 +1,59 @@
# CI tool requirements
Hash-pinned `pip install` targets for CI/release/Dockerfile steps that install
something other than the project's own audited `requirements-ci.txt` set.
These exist because OpenSSF Scorecard's Pinned-Dependencies check flags any
`pip install` in a workflow or Dockerfile that isn't hash-verified, and
`requirements-ci.txt` alone doesn't cover build/release/security tooling or
the project's own local-source install.
Each `.txt` was generated from the adjacent `.in` (or, for `explorer-extra-py311.txt`,
`explorer-extra-py313.txt`, and `base-deps.txt`, from `pyproject.toml` directly) with:
```
uv pip compile <input> --python-version 3.11 --python-platform linux \
--constraint requirements-ci.txt --generate-hashes -o <output>.txt
```
(`--constraint requirements-ci.txt` is omitted for `bootstrap.txt`,
`build-tools.txt`, `uv-tool.txt`, `twine.txt`, `pip-audit.txt`, and
`security-scan-tools.txt`, since those install standalone tooling with no
version relationship to the project's own dependency tree.)
Regenerate a file the same way after bumping a pinned version, and re-run it
whenever `requirements-ci.txt` changes if the file used `--constraint` (see
each file's own autogenerated header comment for its exact command).
| File | Used by | Installs |
| --- | --- | --- |
| `bootstrap.txt` | security.yml, security-scan.yml, benchmark.yml | pip, setuptools (upgrade before anything else) |
| `pep517-build.txt` | ci.yml, benchmark.yml, Dockerfile | exact `[build-system] requires` from `pyproject.toml` (setuptools, wheel) - installed with `--no-build-isolation` before any `pip install -e .` / `pip install .`, since `--no-deps` alone doesn't stop pip's PEP 517 build isolation from fetching those two *unhashed* |
| `explorer-extra-py311.txt` | ci.yml | semantica's base deps + the `explorer` extra, resolved for python 3.11 |
| `explorer-extra-py313.txt` | Dockerfile | the same, resolved for python 3.13 (the image's actual interpreter) |
| `pgvector-extra.txt` | integration.yml | semantica's base deps + the `vectorstore-pgvector` extra, resolved for python 3.11 |
| `pytest-tool.txt` | ci.yml, integration.yml | pytest, for the pre-all-extras deterministic test |
| `uv-tool.txt` | ci.yml | uv, to verify requirements-ci.txt is current |
| `build-tools.txt` | ci.yml, release.yml | build, wheel |
| `twine.txt` | release.yml | twine |
| `pip-audit.txt` | security.yml | pip-audit |
| `security-scan-tools.txt` | security-scan.yml | safety, bandit, semgrep, jq |
| `base-deps.txt` | benchmark.yml | semantica's base deps (no extras) |
| `benchmark-extra.txt` | benchmark.yml | the benchmark-only libs (neo4j, pdfplumber, etc.) |
`explorer-extra-py31{1,3}.txt` and `base-deps.txt` are large (they mirror
most of `requirements-ci.txt`) because semantica's `dependencies` list in
`pyproject.toml` isn't extras-gated - installing the package at all pulls
the full base set. That's expected, not a mistake.
`explorer-extra-py311.txt` and `explorer-extra-py313.txt` are **not**
interchangeable, and can't be collapsed into one file compiled for either
version: `librosa`'s `audioread` dependency needs `standard-aifc` /
`standard-sunau` only under `python_version >= "3.13"` (Python 3.13 dropped
`aifc`/`sunau` from stdlib). A file resolved for 3.11 simply omits those
packages' hashes, so installing it with `--require-hashes` on a real 3.13
interpreter (the Dockerfile's base image) fails outright rather than
silently under-pinning. Any other file shared across a 3.11 and 3.13
consumer would need the same split if it hits a similar stdlib-removal
edge case - check for `ERROR: In --require-hashes mode, all requirements
must have their versions pinned` on the *other* Python version before
assuming one `--python-version` covers every consumer.
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
rdflib
neo4j
faiss-cpu
torch
pyarrow
pdfplumber
python-pptx
openpyxl
lxml
python-docx
beautifulsoup4
chardet
langdetect
en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
pip
setuptools
+10
View File
@@ -0,0 +1,10 @@
# This file was autogenerated by uv via the following command:
# uv pip compile .github/requirements/bootstrap.in --generate-hashes --python-version 3.11 --python-platform linux -o .github/requirements/bootstrap.txt
pip==26.2.1 \
--hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \
--hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f
# via -r .github/requirements/bootstrap.in
setuptools==84.0.0 \
--hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \
--hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73
# via -r .github/requirements/bootstrap.in
+2
View File
@@ -0,0 +1,2 @@
build==1.6.0
wheel==0.48.0
+20
View File
@@ -0,0 +1,20 @@
# This file was autogenerated by uv via the following command:
# uv pip compile .github/requirements/build-tools.in --generate-hashes --python-version 3.11 --python-platform linux -o .github/requirements/build-tools.txt
build==1.6.0 \
--hash=sha256:bd2c8afc603e7a2e0ce70e2ea85f0a6d02043bafbd307f5bada0f98669eca5af \
--hash=sha256:f7aaf1ebbb79178a02ba248bb524f2176b256017e17e8e4bd4289c7b38cc2bad
# via -r .github/requirements/build-tools.in
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via
# build
# wheel
pyproject-hooks==1.2.0 \
--hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \
--hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
# via build
wheel==0.48.0 \
--hash=sha256:3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab \
--hash=sha256:94800765601e9171bf5d58d066e640662842bcedcbab982b2c90787a2c987322
# via -r .github/requirements/build-tools.in
+1
View File
@@ -0,0 +1 @@
checkov==3.3.16
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
setuptools==84.0.0
wheel==0.48.0
+14
View File
@@ -0,0 +1,14 @@
# This file was autogenerated by uv via the following command:
# uv pip compile .github/requirements/pep517-build.in --generate-hashes --python-version 3.11 --python-platform linux -o .github/requirements/pep517-build.txt
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via wheel
setuptools==84.0.0 \
--hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \
--hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73
# via -r .github/requirements/pep517-build.in
wheel==0.48.0 \
--hash=sha256:3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab \
--hash=sha256:94800765601e9171bf5d58d066e640662842bcedcbab982b2c90787a2c987322
# via -r .github/requirements/pep517-build.in
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
pip-audit==2.10.1
+423
View File
@@ -0,0 +1,423 @@
# This file was autogenerated by uv via the following command:
# uv pip compile .github/requirements/pip-audit.in --generate-hashes --python-version 3.11 --python-platform linux -o .github/requirements/pip-audit.txt
boolean-py==5.0 \
--hash=sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95 \
--hash=sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9
# via license-expression
cachecontrol==0.14.4 \
--hash=sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b \
--hash=sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1
# via pip-audit
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via requests
charset-normalizer==3.5.1 \
--hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \
--hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \
--hash=sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5 \
--hash=sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b \
--hash=sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f \
--hash=sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f \
--hash=sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5 \
--hash=sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22 \
--hash=sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5 \
--hash=sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac \
--hash=sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838 \
--hash=sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90 \
--hash=sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626 \
--hash=sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4 \
--hash=sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369 \
--hash=sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b \
--hash=sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e \
--hash=sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee \
--hash=sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1 \
--hash=sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102 \
--hash=sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8 \
--hash=sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8 \
--hash=sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9 \
--hash=sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf \
--hash=sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0 \
--hash=sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031 \
--hash=sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e \
--hash=sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235 \
--hash=sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072 \
--hash=sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb \
--hash=sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c \
--hash=sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950 \
--hash=sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2 \
--hash=sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb \
--hash=sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e \
--hash=sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6 \
--hash=sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b \
--hash=sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2 \
--hash=sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d \
--hash=sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa \
--hash=sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2 \
--hash=sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818 \
--hash=sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032 \
--hash=sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71 \
--hash=sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96 \
--hash=sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687 \
--hash=sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8 \
--hash=sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3 \
--hash=sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61 \
--hash=sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9 \
--hash=sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1 \
--hash=sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a \
--hash=sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420 \
--hash=sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4 \
--hash=sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65 \
--hash=sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663 \
--hash=sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f \
--hash=sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591 \
--hash=sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a \
--hash=sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18 \
--hash=sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e \
--hash=sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f \
--hash=sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7 \
--hash=sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3 \
--hash=sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c \
--hash=sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3 \
--hash=sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7 \
--hash=sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96 \
--hash=sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486 \
--hash=sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3 \
--hash=sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6 \
--hash=sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b \
--hash=sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731 \
--hash=sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959 \
--hash=sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9 \
--hash=sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf \
--hash=sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8 \
--hash=sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e \
--hash=sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f \
--hash=sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885 \
--hash=sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0 \
--hash=sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506 \
--hash=sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2 \
--hash=sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0 \
--hash=sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e \
--hash=sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f \
--hash=sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e \
--hash=sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491 \
--hash=sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a \
--hash=sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20 \
--hash=sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449 \
--hash=sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af \
--hash=sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c \
--hash=sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712 \
--hash=sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7 \
--hash=sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a \
--hash=sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20 \
--hash=sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f \
--hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 \
--hash=sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9 \
--hash=sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e \
--hash=sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5 \
--hash=sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2 \
--hash=sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36 \
--hash=sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263 \
--hash=sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4 \
--hash=sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11 \
--hash=sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a \
--hash=sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3 \
--hash=sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375 \
--hash=sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa \
--hash=sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d \
--hash=sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5 \
--hash=sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99 \
--hash=sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d \
--hash=sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c \
--hash=sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488 \
--hash=sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6 \
--hash=sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc \
--hash=sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b \
--hash=sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f \
--hash=sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00 \
--hash=sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10 \
--hash=sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598 \
--hash=sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6 \
--hash=sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962 \
--hash=sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c \
--hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 \
--hash=sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab \
--hash=sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573 \
--hash=sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90 \
--hash=sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5 \
--hash=sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18 \
--hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d \
--hash=sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af \
--hash=sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea \
--hash=sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c \
--hash=sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b \
--hash=sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6 \
--hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8 \
--hash=sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774 \
--hash=sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004 \
--hash=sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a \
--hash=sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a \
--hash=sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2 \
--hash=sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2 \
--hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa \
--hash=sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe \
--hash=sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3 \
--hash=sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc \
--hash=sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795 \
--hash=sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d \
--hash=sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc \
--hash=sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893 \
--hash=sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef \
--hash=sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d \
--hash=sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda \
--hash=sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17 \
--hash=sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30 \
--hash=sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7 \
--hash=sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5 \
--hash=sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182 \
--hash=sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f \
--hash=sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9 \
--hash=sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada \
--hash=sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876 \
--hash=sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a \
--hash=sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348 \
--hash=sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3 \
--hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \
--hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \
--hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f
# via requests
cyclonedx-python-lib==11.12.0 \
--hash=sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54 \
--hash=sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf
# via pip-audit
defusedxml==0.7.1 \
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
# via py-serializable
filelock==3.32.4 \
--hash=sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd \
--hash=sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30
# via cachecontrol
idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via requests
license-expression==30.4.4 \
--hash=sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4 \
--hash=sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd
# via cyclonedx-python-lib
markdown-it-py==4.2.0 \
--hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
# via rich
mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
# via markdown-it-py
msgpack==1.2.2 \
--hash=sha256:06d95f61de7afe4f4ff908a6feebfcb070d0582ac87c9cf3cedf8551cf634516 \
--hash=sha256:0708afbf6a9587f0bfe479a9825c141d14d91e2f6a5c8103cf28bc96f4edb5d9 \
--hash=sha256:0883a1578168929fd1640fbbc4614773f1a130e419a8a817dc2918d9af1b651c \
--hash=sha256:0a652ceeededf71d3fa40c303a02a149d42338d310162367b91c539d4bd6e0a3 \
--hash=sha256:0dd9173c5ebaf5ecc5ca86e7ae1db92934e1d57b856f3dd90698941431f4fd77 \
--hash=sha256:0e3315de5a4b2920ccef48d96b4448025e064a10d0f5a250f6584477d839c8d4 \
--hash=sha256:0e91332144f69bc3018c91232fac26da580ef748fb8eaddd7914d4458001cc4f \
--hash=sha256:0fbc1bed8a535389b41882cfae66376e248cd1680eaa94fd83193c73e1d24986 \
--hash=sha256:11e8c421e117d1c36728b423d0402555cccbf0c6f53e288f0e75b6b12100d70f \
--hash=sha256:1510f24612d4b983dff6935d9273e02c320cfd525727fbcb58836a75f589fdbc \
--hash=sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc \
--hash=sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34 \
--hash=sha256:1f3af0baafd184436501004828bb3df64eeb2fc49dfe9d89abcf604956094563 \
--hash=sha256:1f6b6f8deb07d49090e1808c6ef9cb7d23ca17bef3aa6ed3e5e03df16606e60c \
--hash=sha256:226a62ffe99fe54c5c61d910ec64c3449b7766c3280bd286bf6c94838dde239a \
--hash=sha256:29cc2d5291711a52956a79a51f41c732329df39ad727c886bd8f0b5b9237a808 \
--hash=sha256:336525cc2688e43ea77dfb1a4ce012c8cde561835913801dbfcfdcf4111d8abb \
--hash=sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b \
--hash=sha256:352ed831042549cca8be23780e1fe7c9177e65ff02bf183509c4b4d33f671782 \
--hash=sha256:3e915d390d7068b257ca8b62f3fc59fad135c8631d1017ab03b0b924b07c5367 \
--hash=sha256:419a45c67a5c04213172a14b1864657e014665b77d7081b107a51707923dd39e \
--hash=sha256:42fd9260416885b4815caca5bdd14dfd5dda6cdade732d6c09104ef8f6228761 \
--hash=sha256:46ec851571d8f1b6e29794ebb9dd36f785008da6d14f57c702e60781d6caf648 \
--hash=sha256:4710d881d8fb047deed2485707409116722af2b992d3fefd73c7667c4e350839 \
--hash=sha256:4955accbd87f27beebef5f3ecc27503aa74cb016fb4f640868e749fd93194a35 \
--hash=sha256:4a4348705be86e029d04e741cf9ed0dfe03e942d7d3b92e838fa80d3aa2c3ebc \
--hash=sha256:4b554d8164ebb526892194f71dcd96ef1fefe0c250087498785d3ffc04a80be3 \
--hash=sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab \
--hash=sha256:51dd39d23cfdea0400ed3ff2d29d1e83bd951d3aea79dc89be5b701a09edfe23 \
--hash=sha256:53679573c75cce5f82359e0bd4e6a97809a6b9a9b7a48fd1ba592f4a82cddc84 \
--hash=sha256:55faa6f8395e23b848c535ad5dcb96b3462f37f5e7f4ac500d500434f7345da7 \
--hash=sha256:58ce37a4a54577115922385d37201d9a44d66d0167dfbbf4770a2e9bf8ea7ba3 \
--hash=sha256:59d5b93efa45fd09f620d0c9ba81cde339a2c9937af3eea42ee9653094ce6640 \
--hash=sha256:6195257a107bf25872ef84aab7295078271eea3ac6413f0506b631f6c9586ed5 \
--hash=sha256:652d1bf13d01bac8fd569def0fe76745e55bcda01e30aa6332d5947ea3788839 \
--hash=sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b \
--hash=sha256:68df2947921d449f6dcfeafd86cb2cdde13327a8b447534bbe4ee5aaf32a5695 \
--hash=sha256:6f53285f20d592ed309ee19e509cc4c77a3bda1db02ad67e8a0949bb227a5a6d \
--hash=sha256:73b0e05c32c3cfc3cd84994908e57430c0ebc6813abf905d3f18ff115d54df3f \
--hash=sha256:77c2e018417dc1d66f235e383877ee885b60ade9d29e494dd581e08af2cb1923 \
--hash=sha256:7826f16edc763e768404f55605ef85dfcf5857e729c1ed29e0d7c180be4fe6d8 \
--hash=sha256:7afa5431f6f3487c584187ca6c8e2a34e9b106529893b3e720eabb068f6ac970 \
--hash=sha256:7d095df2627e5dd59ac7b0c5ad627a671c76e6020171e03cbe4621a61f0562c3 \
--hash=sha256:7fe374ba76eb0ecca13a1703daa8fa85825a6ddddbb52d4c1a732fa524194683 \
--hash=sha256:82b1bdf293267afaadcc608b125e7fc6576bb0785a60c4fa7d07c7ab76ed76ec \
--hash=sha256:86f173a584f72f6164801f31866d22a581f60c991572cf922aed9ab8eb422b77 \
--hash=sha256:8b1415d02e9bf722672af8a90f90813265a0cd0b14163187261e54a5592bc949 \
--hash=sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533 \
--hash=sha256:8c6321a414f8b4a8dc43976b2fa8349156434ca9adedd9a187b796f7e1d3d3fc \
--hash=sha256:8dc4487097571f7311188c3eca2a3e86cd1f1db4c37c7a017bcc3fd38486cbfe \
--hash=sha256:90986cc9aab9d7d1d8f38bcbf65d3f7ac83bdd90c35765db7d691b4829698cba \
--hash=sha256:9352e6cdb510a7b1a5d3ccaccec730e82e50cf3484a3af7bdaab19e23b9589ff \
--hash=sha256:935b1cfad9b908b0fa845010f4271df4c2f04e1cd26e3f18acd61a45f93c9e36 \
--hash=sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5 \
--hash=sha256:9bd3d1557c3fe1a095068210708a03e3e4795973392af6f4047060e70abd9a6c \
--hash=sha256:9bf452ff4d4981f25a18e9476e002bcc9263e7928024aa4d7148e25f7be3f929 \
--hash=sha256:9d7fb25b4442fae0cb2590272d06ab4f6caa526ee36a994edb81e946b874813e \
--hash=sha256:9db1ba1c1e6a84245a9dd866265b56b8a1e9461549cc72ed296d8cbfbd32961b \
--hash=sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a \
--hash=sha256:9fd7f32e2f0fb334e7ecc5adb5cf0458785bd3a9d9d86f950e1715f101cebce5 \
--hash=sha256:a378e12ccc06d76efde115caf4073b7e5ff3cc18291d1341f9e65fb882e3f754 \
--hash=sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd \
--hash=sha256:a9b4cf3685a135666d27d0d7a73fece74e2fad01d9b508fded89e843512f0e90 \
--hash=sha256:aa1120c653b76d8eafa50423b5eba06b5c9737f8692c74fa3afe03e84b8978ea \
--hash=sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f \
--hash=sha256:b13b59e66f107cca1ba708dd5307179870ca1b15b19fcee7ccf722e5308d9212 \
--hash=sha256:b542ffc0a5c531eedc40419f291f1bd659aa8d4223408a5b51c88a2796083fd3 \
--hash=sha256:b5c696ae7cd7166b3657261adb855b461ff31f07823fdbae9de8bf80adfccc21 \
--hash=sha256:b68614fba0570349833b7dd999ff0aed4e5cc8d9eb6e3a7d4527be33c65e33d3 \
--hash=sha256:b8dd6c71d20c28d2d0eb0c51e7cccf3584afde3b1364f6629596186c9025bd54 \
--hash=sha256:b9b0c1f2aa7b0026b4bd50718100e8b04175e4f36e160aa852502377b5e572e7 \
--hash=sha256:c522420d78db2431887d45b518e304d86e27b9ad0b30f24e3806a6ad5d8bdbfc \
--hash=sha256:ccfd880988f8438d1c91c77d7edc58e70f4d2012e999167bc154c64c6f06ea6b \
--hash=sha256:cdb6cc6e1127d15879c47a8b3270716243da82d3e7feab1f5946872c75b3d60f \
--hash=sha256:cf66fb38703e61a486b01b56d43bb1f50698fbe99b6bd90feba10f24fab60b3b \
--hash=sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896 \
--hash=sha256:d242f3c4ccf55b056e6cf901720dccde58f1df117898f2bbf3bcd6e38ec7c248 \
--hash=sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9 \
--hash=sha256:d3c247d457ae9079974c7ce3c665396754a6d2baff7eaa51332212a8a5a3f13b \
--hash=sha256:d886baa46b2532135e7320067e6a44edb09ba5883a6096b0f9c044533984b8a8 \
--hash=sha256:e05a94a0442de86818a30281c6cc2cb9cc7aa148386fd3541c4d4774b73cb3a9 \
--hash=sha256:e1b99ad34613d5f8477fa5cf99bc4eaeaf27965588007c102370cd9a78fe9de5 \
--hash=sha256:e2eb7ea0ac3911a7aac9d8aaa36d40f216d99455b3274cd3fac38181bcd910cf \
--hash=sha256:e497ee34e8a3342bbde51b27c22d8db05a651df3361dd3daef5b3ab0d66f3e04 \
--hash=sha256:f11e09f10210a91c169e39c7a5a1f9090eaa73ad75555fafad5023c3053c47ba \
--hash=sha256:f466049b8e1ec0854287bbe9a074316826fe0e08dcf707245f98b1ae49e92650 \
--hash=sha256:f80361592c13d7226b4379c8941529b63fe1a9d0e05d2de8f3306b70e522b53f \
--hash=sha256:ffdd2f4950daf7815490f23087963e3420175b9609520b7ff5df64d351159c22
# via cachecontrol
packageurl-python==0.17.6 \
--hash=sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25 \
--hash=sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9
# via cyclonedx-python-lib
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via
# pip-audit
# pip-requirements-parser
pip==26.2.1 \
--hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \
--hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f
# via pip-api
pip-api==0.0.34 \
--hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \
--hash=sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625
# via pip-audit
pip-audit==2.10.1 \
--hash=sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc \
--hash=sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a
# via -r .github/requirements/pip-audit.in
pip-requirements-parser==32.0.1 \
--hash=sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526 \
--hash=sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3
# via pip-audit
platformdirs==4.11.5 \
--hash=sha256:89f8d42695853b89c7170bd49bc3dc593f98a71e695ede88e06a3b247bc4563b \
--hash=sha256:e8b31f4f8bcbbedef91a6b57a706255e4f148d2a4e01648382a0a47342539173
# via pip-audit
py-serializable==2.1.0 \
--hash=sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103 \
--hash=sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304
# via cyclonedx-python-lib
pygments==2.21.0 \
--hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \
--hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c
# via rich
pyparsing==3.3.2 \
--hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \
--hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc
# via pip-requirements-parser
requests==2.34.2 \
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
# via
# cachecontrol
# pip-audit
rich==15.0.0 \
--hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \
--hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36
# via pip-audit
sortedcontainers==2.4.0 \
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
# via cyclonedx-python-lib
tomli==2.4.1 \
--hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \
--hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \
--hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \
--hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \
--hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \
--hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \
--hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \
--hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \
--hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \
--hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \
--hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \
--hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \
--hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \
--hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \
--hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \
--hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \
--hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \
--hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \
--hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \
--hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \
--hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \
--hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \
--hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \
--hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \
--hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \
--hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \
--hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \
--hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \
--hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \
--hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \
--hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \
--hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \
--hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \
--hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \
--hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \
--hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \
--hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \
--hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \
--hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \
--hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \
--hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \
--hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \
--hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \
--hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \
--hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \
--hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \
--hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049
# via pip-audit
tomli-w==1.2.0 \
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
# via pip-audit
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
# via cyclonedx-python-lib
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via requests
+1
View File
@@ -0,0 +1 @@
pytest==9.1.1
+32
View File
@@ -0,0 +1,32 @@
# This file was autogenerated by uv via the following command:
# uv pip compile .github/requirements/pytest-tool.in --generate-hashes --python-version 3.11 --python-platform linux --constraint requirements-ci.txt -o .github/requirements/pytest-tool.txt
iniconfig==2.3.0 \
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
# via
# -c requirements-ci.txt
# pytest
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via
# -c requirements-ci.txt
# pytest
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via
# -c requirements-ci.txt
# pytest
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via
# -c requirements-ci.txt
# pytest
pytest==9.1.1 \
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
# via
# -c requirements-ci.txt
# -r .github/requirements/pytest-tool.in
@@ -0,0 +1,4 @@
safety==3.8.1
bandit==1.9.4
semgrep==1.175.0
jq==1.12.0
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
twine==7.0.0
+470
View File
@@ -0,0 +1,470 @@
# This file was autogenerated by uv via the following command:
# uv pip compile .github/requirements/twine.in --generate-hashes --python-version 3.11 --python-platform linux -o .github/requirements/twine.txt
backports-tarfile==1.2.0 \
--hash=sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34 \
--hash=sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991
# via jaraco-context
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via requests
cffi==2.1.1 \
--hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
--hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
--hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \
--hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \
--hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \
--hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \
--hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \
--hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \
--hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \
--hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \
--hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \
--hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \
--hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \
--hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \
--hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \
--hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \
--hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \
--hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \
--hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \
--hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \
--hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \
--hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \
--hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \
--hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \
--hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \
--hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \
--hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \
--hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \
--hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \
--hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \
--hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \
--hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \
--hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \
--hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \
--hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \
--hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \
--hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \
--hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \
--hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \
--hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \
--hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \
--hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \
--hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \
--hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \
--hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \
--hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \
--hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \
--hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \
--hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \
--hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \
--hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \
--hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \
--hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \
--hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \
--hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \
--hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \
--hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \
--hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \
--hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \
--hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \
--hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \
--hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \
--hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \
--hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \
--hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \
--hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \
--hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \
--hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \
--hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \
--hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \
--hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \
--hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \
--hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \
--hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \
--hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \
--hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \
--hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \
--hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \
--hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \
--hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \
--hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \
--hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \
--hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \
--hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \
--hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \
--hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \
--hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \
--hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \
--hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \
--hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \
--hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \
--hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \
--hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \
--hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \
--hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \
--hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \
--hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \
--hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \
--hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \
--hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264
# via cryptography
charset-normalizer==3.5.1 \
--hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \
--hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \
--hash=sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5 \
--hash=sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b \
--hash=sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f \
--hash=sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f \
--hash=sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5 \
--hash=sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22 \
--hash=sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5 \
--hash=sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac \
--hash=sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838 \
--hash=sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90 \
--hash=sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626 \
--hash=sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4 \
--hash=sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369 \
--hash=sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b \
--hash=sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e \
--hash=sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee \
--hash=sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1 \
--hash=sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102 \
--hash=sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8 \
--hash=sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8 \
--hash=sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9 \
--hash=sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf \
--hash=sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0 \
--hash=sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031 \
--hash=sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e \
--hash=sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235 \
--hash=sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072 \
--hash=sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb \
--hash=sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c \
--hash=sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950 \
--hash=sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2 \
--hash=sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb \
--hash=sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e \
--hash=sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6 \
--hash=sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b \
--hash=sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2 \
--hash=sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d \
--hash=sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa \
--hash=sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2 \
--hash=sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818 \
--hash=sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032 \
--hash=sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71 \
--hash=sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96 \
--hash=sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687 \
--hash=sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8 \
--hash=sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3 \
--hash=sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61 \
--hash=sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9 \
--hash=sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1 \
--hash=sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a \
--hash=sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420 \
--hash=sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4 \
--hash=sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65 \
--hash=sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663 \
--hash=sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f \
--hash=sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591 \
--hash=sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a \
--hash=sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18 \
--hash=sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e \
--hash=sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f \
--hash=sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7 \
--hash=sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3 \
--hash=sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c \
--hash=sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3 \
--hash=sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7 \
--hash=sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96 \
--hash=sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486 \
--hash=sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3 \
--hash=sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6 \
--hash=sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b \
--hash=sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731 \
--hash=sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959 \
--hash=sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9 \
--hash=sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf \
--hash=sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8 \
--hash=sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e \
--hash=sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f \
--hash=sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885 \
--hash=sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0 \
--hash=sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506 \
--hash=sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2 \
--hash=sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0 \
--hash=sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e \
--hash=sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f \
--hash=sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e \
--hash=sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491 \
--hash=sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a \
--hash=sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20 \
--hash=sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449 \
--hash=sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af \
--hash=sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c \
--hash=sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712 \
--hash=sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7 \
--hash=sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a \
--hash=sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20 \
--hash=sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f \
--hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 \
--hash=sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9 \
--hash=sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e \
--hash=sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5 \
--hash=sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2 \
--hash=sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36 \
--hash=sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263 \
--hash=sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4 \
--hash=sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11 \
--hash=sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a \
--hash=sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3 \
--hash=sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375 \
--hash=sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa \
--hash=sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d \
--hash=sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5 \
--hash=sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99 \
--hash=sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d \
--hash=sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c \
--hash=sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488 \
--hash=sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6 \
--hash=sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc \
--hash=sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b \
--hash=sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f \
--hash=sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00 \
--hash=sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10 \
--hash=sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598 \
--hash=sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6 \
--hash=sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962 \
--hash=sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c \
--hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 \
--hash=sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab \
--hash=sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573 \
--hash=sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90 \
--hash=sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5 \
--hash=sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18 \
--hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d \
--hash=sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af \
--hash=sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea \
--hash=sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c \
--hash=sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b \
--hash=sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6 \
--hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8 \
--hash=sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774 \
--hash=sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004 \
--hash=sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a \
--hash=sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a \
--hash=sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2 \
--hash=sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2 \
--hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa \
--hash=sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe \
--hash=sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3 \
--hash=sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc \
--hash=sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795 \
--hash=sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d \
--hash=sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc \
--hash=sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893 \
--hash=sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef \
--hash=sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d \
--hash=sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda \
--hash=sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17 \
--hash=sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30 \
--hash=sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7 \
--hash=sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5 \
--hash=sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182 \
--hash=sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f \
--hash=sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9 \
--hash=sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada \
--hash=sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876 \
--hash=sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a \
--hash=sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348 \
--hash=sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3 \
--hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \
--hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \
--hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f
# via requests
cryptography==50.0.1 \
--hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \
--hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \
--hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \
--hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \
--hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \
--hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \
--hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \
--hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \
--hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \
--hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \
--hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \
--hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \
--hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \
--hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \
--hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \
--hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \
--hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \
--hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \
--hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \
--hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \
--hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \
--hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \
--hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \
--hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \
--hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \
--hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \
--hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \
--hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \
--hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \
--hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \
--hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \
--hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \
--hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \
--hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \
--hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \
--hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \
--hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \
--hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \
--hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \
--hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \
--hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \
--hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \
--hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \
--hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \
--hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \
--hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef
# via secretstorage
docutils==0.23 \
--hash=sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea \
--hash=sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e
# via readme-renderer
id==1.6.1 \
--hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \
--hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca
# via twine
idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via requests
importlib-metadata==9.0.1 \
--hash=sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99 \
--hash=sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0
# via keyring
jaraco-classes==3.4.0 \
--hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \
--hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790
# via keyring
jaraco-context==6.1.2 \
--hash=sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535 \
--hash=sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3
# via keyring
jaraco-functools==4.6.0 \
--hash=sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280 \
--hash=sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30
# via keyring
jeepney==0.9.0 \
--hash=sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683 \
--hash=sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732
# via
# keyring
# secretstorage
keyring==25.7.0 \
--hash=sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f \
--hash=sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b
# via twine
markdown-it-py==4.2.0 \
--hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
# via rich
mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
# via markdown-it-py
more-itertools==11.1.0 \
--hash=sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d \
--hash=sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192
# via
# jaraco-classes
# jaraco-functools
nh3==0.3.7 \
--hash=sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877 \
--hash=sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62 \
--hash=sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102 \
--hash=sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086 \
--hash=sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32 \
--hash=sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8 \
--hash=sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d \
--hash=sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc \
--hash=sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af \
--hash=sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5 \
--hash=sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59 \
--hash=sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42 \
--hash=sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848 \
--hash=sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a \
--hash=sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946 \
--hash=sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba \
--hash=sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac \
--hash=sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174 \
--hash=sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493 \
--hash=sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa \
--hash=sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd \
--hash=sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563 \
--hash=sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506 \
--hash=sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b \
--hash=sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a \
--hash=sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479 \
--hash=sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac
# via readme-renderer
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via twine
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
pygments==2.21.0 \
--hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \
--hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c
# via
# readme-renderer
# rich
readme-renderer==46.0 \
--hash=sha256:af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90 \
--hash=sha256:d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560
# via twine
requests==2.34.2 \
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
# via
# requests-toolbelt
# twine
requests-toolbelt==1.0.0 \
--hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \
--hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06
# via twine
rfc3986==2.0.0 \
--hash=sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd \
--hash=sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c
# via twine
rich==15.0.0 \
--hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \
--hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36
# via twine
secretstorage==3.5.0 \
--hash=sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137 \
--hash=sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be
# via keyring
twine==7.0.0 \
--hash=sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177 \
--hash=sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7
# via -r .github/requirements/twine.in
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via
# id
# requests
# twine
zipp==4.1.0 \
--hash=sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f \
--hash=sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602
# via importlib-metadata
+1
View File
@@ -0,0 +1 @@
uv==0.12.1
+23
View File
@@ -0,0 +1,23 @@
# This file was autogenerated by uv via the following command:
# uv pip compile .github/requirements/uv-tool.in --generate-hashes --python-version 3.11 --python-platform linux -o .github/requirements/uv-tool.txt
uv==0.12.1 \
--hash=sha256:04290ea4001dca31ac8a8324113a4930dccad69ce35dbf6eaae307d54880890d \
--hash=sha256:153ec0959a15397514438aefc1d7cd04235f335dd6bb53ea0f9e6e82c5a49f03 \
--hash=sha256:173ee216f17d89fc39f65339d311a53584fc7de4918d27c0f3c7edafabc6b54d \
--hash=sha256:1de49d9b04438f1ad2f41a1441dbbe19e230b94fca56d632818cfaed69e03bfc \
--hash=sha256:1e8fd95fe98768e29436ad57f9ef7b68dc294b7b9862ef63396af8b15ab85e6c \
--hash=sha256:27211df9b277f440dea438a4e525ba40250fb721ad39b8927eefc2d91f9aea15 \
--hash=sha256:29399e1e73b67ed24abe82bc971aa4eb8419c4de804784290f39cf681f0b51ce \
--hash=sha256:2e9b0b86e180abc5968b979c6e25203b32e85969abb5083ee1e8b88a5aa98a76 \
--hash=sha256:3bd5db002adc763aa8d277f5b44f8d6e3fd82d20f2e51225b0bbdae1badc7259 \
--hash=sha256:41b8fc2335f682312a1ca39a7b4abfd6af800992065c663582ca3e4d51cf9258 \
--hash=sha256:5bd04849dd5346517cc4e57b4b3aa0b01c67c423878260c04f5893a038fe25b6 \
--hash=sha256:6f7e72543264d2420ebb2ddc84696a751af2d6c5910046b7666589118f47292b \
--hash=sha256:71f86410264c69a3e8acd18171897dd8ab1a13350cf40f718e4def5db2b724be \
--hash=sha256:76d87de420213ca92fa403e87023c4c7c6956c6726c6b96d91c42cfe620173a3 \
--hash=sha256:9331dda0dc4990512c232f86e1d3a7b83c13f459777fcc2bd46030911b40eaaa \
--hash=sha256:b255ac23958e45f39f9c7a4cd65890df5ef46f539a3b14de03bd296bbba9cb60 \
--hash=sha256:bd02f2da212e6a983115dc64a6fc94e9256c2d60e056d6b669de0a6025aaec05 \
--hash=sha256:e35e0030480a8c3bf8ecd87ae4a6f6a224009e15e96a6fbb3634ac11ab75d582 \
--hash=sha256:ead7ad064f291a5df358c3ffa8ffab347a32bd5a75a6a068ca22254c2539a829
# via -r .github/requirements/uv-tool.in
+76
View File
@@ -0,0 +1,76 @@
"""Drop checkov-suppressed results from its SARIF output before upload.
checkov's SARIF exporter includes every evaluated check as an ordinary
result, including ones it internally marked SKIPPED via an inline
`# checkov:skip=` comment or a `checkov.io/skipN` resource annotation - it
never uses SARIF's `suppressions` field, and never drops them. checkov's
JSON output *does* correctly record which checks were skipped, so this
cross-references the two: any SARIF result whose (check_id, file) pair
appears in the JSON's skipped_checks is removed before GitHub ever sees it.
Without this, every already-suppressed finding reopens as a brand new code
scanning alert on every run, forever (see #6035/#6036, #6112-6115,
#6128-6131 for the pattern this was chasing before this script existed).
Usage: filter_checkov_skipped.py <json_path> <sarif_in_path> <sarif_out_path>
"""
import json
import sys
def path_suffix(path: str, segments: int = 2) -> str:
"""Last N path segments, normalized to forward slashes, lowercased.
checkov's JSON file_path and SARIF artifactLocation.uri are relative to
different roots (the scanned directory vs. a temp helm-render dir), so
they can't be compared directly - but the last couple of segments
(e.g. "templates/service.yaml") are stable across both and specific
enough in practice to avoid cross-file collisions.
"""
normalized = path.replace("\\", "/").strip("/")
return "/".join(normalized.split("/")[-segments:]).lower()
def main() -> None:
json_path, sarif_in_path, sarif_out_path = sys.argv[1:4]
with open(json_path, encoding="utf-8") as f:
checkov_json = json.load(f)
if isinstance(checkov_json, dict):
checkov_json = [checkov_json]
skipped = set()
for block in checkov_json:
for check in block.get("results", {}).get("skipped_checks", []):
skipped.add((check["check_id"], path_suffix(check["file_path"])))
with open(sarif_in_path, encoding="utf-8") as f:
sarif = json.load(f)
removed = 0
for run in sarif.get("runs", []):
kept = []
for result in run.get("results", []):
rule_id = result.get("ruleId")
locations = result.get("locations") or [{}]
uri = (
locations[0]
.get("physicalLocation", {})
.get("artifactLocation", {})
.get("uri", "")
)
if (rule_id, path_suffix(uri)) in skipped:
removed += 1
continue
kept.append(result)
run["results"] = kept
with open(sarif_out_path, "w", encoding="utf-8") as f:
json.dump(sarif, f)
print(f"Removed {removed} checkov-suppressed result(s) from the SARIF before upload.")
if __name__ == "__main__":
main()
+31 -5
View File
@@ -28,11 +28,37 @@ jobs:
BENCHMARK_REAL_LIBS: "1"
run: |
python -m pip install --upgrade pip
pip install -e .
pip install -r benchmarks/requirements.txt
python -m spacy download en_core_web_sm
pip install rdflib neo4j faiss-cpu torch pyarrow pdfplumber python-pptx openpyxl lxml python-docx beautifulsoup4 chardet langdetect
pip install -r .github/requirements/bootstrap.txt --require-hashes
# --no-deps + a hash-pinned install of the same base dependency set
# (rather than a bare `pip install -e .`) so every fetched package
# is hash-verified (Scorecard Pinned-Dependencies); the local
# editable install itself has nothing to hash.
#
# --no-deps only skips *runtime* dependency resolution - `-e .`
# still does a PEP 517 build, which by default creates an isolated
# build env and fetches [build-system] requires (setuptools,
# wheel) completely outside any hash checking. Install
# pep517-build.txt (pins that exact build-system.requires) first
# and pass --no-build-isolation so pip reuses those hash-verified
# copies instead of fetching its own.
pip install -r .github/requirements/pep517-build.txt --require-hashes
pip install --no-deps --no-build-isolation -e .
pip install -r .github/requirements/base-deps.txt --require-hashes
# NOTE: benchmarks/ does not currently exist in this repo (neither
# requirements.txt nor benchmarks_runner.py below), so this job
# already fails on any real invocation - pre-existing, unrelated to
# this pinning change. The `pip install -r benchmarks/requirements.txt`
# step that used to be here is dropped rather than fixed: there's
# nothing to hash-pin without knowing what that file should
# contain, and an unpinned install here would just re-trip
# Scorecard's Pinned-Dependencies check for no real benefit, since
# the job can't run to completion regardless.
#
# `python -m spacy download en_core_web_sm` fetches an unpinned,
# unhashed wheel from spacy-models' GitHub releases - replaced with
# a hash-pinned direct-URL install of the same 3.8.0 model (matches
# the spacy==3.8.15 pinned in base-deps.txt) via benchmark-extra.txt.
pip install -r .github/requirements/benchmark-extra.txt --require-hashes
- name: Execute Benchmarks (Real Mode)
env:
+30 -7
View File
@@ -52,16 +52,39 @@ jobs:
# 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
#
# --no-deps + a separate hash-pinned install (rather than the old
# `pip install -e ".[explorer]" pytest==9.1.1`) so every fetched
# package is hash-verified (Scorecard Pinned-Dependencies); the
# local editable install itself has nothing to hash.
# .github/requirements/explorer-extra-py311.txt is
# `uv pip compile pyproject.toml --extra explorer --python-version 3.11 --constraint requirements-ci.txt --generate-hashes`
# - regenerate it the same way if pyproject.toml's base/explorer
# deps change. Resolved specifically for this job's python 3.11
# (see the Dockerfile's explorer-extra-py313.txt for why this
# can't be shared with python 3.13: audioread needs extra
# standard-aifc/standard-sunau hashes only on 3.13+).
#
# --no-deps only skips *runtime* dependency resolution - `-e .`
# still does a PEP 517 build, which by default creates an isolated
# build env and fetches [build-system] requires (setuptools,
# wheel) completely outside any hash checking. Install
# pep517-build.txt (pins that exact build-system.requires) first
# and pass --no-build-isolation so pip reuses those hash-verified
# copies instead of fetching its own.
pip install -r .github/requirements/pep517-build.txt --require-hashes
pip install --no-deps --no-build-isolation -e .
pip install -r .github/requirements/explorer-extra-py311.txt --require-hashes
pip install -r .github/requirements/pytest-tool.txt --require-hashes
- 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
pip install -r requirements-ci.txt --require-hashes
- name: Verify requirements-ci.txt is up to date
run: |
pip install uv==0.12.1
pip install -r .github/requirements/uv-tool.txt --require-hashes
# Re-resolve with the committed file as a constraint: upstream package
# releases must NOT fail CI (deps only change when pyproject.toml
# changes intentionally). Compare only version lines (pkg==ver),
@@ -72,10 +95,10 @@ jobs:
diff \
<(grep -E '^[a-zA-Z0-9._-]+==' requirements-ci.txt | sed 's/ \\$//') \
<(grep -E '^[a-zA-Z0-9._-]+==' /tmp/requirements-ci-check.txt)
- run: pip install build
# wheel is build-time only (not in requirements-ci.txt) — install the
# same pinned version [build-system] declares so --no-isolation works.
- run: pip install wheel==0.48.0
# build is a dev-time dependency; wheel is build-time only (neither is
# in requirements-ci.txt) — install the same pinned versions
# [build-system] declares so --no-isolation works below.
- run: pip install -r .github/requirements/build-tools.txt --require-hashes
- name: Build package (no isolation — pinned deps)
run: python -m build --no-isolation
- name: Verify Explorer frontend is packaged
+4 -2
View File
@@ -10,13 +10,15 @@ on:
permissions:
contents: read
security-events: write
actions: read
jobs:
analyze:
name: Analyze Python
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # for github/codeql-action/upload-sarif below
actions: read # for github/codeql-action/init's CodeQL bundle cache lookup
steps:
- name: Checkout repository
+75
View File
@@ -0,0 +1,75 @@
name: Container Security Scan
on:
push:
branches: [main]
# Mirrors .dockerignore's opt-in list exactly - anything not listed there
# can't reach the build context, so it can't change the built image.
paths:
- 'Dockerfile'
- '.dockerignore'
- 'pyproject.toml'
- 'README.md'
- 'LICENSE'
- 'MANIFEST.in'
- '.github/requirements/explorer-extra-py313.txt'
- '.github/requirements/pep517-build.txt'
- 'semantica/**'
- 'integrations/**'
- 'explorer/**'
- '.github/workflows/container-scan.yml'
schedule:
- cron: '30 2 * * 1' # weekly, catches new CVEs published against the base image between pushes
workflow_dispatch:
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # for github/codeql-action/upload-sarif below
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Build image
run: docker build -t semantica:scan .
# Run Trivy as a digest-pinned image rather than the aquasecurity/trivy-action
# marketplace wrapper: the aquasecurity GitHub org has an IP allow list on its
# API that 403s verify-action-pins.sh's live tag->SHA check from Actions-runner
# IPs, and this repo already treats Trivy's action pin as a known past target
# for tag-repointing (see the LiteLLM/Trivy 2026 incident note above). Pulling
# by sha256 digest from Docker Hub is immutable and verifiable independently of
# GitHub's API, so it sidesteps both problems at once instead of carving a skip
# exception into the pin verifier for an org already flagged as higher-risk.
#
# Report-only for now: this is Trivy's first run against this image, so we
# don't yet know the CRITICAL/HIGH baseline. Findings still land in the
# Security tab either way. Once triaged, add `--exit-code 1` (like
# Safety/Bandit-HIGH in security-scan.yml) to make it a hard gate.
- name: Scan image for vulnerabilities (Trivy)
run: |
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PWD:/output" \
aquasec/trivy@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 \
image --format sarif --output /output/trivy-results.sarif \
--severity CRITICAL,HIGH --ignore-unfixed semantica:scan
- name: Upload Trivy SARIF
if: always()
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
with:
sarif_file: trivy-results.sarif
category: trivy-container
- name: Generate SBOM (Syft)
if: always()
uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
with:
image: semantica:scan
format: spdx-json
output-file: semantica-sbom.spdx.json
+23 -5
View File
@@ -28,12 +28,14 @@ on:
permissions:
contents: read
security-events: write
jobs:
MSDO:
# currently only windows-latest is supported
runs-on: windows-latest
permissions:
contents: read
security-events: write # for github/codeql-action/upload-sarif below
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -66,7 +68,7 @@ jobs:
python-version: "3.12"
- name: Install Checkov
run: python -m pip install checkov==3.3.1
run: pip install -r .github/requirements/checkov.txt --require-hashes
- name: Run Checkov
shell: pwsh
@@ -74,12 +76,28 @@ jobs:
PYTHONUTF8: "1"
run: |
New-Item -ItemType Directory -Force reports | Out-Null
checkov --directory . --framework kubernetes helm dockerfile github_actions secrets bicep arm --soft-fail --output sarif --output-file-path reports/checkov.sarif
if (-not (Test-Path reports/checkov.sarif)) {
checkov --directory . --framework kubernetes helm dockerfile github_actions secrets bicep arm --soft-fail --output sarif --output json --output-file-path reports
if (-not (Test-Path reports/results_sarif.sarif)) {
$sarif = Get-ChildItem -Path reports -Recurse -Filter *.sarif | Select-Object -First 1
if ($null -eq $sarif) { throw "Checkov did not produce a SARIF file" }
Copy-Item $sarif.FullName reports/checkov.sarif
Copy-Item $sarif.FullName reports/results_sarif.sarif
}
if (-not (Test-Path reports/results_json.json)) {
$json = Get-ChildItem -Path reports -Recurse -Filter *.json | Select-Object -First 1
if ($null -eq $json) { throw "Checkov did not produce a JSON file" }
Copy-Item $json.FullName reports/results_json.json
}
# checkov's SARIF exporter includes checks it internally marked SKIPPED
# (via the inline `# checkov:skip=` comments / `checkov.io/skipN`
# annotations already on the Helm chart) as ordinary un-suppressed
# results - it never uses SARIF's own `suppressions` field, so GitHub
# opens a fresh alert for the same already-suppressed finding on every
# single run (see #6035/#6036, #6112-6115, #6128-6131). checkov's JSON
# output does correctly record the skip, so cross-reference it here
# instead of re-dismissing the same alerts by hand forever.
- name: Filter checkov's own suppressed checks out of the SARIF
run: python .github/scripts/filter_checkov_skipped.py reports/results_json.json reports/results_sarif.sarif reports/checkov.sarif
- name: Upload Checkov results to Security tab
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
+59
View File
@@ -0,0 +1,59 @@
name: Install Matrix
permissions:
contents: read
on:
schedule:
- cron: '0 6 * * 1' # weekly, catches upstream dependency breakage between releases
workflow_run:
# The Release workflow publishes the GitHub release *before* it uploads to
# PyPI (see release.yml), so triggering on `release: published` would race
# the PyPI upload and could pass by silently installing the prior version.
# workflow_run fires only after the whole Release workflow - including the
# PyPI publish step - has finished.
workflows: ['Release']
types: [completed]
workflow_dispatch:
jobs:
verify-install:
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
name: pip install semantica (${{ matrix.os }}, py${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ['3.9', '3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Pin expected version for release-triggered runs
id: expected-version
if: github.event_name == 'workflow_run'
shell: bash
env:
EXPECTED_TAG: ${{ github.event.workflow_run.head_branch }}
run: |
expected="${EXPECTED_TAG#v}"
if [ -z "$expected" ]; then
echo "::error::Could not determine a release tag from the triggering workflow run (head_branch was empty)."
exit 1
fi
echo "constraint===$expected" >> "$GITHUB_OUTPUT"
- id: setup-semantica
uses: ./.github/actions/setup-semantica
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
version: ${{ steps.expected-version.outputs.constraint }}
- name: Smoke test import
shell: bash
run: |
python -c "
import semantica
print('semantica', semantica.__version__, 'installed and importable')
"
+91
View File
@@ -0,0 +1,91 @@
name: Integration Tests
# Separate from ci.yml, which is a required check: a slow image pull or a
# container flake must not block unrelated merges.
permissions:
contents: read
on:
pull_request:
branches: [main]
paths-ignore:
- 'docs/**'
- 'docs_check.py'
- '**/*.md'
schedule:
- cron: '0 5 * * 1'
workflow_dispatch:
jobs:
pgvector:
name: pgvector (live PostgreSQL)
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
# pgvector/pgvector:pg16 as published 2026-08-13. Pinned by digest like
# the action pins, though verify-action-pins.sh does not check images.
image: pgvector/pgvector@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b
env:
POSTGRES_USER: postgres
POSTGRES_DB: test
# Throwaway container reachable only from this job, so trust auth
# avoids putting a credential in the workflow at all.
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
env:
TEST_PGVECTOR_URL: postgresql://postgres@localhost:5432/test
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.11'
cache: 'pip'
- name: Install semantica with the pgvector extra
# Hash-verified installs throughout, matching ci.yml/security.yml/etc
# (OpenSSF Scorecard's Pinned-Dependencies check). --no-deps here
# skips runtime dependency resolution for the editable install itself
# (nothing to hash); pep517-build.txt + --no-build-isolation stops
# its PEP 517 build from separately fetching an unhashed
# setuptools/wheel via build isolation.
run: |
pip install -r .github/requirements/bootstrap.txt --require-hashes
pip install -r .github/requirements/pep517-build.txt --require-hashes
pip install --no-deps --no-build-isolation -e .
pip install -r .github/requirements/pgvector-extra.txt --require-hashes
pip install -r .github/requirements/pytest-tool.txt --require-hashes
- name: Create the vector extension
# PgVectorStore._verify_pgvector_extension() requires it and refuses to
# create it. Doubles as the connectivity gate.
run: |
python - <<'PY'
import os
import psycopg
with psycopg.connect(os.environ["TEST_PGVECTOR_URL"]) as conn:
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
conn.commit()
print("vector extension ready")
PY
- name: Run the live pgvector suite
# pg_available raises rather than skipping when TEST_PGVECTOR_URL was
# set explicitly (which this job always does), so a service that's
# actually unreachable fails this step instead of the suite quietly
# reporting green having run nothing.
run: |
pytest tests/vector_store/test_pgvector_store.py -v -rs
+26 -8
View File
@@ -16,7 +16,7 @@ jobs:
cancel-in-progress: false
permissions:
contents: write # for the GitHub Release
id-token: write # for PyPI Trusted Publishing (OIDC) and attestation signing
id-token: write # for PyPI Trusted Publishing (OIDC), attestation signing, and Sigstore
attestations: write # for SLSA build provenance
# If you add another job to this workflow, give it its own explicit
# `permissions:` block rather than relying on the workflow-level default
@@ -39,11 +39,11 @@ jobs:
# Install the pinned dependency set (with hashes) so the sdist/wheel
# build runs against the same versions CI tests against.
- name: Install pinned build dependencies
run: pip install -r requirements-ci.txt
- run: pip install build
# wheel is build-time only (not in requirements-ci.txt) — install the
# same pinned version [build-system] declares so --no-isolation works.
- run: pip install wheel==0.48.0
run: pip install -r requirements-ci.txt --require-hashes
# build is a dev-time dependency; wheel is build-time only (neither is
# in requirements-ci.txt) — install the same pinned versions
# [build-system] declares so --no-isolation works below.
- run: pip install -r .github/requirements/build-tools.txt --require-hashes
- name: Build package (no isolation — pinned deps)
run: python -m build --no-isolation
- name: Verify Explorer frontend is packaged
@@ -63,11 +63,29 @@ jobs:
print("Explorer frontend is packaged")
PY
- name: Verify PyPI long-description will render
run: |
pip install -r .github/requirements/twine.txt --require-hashes
twine check dist/*
- name: Attest build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
with:
subject-path: 'dist/*'
- uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
# attest-build-provenance publishes to the GH attestations API only, which
# OpenSSF Scorecard's Signed-Releases check does not inspect - it looks for
# signature files attached as release assets. Sign here too so
# `dist/*.sigstore.json` bundles ship alongside the wheel/sdist on the
# GitHub Release itself.
- name: Sign artifacts with Sigstore
uses: sigstore/gh-action-sigstore-python@790bc6befb9d733738f18d8f895854b453640ec9 # v3.5.0
with:
files: dist/*
inputs: |
dist/*.whl
dist/*.tar.gz
- uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
files: |
dist/*.whl
dist/*.tar.gz
dist/*.sigstore.json
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
+45
View File
@@ -0,0 +1,45 @@
name: Scorecard supply-chain security
permissions: read-all
on:
branch_protection_rule:
schedule:
- cron: '30 1 * * 6' # weekly
push:
branches: [main]
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
security-events: write # to upload SARIF results
id-token: write # to publish results and get a badge
contents: read
actions: read # to detect GitHub Actions workflows
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Run analysis
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
with:
sarif_file: results.sarif
+72 -15
View File
@@ -44,15 +44,15 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r .github/requirements/bootstrap.txt --require-hashes
# Install the pinned dependency set FIRST so Safety scans Semantica's
# exact CI/release dependency tree (requirements-ci.txt is generated
# from pyproject.toml extras, so this covers the project's real deps).
pip install -r requirements-ci.txt
pip install -r requirements-ci.txt --require-hashes
# Tooling AFTER the pinned set: installing safety/bandit/semgrep/jq
# first lets the pinned requirements overwrite their transitive deps
# (e.g. rich), which breaks the safety CLI at runtime.
pip install safety bandit semgrep jq
pip install -r .github/requirements/security-scan-tools.txt --require-hashes
- name: Run Safety Check (Package Vulnerabilities)
run: |
@@ -60,7 +60,12 @@ jobs:
# (json/text/screen/...), not a file path. Writing JSON to a file
# now requires --save-json; the previous `--output safety-report.json`
# usage was silently invalid and never produced a report.
safety check --save-json safety-report.json || true
#
# Scan requirements-ci.txt directly instead of the installed environment
# to avoid crashes from packages like cuda-toolkit that Safety cannot
# parse. This also ensures we're auditing the declared dependency tree
# rather than transitive dependencies of the security tooling itself.
safety check --file requirements-ci.txt --save-json safety-report.json || true
# Guard 1: fail loudly if Safety exited before writing a report at all
# (network error, API auth failure, tool crash). Without this check a
@@ -73,10 +78,45 @@ jobs:
echo "Checking for package vulnerabilities..."
# No || echo "0" fallback: if jq fails (malformed JSON, missing key,
# vulnerabilities:null) VULNS will be empty or "null" so guard 2 below
# catches it rather than silently treating the broken report as zero.
VULNS=$(jq '.vulnerabilities | length' safety-report.json 2>/dev/null)
# Vulnerability IDs reviewed and accepted as non-actionable for this
# project. Filtered out here with jq rather than passed to Safety's
# own --ignore flag: --ignore crashes ("Unhandled exception happened:
# 'cuda-toolkit'") when it has to apply itself against a live-matched
# vulnerability for cuda-toolkit, apparently the same class of
# unguarded dependency-graph lookup that broke the plain environment
# scan (see git history on this file). The un-ignored scan above is
# the one path confirmed - by an actual CI run - not to crash even
# with a live cuda-toolkit match, so all filtering happens after the
# fact in jq instead of inside Safety.
#
# - SFTY-20260120-40557 (CVE-2025-33228): cuda-toolkit<13.1.0. torch
# 2.13.0 (latest available; no newer release exists) hard-pins
# cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,
# cusparse,nvjitlink,nvrtc,nvtx]==13.0.3 on Linux - not a version we
# control. The CVE is OS command injection in NVIDIA Nsight
# Systems' gfx_hotspot recipe (process_nsys_rep_cli.py), requiring
# manual invocation with an attacker-supplied string; unreachable
# from Semantica, and Nsight Systems isn't among the extras torch
# requests above. Re-evaluate once torch pins a patched
# cuda-toolkit.
IGNORED_VULN_IDS="SFTY-20260120-40557"
# Exported so the "Comment PR with Security Results" step below can
# apply the same exclusion list to the raw report - it reads
# safety-report.json independently in JS, so without this the PR
# comment would show the accepted CVE as a live finding even though
# this gate correctly treats it as non-actionable.
echo "IGNORED_VULN_IDS=$IGNORED_VULN_IDS" >> "$GITHUB_ENV"
# No []? / || echo "0" fallback on a missing/null "vulnerabilities"
# key: iterating over null raises inside jq, leaving VULNS empty, so
# guard 2 below catches it rather than silently treating a broken
# report as zero.
VULNS=$(jq --arg ignored "$IGNORED_VULN_IDS" '
($ignored | split(",")) as $ignore_list
| [.vulnerabilities[] | select(.vulnerability_id as $id | ($ignore_list | index($id)) | not)]
| length
' safety-report.json 2>/dev/null)
# Guard 2: ensure VULNS is a non-negative integer before the -gt
# comparison. "null" (missing/null key) or "" (jq parse failure) would
@@ -92,10 +132,14 @@ jobs:
echo "CI will fail to prevent merging of vulnerable dependencies"
echo ""
echo "Vulnerability details:"
jq -r '.vulnerabilities[] | "- \(.package_name)==\(.analyzed_version): \(.vulnerability_id) (\(.CVE // "no CVE assigned"))"' safety-report.json || true
jq --arg ignored "$IGNORED_VULN_IDS" -r '
($ignored | split(",")) as $ignore_list
| .vulnerabilities[] | select(.vulnerability_id as $id | ($ignore_list | index($id)) | not)
| "- \(.package_name)==\(.analyzed_version): \(.vulnerability_id) (\(.CVE // "no CVE assigned"))"
' safety-report.json || true
exit 1
else
echo "✅ No security vulnerabilities found"
echo "✅ No actionable security vulnerabilities found (ignored: $IGNORED_VULN_IDS)"
fi
- name: Run Bandit (Code Security Linter)
@@ -184,14 +228,27 @@ jobs:
return lines.join('\n');
}
// Mirrors the shell step's own IGNORED_VULN_IDS (passed through
// $GITHUB_ENV) so an accepted, non-actionable CVE that the CI
// gate already excluded doesn't reappear here as a live finding -
// this reads the same raw, unfiltered safety-report.json.
const ignoredVulnIds = (process.env.IGNORED_VULN_IDS || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean);
const safetySection = renderSection(
'Safety — dependency vulnerabilities',
'safety-report.json',
(data) => (data.vulnerabilities || []).map(
(v) => `- \`${v.package_name}==${v.analyzed_version}\`: ${v.vulnerability_id}` +
(v.CVE ? ` (${v.CVE})` : '') + ` — ${v.advisory || 'no advisory text'}`
)
);
(data) => (data.vulnerabilities || [])
.filter((v) => !ignoredVulnIds.includes(v.vulnerability_id))
.map(
(v) => `- \`${v.package_name}==${v.analyzed_version}\`: ${v.vulnerability_id}` +
(v.CVE ? ` (${v.CVE})` : '') + ` — ${v.advisory || 'no advisory text'}`
)
) + (ignoredVulnIds.length
? `\n\n_Excluded as accepted, non-actionable findings: ${ignoredVulnIds.join(', ')} — see the workflow file's inline comments for why._`
: '');
const banditSection = renderSection(
'Bandit — HIGH-severity code issues',
+3 -3
View File
@@ -25,18 +25,18 @@ jobs:
# Upgrade first: actions/setup-python's baked-in setuptools has been
# behind known-vulnerable floors before (e.g. PYSEC-2026-3447 /
# setuptools 75.1.0), so don't trust the preinstalled one.
- run: python -m pip install --upgrade pip setuptools
- run: pip install -r .github/requirements/bootstrap.txt --require-hashes
# Audit the pinned dependency set (requirements-ci.txt is compiled from
# pyproject.toml with --extra all — the same coverage as the [all]
# extra, minus the Linux-only gpu set — so this keeps scan parity with
# CI/release builds without a time-dependent resolution). This is the
# fix for PYSEC-2024-38 (#869): the bare-env job never had fastapi or
# python-multipart installed to look at.
- run: pip install -r requirements-ci.txt
- run: pip install -r requirements-ci.txt --require-hashes
# PR runs gate on findings, since they're scoped to actual
# pyproject.toml changes under review. The schedule/workflow_dispatch
# runs stay non-blocking until a full pass over pre-existing findings
# across the whole [all] tree has been done.
- run: pip install pip-audit
- run: pip install -r .github/requirements/pip-audit.txt --require-hashes
- run: pip-audit -r requirements-ci.txt
continue-on-error: ${{ github.event_name != 'pull_request' }}
+32
View File
@@ -9,6 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **`ErasureCoordinator` completes the erasure workflow `purge_node()` only starts — the graph node was removed while the same content survived verbatim in `AgentMemory` and as an embedding** (closes #1018) by @pravit-amp
- New `semantica/context/erasure.py`, exporting `ErasureCoordinator` and `ErasureReceipt` from `semantica.context`. `purge_node()`/`purge_edge()` (#957) are graph-scope by design and their changelog entry documents this gap explicitly; the changelog also names GDPR Article 17 as the motivation, and an Article 17 erasure that removes the node while the content stays retrievable by similarity search is not an erasure — it is worse than not offering one, because `purge_node()` returns `True` and writes a tombstone attesting the content is gone
- The coordinator **composes** the existing public APIs — nothing in `context_graph.py` or `agent_memory.py` changes behaviorally, and `ContextGraph` keeps its documented graph-scope contract rather than acquiring references to `AgentMemory`/`vector_store` that would invert the dependency
- `erase_entity(entity_id, reason=..., at=..., vector_ids=...)` returns an `ErasureReceipt`; `erase_entities([...])` returns one receipt per entity, in order, so one entity's failure does not stop the rest
- **Honest partial reporting is the point.** Each store reports one of five statuses — `erased`, `not_found`, `not_configured` (store never bound; normal), `unsupported` (store cannot delete at all; retrying will not help), `failed` — and `receipt.complete` is `False` when any store reports `unsupported`/`failed`, with `receipt.incomplete_stores` naming them. A receipt reading `graph: erased, memory: 14 erased, vectors: unsupported on faiss` is actionable; a bare `True` is a compliance liability
- **Erasure runs outward-in: vectors → memory → graph.** The graph tombstone is the durable attestation that an erasure happened, so writing it first would let a crash mid-cascade leave a record claiming more than occurred. Erasing the graph last means a partial failure leaves the node present and the receipt incomplete — recoverable and honest; the reverse is neither
- **Partial failure is a result, not an exception**: a store that raises is recorded as `failed` (with the exception type) and the remaining legs still run, rather than aborting into a half-erased state with no record of which half
- **The memory sweep cannot be silently truncated.** `find_by_entity(entity_id, limit=10)` returned `results[:limit]`, so the obvious hand-rolled cascade erases the first ten items and reports success — an erasure check computed from a page already truncated by the very `limit` it was called with. The coordinator sweeps in pages until dry (deleting as it goes, so the next page is the remainder) rather than passing one large number that is only correct until someone exceeds it, then **re-queries once after the sweep** and reports `failed` with the residual count if anything survived. It also stops rather than spinning if `batch_delete` reports no progress on a non-empty page. Note `find_by_entity` returns items keyed `memory_id`, not `id`
- **`unsupported` vector backends are detected by probing, not by calling and catching.** `faiss_store.py`, `milvus_store.py` and `weaviate_store.py` expose no delete at all (FAISS cannot remove from a flat index without a rebuild), while the `VectorStore` facade declares `delete_vectors()` for *every* backend and only raises `NotImplementedError` once called — so probing the facade alone cannot tell a deletable backend from a delete-less one, and the coordinator looks at the backend it wraps. Probing also keeps a missing method distinguishable from an `AttributeError` raised *inside* a working one, which is exactly where guessing wrong produces a false clean bill of health. `NotImplementedError` at call time is still caught and reported as `unsupported`; a store returning `False` is reported as `failed`
- Backends are reached under either supported name — `delete_vectors(ids)` (pinecone/qdrant) or `delete(ids)` (pgvector/sqlite-vec) — and the receipt records which was used
- `vector_store` defaults to `memory.vector_store` when a memory is supplied, stays overridable for deployments binding a store the memory does not own, and accepts `False` to disable the vector leg. Vectors owned by memory items are removed by the memory leg's own `delete_memory()` cascade; the explicit vector leg covers entity-keyed embeddings written by something other than `AgentMemory`
- The receipt's `erased_at` is normalized through `ContextGraph`'s own temporal normalizer, so the receipt and the tombstone written by the same erasure cannot disagree about when it happened; an unparseable `at` is rejected before any store is touched rather than half way through the cascade
- `purge_node()`'s docstring now points at the coordinator, so callers reading the graph-scope caveat find the thing that completes the workflow
- New `tests/context/test_erasure_coordinator.py`: 48 tests against **real** `ContextGraph`/`AgentMemory` instances rather than mocks — the bug lives in the interaction between them, so mocking it away would test nothing. Covers the 25-items-on-one-entity regression that fails against a naive single `find_by_entity()` call, all three vector-backend shapes (`delete_vectors`/`delete`/neither) plus the facade-over-delete-less-backend shape, residual/no-progress/no-identifier memory failures, partial failure continuing the cascade, idempotency, receipt serialization, and `at` normalization
- Full `tests/context/` suite: 738 passed
- **Fixed during review** (Qodo): `erase_entity()` resolved `erased_at` up front but passed the caller's original `at` down to `purge_node()`, so on the default `at=None` path the coordinator and the graph each took their own `now()` and the receipt attested to a different instant than the tombstone it points at — breaking the one invariant this module states most loudly. The resolved timestamp is now passed to the graph. The existing test passed only because it supplied an explicit `at`, which hides the drift; a regression test now covers the `at=None` path that callers actually use
- **Fixed during review** (Qodo): the vectors leg treated any return value other than the literal `False` as success, but no in-repo backend returns a bool — Qdrant returns `{"status": <UpdateStatus>}` and Pinecone `{"deleted": True}`, so every dict was read as a success and the backend's own account of the delete was discarded. Delete results are now interpreted by shape (bool, dict with explicit failure markers, `None` for a void method, anything else at face value) and the backend payload is recorded in the receipt as `backend_result`, stringified so the receipt stays JSON-serializable as the audit record it is meant to be. Bool markers are matched by identity so a `0` count is not read as `False`, and string markers match as substrings so an enum rendering as `"UpdateStatus.FAILED"` is not read as a success
- **Fixed during review** (Qodo): the constructor's "at least one store" guard used `not vector_store`, rejecting a valid store whose `__bool__`/`__len__` makes an empty instance falsey, and reporting `vector_store=None` in the error when an object had been passed; it now distinguishes `None` (absent) from `False` (deliberately disabled) from any other value (provided), and echoes what it actually received
- **Fixed during review** (Qodo): `at` annotations accepted only `str`/`datetime` while the shared `ContextGraph` normalizer they delegate to also takes epoch seconds; widened to `int`/`float` with the docstrings updated, so the coordinator no longer advertises less than the graph API it wraps
- **Known limitation, unchanged by this PR**: erasure still cannot be *completed* on FAISS/Milvus/Weaviate — `delete_vectors()` is declared on the `VectorStore` facade (`vector_store.py:786`) but not implemented across the backend set, under at least three different names. That is worth its own issue; the coordinator ships reporting `unsupported` and starts reporting `erased` for those backends once it is fixed, with no API change here
## [0.6.7] - 2026-08-28
### Added
@@ -213,6 +236,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **RETE engine matched every fact against every rule — `AlphaNode._matches()` and `BetaNode._can_join()` were placeholder stubs that always returned `True`** (closes #300)
- `semantica/reasoning/rete_engine.py` shipped a Rete network whose per-condition alpha test and cross-condition beta join were both `return True` stubs, so `match_patterns()` fired every rule for every fact regardless of predicate, arity, or shared-variable consistency
- New module-level `unify_condition()` reuses the regex-based approach from `Reasoner._match_pattern()`: a condition pattern like `Person(?x)` / `Parent(?x, ?y)` is compiled against a fact's `predicate(arg, ...)` string, `?var` becomes a named capture group, and a variable seen twice within one condition (e.g. `Loves(?x, ?x)`) becomes a backreference, so it only unifies when both positions hold the same value. Returns the bindings dict or `None`
- Reworked propagation to carry partial-match **tokens** instead of bare facts: a new `Token` dataclass bundles the accumulated `facts` with the consistent `bindings`. `AlphaNode` emits a single-fact token per match; `BetaNode.join()` merges a left token with a right token, concatenating their facts in condition order and returning the merged token only when shared variables agree (conflicting values → `None`, no join). Terminal activations carry the full fact list and accumulated bindings through to the emitted match
- This fixes a P1 chained-join defect: rules with three or more conditions (e.g. `Person(?x)`, `Parent(?x, ?y)`, `Located(?y, ?z)`) previously lost bindings and accumulated wrong facts at the third join, and a conflicting third condition could spuriously fire. Beta nodes now keep both `left_tokens` and `right_tokens` memories and join each new token against every token on the opposite side, so deep chains stay binding-consistent and third-level conflicts are correctly suppressed
- Fixed an adjacent network-topology bug surfaced by the above: newly created beta nodes were never appended to their input nodes' `children`, so tokens could not propagate; propagation was reworked to support chained joins and to thread bindings end-to-end
- Reconciled with the rule-actions/provenance layer (#1096) merged after this fix was opened: `execute_matches()` still dedupes and fires `Rule.actions`/legacy `handler` through a bound `Reasoner` via `_make_activation_key`, now sourced from the Token model's own `bindings` instead of the interim `_bindings_for_rule()` regex re-extraction, which is removed as redundant
- New `tests/reasoning/test_rete_engine.py`: `unify_condition` unit cases (single/multi variable, literal args, predicate mismatch, repeated-variable equality), alpha match/reject, beta consistent-join vs conflict-reject, end-to-end rules (single-condition fires only the matching fact; multi-condition join fires only on consistent bindings), and a `TestThreeConditionChain` suite (valid three-condition match, third-level conflict suppression, insertion-order independence, `Match.facts` complete and in condition order, multiple left tokens joining one right fact, parity against `Reasoner._match_rule()`, and `reset()` clearing all token memory)
- **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
+20
View File
@@ -0,0 +1,20 @@
cff-version: 1.2.0
message: "If you use this software, please cite it as below."
title: "Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems"
type: software
authors:
- name: "Semantica"
repository-code: "https://github.com/semantica-agi/semantica"
url: "https://getsemantica.ai"
license: MIT
version: 0.6.7
date-released: 2026-08-28
keywords:
- knowledge-graph
- context-graph
- ai-agents
- llm
- decision-intelligence
- provenance
- explainability
- graph-rag
+38 -4
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1
FROM node:26-alpine AS frontend-builder
FROM node:26-alpine@sha256:2d984a15c9b54fd0aeb608b8e0d0d83529eb34d2966db27a1fb4f1edc3d298a3 AS frontend-builder
WORKDIR /app
COPY explorer/package*.json ./explorer/
@@ -9,7 +9,18 @@ RUN npm ci
COPY explorer/ ./
RUN mkdir -p /app/semantica && npm run build
FROM python:3.13-slim AS runtime
# CVE-2026-14456 (OpenSSL QUIC-server DoS, flagged against this base image's
# openssl/libssl3t64/openssl-provider-legacy): the Debian fix
# (3.5.7-1~deb13u2) is only in trixie-proposed-updates as of this writing,
# not yet promoted to trixie-security, so there's no package to pin here
# today. Deliberately NOT running `apt-get upgrade` to chase it - that
# breaks build reproducibility (terrascan AC_DOCKER_0052) and still
# wouldn't reach a proposed-updates-only package. Once Debian ships the fix
# and rebuilds this tag, the docker Dependabot ecosystem in
# .github/dependabot.yml opens a PR bumping the digest pin above. Also: this
# image only serves plain HTTP via uvicorn and never opens a QUIC listener,
# so the bug isn't reachable here regardless.
FROM python:3.13-slim@sha256:7ce4b6dfe35e55397b7cda544f8a13f191b7ae28dc5aad71fe664dbc9bc2623f AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
@@ -22,12 +33,35 @@ WORKDIR /app
RUN groupadd --system semantica \
&& useradd --system --gid semantica --home-dir /app --shell /usr/sbin/nologin semantica
COPY pyproject.toml README.md LICENSE MANIFEST.in ./
COPY pyproject.toml README.md LICENSE MANIFEST.in \
.github/requirements/explorer-extra-py313.txt .github/requirements/pep517-build.txt ./
COPY semantica/ ./semantica/
COPY integrations/ ./integrations/
COPY --from=frontend-builder /app/semantica/static ./semantica/static
RUN pip install --no-cache-dir ".[explorer]" \
# explorer-extra-py313.txt is `uv pip compile pyproject.toml --extra explorer
# --python-version 3.13 --constraint requirements-ci.txt --generate-hashes`
# (see ci.yml's explorer-extra-py311.txt for the CI counterpart, resolved
# for CI's python 3.11 instead - the two aren't interchangeable: audioread
# (via librosa) needs standard-aifc/standard-sunau only on python>=3.13,
# since aifc/sunau left stdlib there, so a 3.11-resolved lockfile is
# missing hashes pip needs on this image's actual 3.13 interpreter and
# --require-hashes fails outright rather than silently under-pinning).
# Every fetched package is hash-verified (Scorecard Pinned-Dependencies)
# and pinned to the same versions CI audited, e.g. msgpack==1.2.1 and
# setuptools==84.0.0 (which also replaces the base image's vulnerable
# 70.3.0, CVE-2025-47273 - nothing else in the tree pulls a newer copy).
# --no-deps on the local package itself: it's our own source tree, not a
# fetch, so there's nothing to hash-pin there - but `pip install .` still
# does a PEP 517 build, which by default creates an *isolated* build env
# and fetches [build-system] requires (setuptools, wheel) completely
# outside any hash checking. pep517-build.txt pins that exact
# build-system.requires; installing it first and passing
# --no-build-isolation makes pip reuse those hash-verified copies instead
# of fetching its own.
RUN pip install --no-cache-dir -r explorer-extra-py313.txt -r pep517-build.txt --require-hashes \
&& pip install --no-cache-dir --no-deps --no-build-isolation . \
&& rm -f explorer-extra-py313.txt pep517-build.txt \
&& chown -R semantica:semantica /app
USER semantica
+131
View File
@@ -0,0 +1,131 @@
# Growth & Distribution Playbook
North star: **10,000 developers who actually use Semantica in real projects**, not a raw PyPI download number. Downloads are a lagging indicator of distribution, not a target to optimize directly.
```
GitHub stars → Website visitors → PyPI installs → Weekly active users → Production deployments → Enterprise customers
```
The last two matter far more than the download count.
## Guardrails — do not do this
- No fake/looping CI jobs that repeatedly `pip install semantica` purely to inflate the graph. It's detectable, it produces zero real users, and it damages credibility with anyone doing diligence (investors, enterprise buyers, security reviewers).
- No package-splitting purely to multiply install counts — only split into `semantica-*` packages when there's a real architectural reason.
- No meaningless Docker pulls or notebook launches with no real content behind them.
- Every item below should get someone from "installed it" to "used it for something real." If a channel can't do that, it's not worth building.
## 30-day priority sprint
Ordered by leverage-to-effort ratio; do these first.
| # | Initiative | Target |
| - | ---------- | ------ |
| 1 | ✅ GitHub Actions example + reusable `setup-semantica` composite action + install-matrix badge | done |
| 2 | Google Colab notebooks | 10 |
| 3 | Docker images (RAG, Graph, Agent, API) | 4-5 |
| 4 | Hugging Face Spaces demos | 3-4 |
| 5 | LangChain integration + example | 1 |
| 6 | LlamaIndex integration + example | 1 |
| 7 | Vector/graph DB integrations (Qdrant, Weaviate, Neo4j) | 3 |
| 8 | MCP server + example | 1 (already have `mcp/` — package as a distributable example) |
| 9 | Production-quality starter repos (FastAPI, Streamlit, Gradio) | 3 |
| 10 | `awesome-rag` / `awesome-llm` / `awesome-knowledge-graph` list submissions | 3+ PRs |
Push everything through: GitHub → Discord (`sV34vps5hH`) → X (`@BuildSemantica`) → GitHub Discussions → Reddit → Hacker News → relevant newsletters.
## Full channel checklist
### CI/CD (highest-intent distribution — installs tied to real pipelines)
- [x] GitHub Actions example in `examples/ci/github-actions.yml`
- [x] Reusable composite GitHub Action — [`.github/actions/setup-semantica`](.github/actions/setup-semantica/action.yml), modeled on `actions/setup-python`; usable by any repo as `uses: semantica-agi/semantica/.github/actions/setup-semantica@main`
- [x] "pip install" status badge in the README, backed by [`.github/workflows/install-matrix.yml`](.github/workflows/install-matrix.yml) — verifies the *published* package installs cleanly on Ubuntu/macOS/Windows across Python 3.9-3.12, weekly + on every release
- [x] GitLab CI template — `examples/ci/gitlab-ci.yml`
- [x] CircleCI template — `examples/ci/circleci-config.yml`
- [ ] Jenkins, Azure DevOps, Bitbucket Pipelines, Buildkite, Travis CI equivalents
### Release pipeline hardening (already had Trusted Publishing/OIDC + SLSA attestation — this rounds it out to match top-tier OSS release practice)
- [x] `twine check` gate in `.github/workflows/release.yml` before publish — catches a broken PyPI long-description render before it goes live instead of after (a malformed README on the live PyPI page is a silent conversion killer)
- [x] `CITATION.cff` (see Academic & research below)
- [x] OpenSSF Scorecard (see Discoverability below)
- [ ] Considered and deliberately skipped: Release Drafter / auto-generated changelogs — this repo hand-curates `CHANGELOG.md` with far more detail (PR numbers, contributors, phase-1 limitations) than a bot would produce. Don't introduce this without checking with maintainers first.
- [ ] Renovate / Dependabot config templates that auto-bump the `semantica` version in downstream repos — real recurring CI runs on real adopters
- [ ] Nightly scheduled workflow template that tests a downstream project against `semantica@latest`
### Containers & dev environments
- [ ] Official Docker images: RAG, Graph, Agent, API, `+Postgres`, `+Neo4j`, `+Qdrant`
- [ ] `docker-compose` examples (repo already has `docker-compose.dev.yml` / `docker-compose.yml` as a base)
- [ ] `.devcontainer/devcontainer.json` for one-click "Reopen in Container"
- [ ] GitHub Codespaces-ready config
- [ ] Gitpod config
- [ ] "Use this template" GitHub repo button so new projects start with `semantica` in `requirements.txt`
### Notebooks & hosted demos
- [ ] 10-20 Google Colab notebooks (Graph RAG, agent memory, entity resolution, semantic search, document intelligence)
- [ ] Kaggle Notebooks/Kernels
- [ ] Binder / mybinder.org config for instant repo launch
- [ ] SageMaker Studio Lab / Databricks Community Edition / Paperspace Gradient examples
- [ ] Hugging Face Spaces (Streamlit/Gradio) demos with `semantica` in `requirements.txt`
- [ ] Public hosted playground (source on GitHub, install visible)
### Framework & data-store integrations
- [x] LangChain integration — `integrations/langchain/` (`SemanticaRetriever`, `SemanticaVectorStore`, `SemanticaKGTool`/`SemanticaDecisionTool`), `pip install semantica[langchain]`, shipped in 0.6.7
- [ ] LlamaIndex integration + example
- [ ] LangGraph example
- [ ] Neo4j integration/example (docs already list it as a supported graph store — turn into a runnable example repo)
- [ ] Vector DB examples: Qdrant, Weaviate, Milvus, Pinecone, Chroma, FAISS, pgvector, OpenSearch/Elasticsearch (FAISS/Pinecone/Weaviate/Qdrant/Milvus/PgVector already supported per `docs/community-projects.md` — package each as a standalone example)
- [ ] LLM provider quickstarts: OpenAI, Anthropic, Gemini, Groq, Ollama, HuggingFace, DeepSeek, LiteLLM (already-supported providers per docs — each gets its own copy-paste quickstart)
- [ ] CrewAI / Agno integration examples (already documented under `docs/integrations/`) — promote as standalone repos, not just docs pages
### Package managers & installers
- [ ] conda-forge feedstock
- [ ] Homebrew formula for the CLI
- [ ] Nix/nixpkgs packaging
- [ ] Chocolatey / Scoop (Windows)
- [ ] Document `uv add semantica` and `poetry add semantica` explicitly alongside `pip install`
### Downstream packages & CLI
- [ ] Genuinely useful `semantica-*` packages only where warranted (e.g. `semantica-rag`, `semantica-connectors`) — each pulls `semantica` as a real dependency
- [ ] Make sure `semantica init / ingest / index / query / serve` CLI flows are the default onboarding path in every tutorial
- [ ] VS Code extension wrapping the CLI (scaffold + run commands from the command palette)
- [ ] JetBrains plugin equivalent
### Templates & starters
- [ ] Cookiecutter templates: `cookiecutter-semantic-rag`, `cookiecutter-ai-agent`, `cookiecutter-enterprise-rag`
- [ ] Starter repos: FastAPI, Streamlit, Gradio, Next.js frontend + Semantica backend
- [ ] Cloud deploy templates: AWS, GCP, Azure, Modal, Railway, Render, Fly.io (repo already has `deploy/azure`, `deploy/gcp`, `deploy/fly`, `deploy/railway`, `deploy/render`, `deploy/kubernetes`, `deploy/helm` — link these prominently from the README/quickstart, they're already-built distribution surface)
- [ ] Terraform / Pulumi / Helm modules published to their respective registries
### Discoverability & curation
- [ ] Submit to `awesome-rag`, `awesome-llm`, `awesome-knowledge-graph`, `awesome-python`
- [ ] Pitch newsletters with engaged Python/AI audiences (Python Weekly, Import AI, TLDR AI, etc.)
- [x] PyPI trove classifiers/keywords and `project.urls` (Homepage/Docs/Repository/Changelog/Bug Tracker) — already complete in `pyproject.toml`
- [ ] Get listed on Papers With Code for any retrieval/graph-RAG benchmark work
- [x] [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/semantica-agi/semantica) badge + weekly workflow (`.github/workflows/scorecard.yml`) — a concrete trust signal security/procurement teams check before greenlighting adoption, which gates real (non-CI-bot) install growth at enterprises
### Academic & research
- [x] `CITATION.cff` at repo root — enables GitHub's native "Cite this repository" button, feeds Google Scholar/academic tooling; complements `docs/citation.md` (still needs a real Zenodo DOI to replace the `XXXXXXX` placeholder in both places once one is minted)
- [ ] arXiv paper if there's real architectural novelty to describe
- [ ] Zenodo DOI for citability (`docs/citation.md` already exists — make sure it points to a real DOI)
- [ ] Workshop/tutorial sessions at PyData/ODSC-style events with hands-on install steps
- [ ] University course material / bootcamp adoption outreach
### Content
- [ ] Reproducible benchmark repos (Graph RAG vs vector RAG, retrieval@k, enterprise-scale retrieval) with `pip install semantica && python benchmark.py`
- [ ] 20-30 real-world example applications (RAG, enterprise document intelligence, financial entity graphs, code knowledge graphs, research discovery, agent memory)
- [ ] Blog/tutorial posts on Dev.to, Medium, personal blogs — always with runnable code, not just prose
- [ ] Contribute integrations/PRs to other projects building RAG/agents/knowledge graphs — "I implemented Semantica support" beats "please use Semantica"
## Tracking
Don't just watch the raw PyPI number — use download analytics (e.g. PePy) to separate CI/bot traffic from real installs, and track the funnel above end-to-end where possible (stars → site visits → installs → weekly actives).
+19 -7
View File
@@ -18,7 +18,7 @@
> Ingest your enterprise data, extract what matters, build a Context Graph and knowledge graph (KG), and run graph analytics and causal reasoning over all of it, with full decision provenance baked in. Explainable, traceable, and trustworthy by design.
**Decision Intelligence &nbsp;·&nbsp; Context Management &nbsp;·&nbsp; Deterministic Reasoning &nbsp;·&nbsp; Ontology Management &nbsp;·&nbsp; Knowledge Modeling &nbsp;·&nbsp; End-to-End Traceability**
**Context Management &nbsp;·&nbsp; Knowledge Modeling &nbsp;·&nbsp; Deterministic Reasoning &nbsp;·&nbsp; Ontology Management &nbsp;·&nbsp; Decision Intelligence &nbsp;·&nbsp; End-to-End Traceability**
**Open Source &nbsp;·&nbsp; Self-Hostable &nbsp;·&nbsp; Auditable &nbsp;·&nbsp; Governed &nbsp;·&nbsp; Zero Vendor Lock-In**
@@ -26,7 +26,7 @@
#### Built for High-Stakes, Regulated Domains
[![GitHub Stars](https://img.shields.io/github/stars/semantica-agi/semantica?style=flat-square&color=FFD700&logo=github&logoColor=white&label=Stars)](https://github.com/semantica-agi/semantica) [![GitHub Forks](https://img.shields.io/github/forks/semantica-agi/semantica?style=flat-square&color=6E40C9&logo=github&logoColor=white&label=Forks)](https://github.com/semantica-agi/semantica/network/members) [![Contributors](https://img.shields.io/github/contributors/semantica-agi/semantica?style=flat-square&color=2EA043&logo=github&logoColor=white)](https://github.com/semantica-agi/semantica/graphs/contributors) [![PyPI](https://img.shields.io/pypi/v/semantica.svg?style=flat-square&color=0066CC&logo=pypi&logoColor=white)](https://pypi.org/project/semantica/) [![Total Downloads](https://static.pepy.tech/badge/semantica?style=flat-square)](https://pepy.tech/project/semantica) [![Python 3.8+](https://img.shields.io/badge/python-3.8+-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) [![CI](https://img.shields.io/github/actions/workflow/status/semantica-agi/semantica/ci.yml?style=flat-square&label=CI)](https://github.com/semantica-agi/semantica/actions) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/semantica-agi/semantica)
[![GitHub Stars](https://img.shields.io/github/stars/semantica-agi/semantica?style=flat-square&color=FFD700&logo=github&logoColor=white&label=Stars)](https://github.com/semantica-agi/semantica) [![GitHub Forks](https://img.shields.io/github/forks/semantica-agi/semantica?style=flat-square&color=6E40C9&logo=github&logoColor=white&label=Forks)](https://github.com/semantica-agi/semantica/network/members) [![Contributors](https://img.shields.io/github/contributors/semantica-agi/semantica?style=flat-square&color=2EA043&logo=github&logoColor=white)](https://github.com/semantica-agi/semantica/graphs/contributors) [![PyPI](https://img.shields.io/pypi/v/semantica.svg?style=flat-square&color=0066CC&logo=pypi&logoColor=white)](https://pypi.org/project/semantica/) [![Total Downloads](https://static.pepy.tech/badge/semantica?style=flat-square)](https://pepy.tech/project/semantica) [![Python 3.8+](https://img.shields.io/badge/python-3.8+-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) [![CI](https://img.shields.io/github/actions/workflow/status/semantica-agi/semantica/ci.yml?style=flat-square&label=CI)](https://github.com/semantica-agi/semantica/actions) [![Install Matrix](https://img.shields.io/github/actions/workflow/status/semantica-agi/semantica/install-matrix.yml?style=flat-square&label=pip%20install)](https://github.com/semantica-agi/semantica/actions/workflows/install-matrix.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/semantica-agi/semantica/badge?style=flat-square)](https://scorecard.dev/viewer/?uri=github.com/semantica-agi/semantica) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/semantica-agi/semantica)
[![Website](https://img.shields.io/badge/Website-getsemantica.ai-000000?style=flat-square&logo=googlechrome&logoColor=white)](https://getsemantica.ai/) [![Docs](https://img.shields.io/badge/Docs-docs.getsemantica.ai-0099FF?style=flat-square&logo=readthedocs&logoColor=white)](https://docs.getsemantica.ai/) [![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/sV34vps5hH) [![Twitter/X](https://img.shields.io/badge/Follow-%40BuildSemantica-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/BuildSemantica) [![YouTube](https://img.shields.io/badge/YouTube-Watch%20Demos-FF0000?style=flat-square&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=QfnNZg4-dZA) [![Changelog](https://img.shields.io/badge/Changelog-View-6E40C9?style=flat-square&logo=keepachangelog&logoColor=white)](CHANGELOG.md)
@@ -56,9 +56,7 @@ pip install semantica
---
Most AI agents act without a trail. They store embeddings, not meaning: context that can't be explained, decisions that can't be audited. In lending, that gap is a compliance exposure, not an inconvenience: an underwriting agent's approval has to survive a regulator's "why" months later.
Semantica sits underneath your LLM, vector store, and agent framework as a deterministic infrastructure layer: no LLM required for graph construction, reasoning, or provenance.
Most AI agents run on embeddings, not meaning: similarity scores with no structure, no relationships, and no way to explain why a result came back. Semantica is the semantic/context layer underneath your LLM, vector store, and agent framework: a deterministic infrastructure layer (no LLM required for graph construction, reasoning, or provenance) that turns fragmented enterprise data into a structured, queryable Context Graph and knowledge graph, governed by ontologies and controlled vocabularies (OWL, SHACL, SKOS) so the meaning of your data is explicit, not just its embedding. Decision provenance and audit trails fall out of that structure as a property, not the product itself; in domains a regulator can question, that same structure just happens to double as a straight answer to "why."
> ⚠️ **System-level explainability, not foundation-model explainability.** Semantica does not expose or reconstruct what happens *inside* the LLM — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. Semantica explains what's *outside* the model: the context and data fed in, the decision produced, its provenance, relevant relationships, applied policies, and the full execution trail.
@@ -279,7 +277,7 @@ retrieved = ctx.retrieve("who approved the Acme contract?")
## Recipe: Audit Trail for a Regulated Decision
The flagship pattern: record a causally-linked decision chain, attach provenance to every entity, and export a regulator-ready audit trail.
One pattern built on the same Context Graph: record a causally-linked decision chain, attach provenance to every entity, and export a regulator-ready audit trail.
```python
from semantica.context import ContextGraph
@@ -1030,7 +1028,7 @@ team = Team(agents=[researcher, analyst], mode="coordinate")
## More Recipes
The flagship audit-trail recipe is [above](#recipe-audit-trail-for-a-regulated-decision). Here are three more common patterns.
The audit-trail recipe is [above](#recipe-audit-trail-for-a-regulated-decision). Here are three more common patterns.
<details>
<summary><b>End-to-End GraphRAG Pipeline</b></summary>
@@ -1534,6 +1532,20 @@ git clone https://github.com/semantica-agi/semantica.git
cd semantica && pip install -e ".[dev]" && pytest tests/
```
### CI & Deployment
Wiring `semantica` into your own CI is a two-minute job. On GitHub Actions, use the reusable composite action:
```yaml
- uses: semantica-agi/semantica/.github/actions/setup-semantica@main
with:
python-version: '3.11'
```
Copy-paste starting templates for GitHub Actions, GitLab CI, and CircleCI live in [examples/ci/](examples/ci/). The published package itself is verified installable across Ubuntu/macOS/Windows and Python 3.9-3.12 every week by the [Install Matrix workflow](.github/workflows/install-matrix.yml).
Ready-made deployment configs for AWS, GCP, Azure, Fly.io, Railway, Render, Kubernetes, and Helm are in [deploy/](deploy/).
---
## Enterprise
@@ -10,15 +10,16 @@
"\n",
"## Overview\n",
"\n",
"This notebook demonstrates how to build knowledge graphs from entities and relationships using Semantica's graph building modules. You'll learn to use `GraphBuilder` and `EntityResolver`.\n",
"This notebook demonstrates how to build knowledge graphs from extracted entities and relationships using Semantica's graph building modules. You'll learn to use `GraphBuilder` and `EntityResolver`.\n",
"\n",
"**Documentation**: [API Reference](https://semantica.readthedocs.io/reference/kg/)\n",
"\n",
"### Learning Objectives\n",
"\n",
"- Use `GraphBuilder` to construct knowledge graphs\n",
"- Use `EntityResolver` to resolve entity conflicts\n",
"**Note**: For deduplication, use the `semantica.deduplication` module.\n",
"- Extract entity mentions and relations, and map them into graph records\n",
"- Use `GraphBuilder` to construct a graph whose edges come from the actual extracted relations\n",
"- Use `EntityResolver` to merge duplicate mentions and remap relationship endpoints\n",
"- Use the `semantica.deduplication` module and report the complete deduplicated entity set\n",
"\n",
"## Installation\n",
"\n",
@@ -32,120 +33,217 @@
"\n",
"---\n",
"\n",
"## Step 1: Build Knowledge Graph\n",
"## Step 1: Extract Entities and Relations\n",
"\n",
"Construct a knowledge graph from entities and relationships.\n"
"Extract entity mentions and relations from text. The sample text mentions `Apple Inc.` in two separate sentences, so we can later show how duplicate mentions are resolved into one canonical entity.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!pip install semantica\n"
]
"%pip install semantica\n",
"\n",
"# spaCy models are distributed separately from the spaCy library. This lesson\n",
"# relies on the English model to recognize standalone places such as Cupertino.\n",
"import sys\n",
"import subprocess\n",
"import spacy\n",
"\n",
"try:\n",
" spacy.load(\"en_core_web_sm\")\n",
"except OSError:\n",
" subprocess.check_call([sys.executable, \"-m\", \"spacy\", \"download\", \"en_core_web_sm\"])\n"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.kg import GraphBuilder\n",
"from semantica.semantic_extract import NERExtractor, RelationExtractor\n",
"\n",
"builder = GraphBuilder()\n",
"text = (\n",
" \"Apple Inc. is headquartered in Cupertino, California. \"\n",
" \"Tim Cook is the CEO of Apple Inc. \"\n",
" \"The company is a technology company.\"\n",
")\n",
"\n",
"ner_extractor = NERExtractor()\n",
"relation_extractor = RelationExtractor()\n",
"\n",
"text = \"Apple Inc. is a technology company. Tim Cook is the CEO of Apple Inc. Apple Inc. is headquartered in Cupertino, California.\"\n",
"mentions = ner_extractor.extract(text)\n",
"relations = relation_extractor.extract(text, mentions)\n",
"\n",
"entities_list = ner_extractor.extract(text)\n",
"relationships_list = relation_extractor.extract(text, entities_list)\n",
"print(\"Entity mentions:\")\n",
"for mention in mentions:\n",
" print(f\" {mention.text!r:<13} {mention.label:<7} span=[{mention.start_char}:{mention.end_char}]\")\n",
"\n",
"entities = []\n",
"for i, entity in enumerate(entities_list[:5], 1):\n",
" entities.append({\n",
" \"id\": f\"e{i}\",\n",
" \"type\": entity.label,\n",
" \"name\": entity.text,\n",
" \"properties\": {}\n",
" })\n",
"\n",
"relationships = []\n",
"for i, rel in enumerate(relationships_list[:3], 1):\n",
" relationships.append({\n",
" \"source\": f\"e{1}\",\n",
" \"target\": f\"e{i+1}\",\n",
" \"type\": rel.predicate,\n",
" \"properties\": {}\n",
" })\n",
"\n",
"knowledge_graph = builder.build(entities, relationships)\n",
"\n",
"print(f\"Built knowledge graph with {len(knowledge_graph.get('entities', []))} entities\")\n",
"print(f\"Relationships: {len(knowledge_graph.get('relationships', []))}\")"
]
"print(\"\\nExtracted relations:\")\n",
"for rel in relations:\n",
" print(f\" {rel.subject.text!r} --{rel.predicate}--> {rel.object.text!r}\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 2: Entity Resolution\n",
"## Step 2: Build the Knowledge Graph\n",
"\n",
"Resolve entity conflicts and duplicates.\n"
"Give every mention a graph ID, then translate each relation's `subject` and `object` into those IDs. Building edges from the actual relation endpoints — rather than guessing endpoints from list positions — is what keeps the graph faithful to the text.\n"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"from semantica.kg import GraphBuilder\n",
"\n",
"entities = []\n",
"span_to_id = {}\n",
"for i, mention in enumerate(mentions, 1):\n",
" graph_id = f\"e{i}\"\n",
" span_to_id[(mention.start_char, mention.end_char)] = graph_id\n",
" entities.append({\n",
" \"id\": graph_id,\n",
" \"type\": mention.label,\n",
" \"name\": mention.text,\n",
" \"properties\": {},\n",
" })\n",
"\n",
"relationships = []\n",
"for rel in relations:\n",
" source_id = span_to_id.get((rel.subject.start_char, rel.subject.end_char))\n",
" target_id = span_to_id.get((rel.object.start_char, rel.object.end_char))\n",
" if source_id is None or target_id is None:\n",
" print(f\"Skipping relation with unmapped endpoint: \"\n",
" f\"{rel.subject.text!r} --{rel.predicate}--> {rel.object.text!r}\")\n",
" continue\n",
" relationships.append({\n",
" \"source\": source_id,\n",
" \"target\": target_id,\n",
" \"type\": rel.predicate,\n",
" \"properties\": {},\n",
" })\n",
"\n",
"builder = GraphBuilder()\n",
"knowledge_graph = builder.build({\"entities\": entities, \"relationships\": relationships})\n",
"\n",
"id_to_name = {entity[\"id\"]: entity[\"name\"] for entity in entities}\n",
"\n",
"print(f\"Graph entities ({len(knowledge_graph['entities'])}):\")\n",
"for entity in knowledge_graph[\"entities\"]:\n",
" print(f\" {entity['id']}: {entity['name']} ({entity['type']})\")\n",
"\n",
"print(f\"\\nGraph relationships ({len(knowledge_graph['relationships'])}):\")\n",
"for relationship in knowledge_graph[\"relationships\"]:\n",
" print(f\" {id_to_name[relationship['source']]} \"\n",
" f\"--{relationship['type']}--> {id_to_name[relationship['target']]}\")\n",
"\n",
"edges = {\n",
" (id_to_name[r[\"source\"]], r[\"type\"], id_to_name[r[\"target\"]])\n",
" for r in knowledge_graph[\"relationships\"]\n",
"}\n",
"assert (\"Apple Inc.\", \"located_in\", \"Cupertino\") in edges\n",
"assert (\"Tim Cook\", \"works_for\", \"Apple Inc.\") in edges"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 3: Entity Resolution\n",
"\n",
"The graph currently contains two nodes for the same organization. `EntityResolver` merges duplicate mentions into one canonical entity and records which source IDs were merged (`merged_from`), so relationship endpoints can be remapped onto the canonical entity.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.kg import EntityResolver\n",
"\n",
"entity_resolver = EntityResolver()\n",
"\n",
"resolved_entities = entity_resolver.resolve_entities(entities)\n",
"\n",
"print(f\"Original entities: {len(entities)}\")\n",
"print(f\"Resolved entities: {len(resolved_entities)}\")"
]
"canonical_id = {}\n",
"for entity in resolved_entities:\n",
" for source_id in entity.get(\"merged_from\", [entity[\"id\"]]):\n",
" canonical_id[source_id] = entity[\"id\"]\n",
" if entity.get(\"merged_from\"):\n",
" print(f\"Merged {entity['merged_from']} -> {entity['id']}: {entity['name']}\")\n",
"\n",
"print(f\"\\nMentions in: {len(entities)}, resolved entities out: {len(resolved_entities)}\")\n",
"\n",
"resolved_names = {entity[\"id\"]: entity[\"name\"] for entity in resolved_entities}\n",
"print(\"\\nRelationships remapped onto canonical entities:\")\n",
"for relationship in relationships:\n",
" source = canonical_id[relationship[\"source\"]]\n",
" target = canonical_id[relationship[\"target\"]]\n",
" print(f\" {resolved_names[source]} --{relationship['type']}--> {resolved_names[target]}\")\n",
"\n",
"canonical_entities = {(entity[\"name\"], entity[\"type\"]) for entity in resolved_entities}\n",
"assert canonical_entities == {\n",
" (\"Apple Inc.\", \"ORG\"),\n",
" (\"Tim Cook\", \"PERSON\"),\n",
" (\"Cupertino\", \"GPE\"),\n",
" (\"California\", \"GPE\"),\n",
"}\n",
"assert len(resolved_entities) == 4"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 3: Deduplication\n",
"## Step 4: Deduplication\n",
"\n",
"Remove duplicate entities from the graph.\n"
"The `semantica.deduplication` module gives finer control over the same problem. Note that `merge_duplicates` returns one `MergeOperation` per duplicate *group* — the complete deduplicated collection is those merged entities plus every entity that was not part of any group.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.deduplication import DuplicateDetector, EntityMerger, MergeStrategy\n",
"\n",
"# Detect duplicates\n",
"detector = DuplicateDetector(similarity_threshold=0.8)\n",
"duplicate_groups = detector.detect_duplicate_groups(knowledge_graph.get('entities', []))\n",
"duplicate_groups = detector.detect_duplicate_groups(entities)\n",
"print(f\"Duplicate groups: {len(duplicate_groups)}\")\n",
"for group in duplicate_groups:\n",
" print(f\" {[entity['name'] for entity in group.entities]} \"\n",
" f\"(confidence={group.confidence:.2f})\")\n",
"\n",
"# Merge duplicates\n",
"merger = EntityMerger()\n",
"merge_operations = merger.merge_duplicates(\n",
" knowledge_graph.get('entities', []),\n",
" strategy=MergeStrategy.KEEP_MOST_COMPLETE\n",
" entities, strategy=MergeStrategy.KEEP_MOST_COMPLETE\n",
")\n",
"\n",
"deduplicated_entities = [op.merged_entity for op in merge_operations]\n",
"merged_source_ids = {\n",
" entity[\"id\"] for op in merge_operations for entity in op.source_entities\n",
"}\n",
"untouched_entities = [e for e in entities if e[\"id\"] not in merged_source_ids]\n",
"deduplicated_entities = untouched_entities + [\n",
" op.merged_entity for op in merge_operations\n",
"]\n",
"\n",
"print(f\"Original entities: {len(knowledge_graph.get('entities', []))}\")\n",
"print(f\"Deduplicated entities: {len(deduplicated_entities)}\")\n"
]
"print(f\"\\nMerge operations: {len(merge_operations)}\")\n",
"print(f\"Deduplicated entities ({len(deduplicated_entities)}):\")\n",
"for entity in deduplicated_entities:\n",
" print(f\" {entity['id']}: {entity['name']} ({entity['type']})\")\n",
"\n",
"assert len(merge_operations) == 1\n",
"assert len(deduplicated_entities) == 4"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
@@ -155,9 +253,10 @@
"\n",
"You've learned how to build knowledge graphs:\n",
"\n",
"- **GraphBuilder**: Construct knowledge graphs from entities and relationships\n",
"- **EntityResolver**: Resolve entity conflicts and duplicates\n",
"- **Deduplication**: Use `semantica.deduplication` module for removing duplicate entities\n",
"- **Extraction to graph**: map each mention to a graph ID and build edges from the actual `Relation.subject` / `Relation.object` endpoints\n",
"- **GraphBuilder**: construct knowledge graphs from explicit `{\"entities\": ..., \"relationships\": ...}` input\n",
"- **EntityResolver**: merge duplicate mentions into canonical entities and remap relationship endpoints\n",
"- **Deduplication**: combine `MergeOperation` results with untouched entities to get the complete deduplicated set\n",
"\n",
"Next: Learn how to analyze graphs in the Graph_Analytics notebook.\n"
]
+152 -24
View File
@@ -28,6 +28,7 @@ The `semantica.llms` module provides a unified interface for connecting to Large
## When To Use / When Not To Use
**Use LLM integrations for:**
- Text generation, summarization, and question-answering tasks
- Complex reasoning that requires natural language understanding
- Structured data extraction from unstructured text
@@ -35,6 +36,7 @@ The `semantica.llms` module provides a unified interface for connecting to Large
- Tasks where context, ambiguity, or domain knowledge matter
**Deterministic tools may be better for:**
- Pattern matching that regular expressions can handle
- Simple rule-based classification with clear criteria
- Mathematical calculations or statistical analysis
@@ -42,6 +44,7 @@ The `semantica.llms` module provides a unified interface for connecting to Large
- Data transformations with known logic
**A full LLM may be unnecessary for:**
- Simple keyword search or exact string matching
- Deterministic workflows with predefined decision trees
- High-frequency, low-latency operations where inference overhead matters
@@ -59,7 +62,7 @@ Four factors drive provider selection, each optimized for different use cases:
**Accuracy** matters most in high-stakes decisions: clinical contraindication checks, credit committee reasoning, and legal document analysis. Frontier models like Claude or GPT-4 available through `LiteLLM` provide the strongest reasoning capabilities.
**Data residency** constraints eliminate cloud providers for classified or HIPAA-regulated workloads. `HuggingFaceLLM` with local model paths enables fully air-gapped deployments without network calls.
**Data residency** constraints eliminate cloud providers for classified or HIPAA-regulated workloads. `HuggingFaceLLM` with local model paths, or `Ollama` pointed at a local server, both enable fully air-gapped deployments without network calls.
**Cost at scale** favors high-throughput providers like Novita AI for bulk extraction pipelines processing thousands of documents per hour where per-token costs accumulate quickly.
@@ -143,6 +146,131 @@ risk_data = oai.generate_structured(
The default model `gpt-3.5-turbo` is fine for classification and light extraction. Switch to `gpt-4o` for complex multi-step regulatory reasoning or document understanding.
## Anthropic — Complex Reasoning and Structured Extraction
**Anthropic** provides the Claude model family, built with an emphasis on careful, instruction-following behavior and strong performance on multi-step reasoning, long-document analysis, and code-related tasks. Claude models tend to be more cautious about ambiguous instructions than other providers. That matters when the cost of a confidently wrong answer is high.
The `Anthropic` provider wraps the Claude API. Reach for it when the task involves reasoning through several dependent steps (not just single-turn extraction), when you're processing long source documents that need to stay in context, or when you need schema-validated structured output rather than best-effort JSON.
Install with `pip install "semantica[llm-anthropic]"` (or just `pip install anthropic`) before using this provider.
```python
from semantica.llms import Anthropic
claude = Anthropic(model="claude-sonnet-4-6", api_key="YOUR_ANTHROPIC_KEY")
# api_key falls back to the ANTHROPIC_API_KEY environment variable
# is_available() only confirms a client was constructed from some key.
# It does not validate the key or check network reachability - an
# invalid or expired key still passes this check and fails at generate().
if not claude.is_available():
raise RuntimeError("Anthropic provider not configured - set ANTHROPIC_API_KEY")
# Plain generation - multi-step reasoning over a contract clause
verdict = claude.generate(
"A vendor contract has a 30-day termination-for-convenience clause "
"but a 90-day data-return obligation that survives termination. "
"If the customer terminates on day 1, when must vendor-held data "
"be returned? Answer with the date basis only.",
temperature=0.1,
)
print(verdict)
# "Day 120 from termination notice. The 90-day return period runs from
# the termination date (day 30), not from the notice date."
# Structured, schema-validated output
from pydantic import BaseModel
class ContractRisk(BaseModel):
clause: str
risk_level: str
days_to_deadline: int
risk = claude.generate_typed(
"Extract the termination clause risk from: vendor contract, "
"30-day termination for convenience, 90-day post-termination "
"data return obligation.",
schema=ContractRisk,
)
print(risk.risk_level, risk.days_to_deadline)
# "medium" 90
```
Model selection follows the same tier structure as the other providers: a Haiku model for high-volume classification where cost matters more than depth, a Sonnet model as the default for most extraction and reasoning tasks, an Opus model when a task genuinely needs the deepest reasoning available and latency/cost are secondary. Check Anthropic's docs for the current model identifiers, since they're versioned and change over time.
## Gemini — Long Context and Multimodal Input
**Gemini** is Google's model family, with a context window large enough to hold entire codebases or long regulatory filings in a single call, and native support for image and document input alongside text. Reach for it when a task needs to reference a large amount of source material at once, or when the input isn't plain text.
The `Gemini` provider tries the newer `google-genai` SDK first and falls back to the older `google-generativeai` package if that's what's installed. Install with `pip install "semantica[llm-gemini]"` (or `pip install google-genai`) before using this provider.
```python
from semantica.llms import Gemini
gemini = Gemini(model="gemini-pro", api_key="YOUR_GEMINI_KEY")
# api_key falls back to the GEMINI_API_KEY environment variable
if not gemini.is_available():
raise RuntimeError("Gemini provider not configured - set GEMINI_API_KEY")
response = gemini.generate(
"Summarize the key obligations in a standard NDA in three bullet points."
)
print(response)
data = gemini.generate_structured(
"Extract the party names and effective date from: "
"This Agreement is entered into between Acme Corp and Globex LLC, "
"effective January 1, 2026."
)
print(data)
```
## Ollama — Local, Air-Gapped Inference
**Ollama** runs models entirely on your own machine, with no API key and no outbound network call. It's the right choice for air-gapped environments, offline development, or any workload where the source data can't leave the local network.
Unlike the other providers here, `Ollama` takes a `base_url` instead of an `api_key`. It talks to a local Ollama server over HTTP. Start the server with `ollama serve` and pull a model with `ollama pull llama2` before using this provider. Install the Python client with `pip install "semantica[llm-ollama]"` (or `pip install ollama`).
```python
from semantica.llms import Ollama
llm = Ollama(model="llama2", base_url="http://localhost:11434")
if not llm.is_available():
raise RuntimeError("Ollama provider not configured - is 'ollama serve' running?")
response = llm.generate("Explain the difference between a hash map and a tree map.")
print(response)
```
`is_available()` for Ollama does a real connectivity check (it calls the server's `list()` endpoint), unlike the API-key-based providers above, so a `False` here usually means the server isn't running rather than a missing credential.
## DeepSeek — Budget Reasoning at Scale
**DeepSeek** exposes an OpenAI-compatible API at a fraction of the cost of the larger US providers, with reasoning quality that holds up well for extraction and classification work. It's a reasonable default when you're processing a large volume of documents and don't need the deepest reasoning tier.
Install with `pip install "semantica[llm-deepseek]"` (or `pip install openai`, since DeepSeek is accessed through the OpenAI client pointed at a different base URL).
```python
from semantica.llms import DeepSeek
llm = DeepSeek(model="deepseek-chat", api_key="YOUR_DEEPSEEK_KEY")
# api_key falls back to the DEEPSEEK_API_KEY environment variable
if not llm.is_available():
raise RuntimeError("DeepSeek provider not configured - set DEEPSEEK_API_KEY")
response = llm.generate("List three risks of using a floating IP in a Kubernetes ingress.")
print(response)
data = llm.generate_structured(
"Extract the CVE ID and affected product from: "
"CVE-2024-3400 affects PAN-OS GlobalProtect gateways."
)
print(data)
```
## LiteLLM — One Interface, 100+ Providers
**LiteLLM** is a universal adapter that provides a single interface to over 100 different LLM providers, including Anthropic Claude, Azure OpenAI, AWS Bedrock, Google Vertex AI, and local Ollama instances. It acts as a translation layer, converting your unified API calls into provider-specific requests, enabling easy switching between providers without code changes.
@@ -306,30 +434,32 @@ for t in triplets:
## Novita AI — Cost-Efficient Bulk Extraction
Novita AI exposes an OpenAI-compatible API and is available as a built-in provider for the extraction layer. It is accessed differently from the `semantica.llms` classes — through `create_provider` from `semantica.semantic_extract.providers` — making it the right choice for high-volume NER pipelines where per-call cost matters.
**Novita AI** exposes an OpenAI-compatible API at low per-call cost, making it a reasonable choice for high-volume NER pipelines where cost matters more than getting the single best answer.
Install with `pip install "semantica[llm-novita]"` (or `pip install openai`, since Novita is accessed through the OpenAI client pointed at a different base URL).
```python
from semantica.llms import Novita
llm = Novita(model="deepseek/deepseek-v3.2", api_key="YOUR_NOVITA_KEY")
# api_key falls back to the NOVITA_API_KEY environment variable
if not llm.is_available():
raise RuntimeError("Novita provider not configured - set NOVITA_API_KEY")
response = llm.generate("Summarize the Basel III leverage ratio requirement.")
data = llm.generate_structured(
"Extract drug names and dosages from: "
"Patient received warfarin 5mg daily, aspirin 75mg daily, metformin 500mg twice daily."
)
```
Novita is also reachable as a provider name string for the NER interface, without going through the `Novita` class directly:
```python
from semantica.semantic_extract.providers import create_provider
from semantica.semantic_extract import NamedEntityRecognizer
# create_provider pools instances — same key reuses the same object
provider = create_provider(
"novita",
api_key="YOUR_NOVITA_KEY", # or set NOVITA_API_KEY env var
model="deepseek/deepseek-v3.2", # default model
)
if provider.is_available():
# Plain generation
response = provider.generate("Summarise the Basel III leverage ratio requirement.")
# Structured extraction — returns parsed dict
data = provider.generate_structured(
"Extract drug names and dosages from: "
"Patient received warfarin 5mg daily, aspirin 75mg daily, metformin 500mg twice daily."
)
# Use Novita through the NER interface — provider name as string
ner = NamedEntityRecognizer(
methods=["llm"],
provider="novita",
@@ -339,11 +469,9 @@ entities = ner.extract_entities(
"CVE-2024-3400 is exploited by UNC3886 targeting PAN-OS GlobalProtect."
)
for e in entities:
print("{} ({}) conf={:.2f}".format(e.text, e.label, e.confidence))
print("{} ({}) conf={:.2f}".format(e.text, e.label, e.confidence))
```
Novita requires the `openai` Python client under the hood — install with `pip install "semantica[llm-openai]"` or `pip install openai`.
## Domain Examples
<Tabs>
+3 -2
View File
@@ -327,10 +327,11 @@ print(f"Relationships active in 2023: {result_2023['num_relationships']}")
<Accordion title="Persistent graph store: Neo4j, FalkorDB, Apache AGE" icon="database">
```python
from semantica.graph_store import Neo4jStore
from semantica.graph_store import GraphStore
from semantica.kg import GraphBuilder
store = Neo4jStore(
store = GraphStore(
backend="neo4j",
uri="bolt://localhost:7687",
user="neo4j",
password="password",
+95
View File
@@ -25,6 +25,7 @@ icon: "brain"
| `DecisionRecorder` | Record decisions with embeddings, causal chains, and metadata |
| `PolicyEngine` | Policy management: `add_policy()`, `check_compliance()`, `get_applicable_policies()` |
| `CausalChainAnalyzer` | Trace how decisions influenced each other: `get_causal_chain(decision_id)` |
| `ErasureCoordinator` | Erase an entity across graph, memory, and vector store, returning an auditable `ErasureReceipt` |
## What You Get
@@ -634,6 +635,100 @@ queried together safely. Vector-store writes are deferred until the in-memory im
commits; adapter synchronization remains best-effort and logs failures.
## ErasureCoordinator
`ContextGraph.purge_node()` is scoped to one graph: the node is removed and a
tombstone is written, but the same content can still be live as an `AgentMemory`
item and as an embedding in the vector store. `ErasureCoordinator` drives the
cascade across every bound store and returns an `ErasureReceipt` recording what
each one reported.
```python
from semantica.context import AgentMemory, ContextGraph, ErasureCoordinator
coordinator = ErasureCoordinator(graph=graph, memory=memory)
receipt = coordinator.erase_entity(
"customer-4471",
reason="GDPR Art. 17 request #882",
)
if not receipt.complete:
# These stores may still hold the entity; handle them out of band.
print(receipt.incomplete_stores)
```
<Warning>
Check the receipt — the call returning is not proof the data is gone. FAISS,
Milvus, and Weaviate expose no delete method, so erasure cannot be completed on
those backends today; the receipt reports `unsupported` rather than a success it
did not achieve.
</Warning>
### Constructor Parameters
| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `graph` | `ContextGraph` | `None` | Anything exposing `purge_node()` |
| `memory` | `AgentMemory` | `None` | Anything exposing `find_by_entity()` and `batch_delete()` |
| `vector_store` | `VectorStore` | `memory.vector_store` | Store holding entity-keyed embeddings; pass `False` to disable the leg |
At least one store is required; a store that is not supplied reports
`not_configured` rather than being silently skipped.
### Methods
| Method | Returns | Description |
| :--- | :--- | :--- |
| `erase_entity(entity_id, reason, at, vector_ids)` | `ErasureReceipt` | Erase one entity from every bound store |
| `erase_entities(entity_ids, reason, at)` | `List[ErasureReceipt]` | One receipt per entity, in order; one failure does not stop the rest |
### Store Statuses
| Status | Meaning |
| :--- | :--- |
| `erased` | Reached, data removed. On the vectors leg this means the store accepted the delete for the ids given — backends offer no portable existence check, so it is not a count of embeddings that were really there |
| `not_found` | Reached, held nothing for this entity |
| `not_configured` | No such store was bound — normal, not a failure |
| `unsupported` | The store cannot delete at all; retrying will not help |
| `failed` | The store was reached and the deletion did not succeed |
### ErasureReceipt
| Member | Type | Description |
| :--- | :--- | :--- |
| `entity_id` | `str` | Entity the erasure was requested for |
| `reason` | `Optional[str]` | Recorded in the receipt and the graph tombstone |
| `erased_at` | `str` | ISO-8601; matches the tombstone's `purged_at` |
| `stores` | `Dict[str, Dict]` | Per-store outcome keyed `vectors`, `memory`, `graph` |
| `complete` | `bool` | `False` when any store reports `unsupported` or `failed` |
| `incomplete_stores` | `List[str]` | Stores that may still hold the entity's data |
| `to_dict()` | `Dict` | Serialized receipt, safe to persist as an audit record |
```python
receipt.to_dict()
# {
# "entity_id": "customer-4471",
# "reason": "GDPR Art. 17 request #882",
# "erased_at": "2026-08-16T09:03:36.813220",
# "complete": False,
# "stores": {
# "vectors": {"status": "unsupported", "backend": "faiss",
# "detail": "backend exposes no delete()/delete_vectors(); ..."},
# "memory": {"status": "erased", "items": 14},
# "graph": {"status": "erased", "nodes": 1, "edges": 3},
# },
# }
```
Erasure runs outward-in — vectors, then memory, then the graph. The tombstone is
the durable attestation that an erasure happened, so it is written last: a crash
mid-cascade leaves the node present and the receipt incomplete, rather than a
tombstone claiming more than actually happened. A store that raises is recorded
as `failed` and the remaining stores are still erased. Erasing the same entity
twice returns a receipt saying there was nothing left to do rather than raising.
## PolicyEngine
`PolicyEngine` manages versioned policies stored in the knowledge graph. Policies are stored as nodes and can be linked to decisions:
+36
View File
@@ -0,0 +1,36 @@
# CI templates
Copy-paste starting points for wiring `semantica` into your own project's CI. Each file is a
complete, working config — rename it into your project (see the comment at the top of each file
for the target path) and swap the smoke-test / test step for whatever your project does with
Semantica. Each template installs `semantica` unconditionally and your own project's dependencies
only if a `requirements.txt` is present; if your project uses `pyproject.toml`, Poetry, or Pipenv
instead, adjust the marked install line (each file calls it out inline).
| File | Target path in your repo |
| ---- | ------------------------- |
| [`github-actions.yml`](github-actions.yml) | `.github/workflows/semantica.yml` |
| [`gitlab-ci.yml`](gitlab-ci.yml) | `.gitlab-ci.yml` |
| [`circleci-config.yml`](circleci-config.yml) | `.circleci/config.yml` |
If your own project is hosted on GitHub, you can skip the setup boilerplate entirely and use
Semantica's reusable composite action instead:
```yaml
- uses: semantica-agi/semantica/.github/actions/setup-semantica@main
with:
python-version: '3.11'
# extras: 'explorer,all' # optional
# version: '==0.6.7' # optional, pin an exact release
# cache: 'pip' # optional, only if your repo has a requirements.txt/pyproject.toml/etc.
```
`@main` always tracks this repo's default branch, which is convenient but — like any mutable
ref — can change out from under you between runs. For production CI, pin it to a commit SHA
instead (find one via `git rev-parse` against a tagged release, or the commit history for
[`.github/actions/setup-semantica/`](../../.github/actions/setup-semantica/)) and update the pin
deliberately when you want to pick up changes, the same way this repo's own workflows are pinned
(see [`verify-action-pins.yml`](../../.github/workflows/verify-action-pins.yml)).
It installs Python, installs `semantica`, and verifies the import (pip caching is opt-in via `cache: 'pip'`, since not every caller repo has a requirements file to key the cache on) — see
[`.github/actions/setup-semantica/action.yml`](../../.github/actions/setup-semantica/action.yml).
+40
View File
@@ -0,0 +1,40 @@
# Drop this in as .circleci/config.yml in your own project.
version: 2.1
jobs:
test:
docker:
- image: cimg/python:3.11
steps:
- checkout
# A content-hashed cache key (e.g. `{{ checksum "requirements.txt" }}`)
# is more precise but breaks if that exact file doesn't exist in your
# project - swap in one matched to however you declare dependencies
# once you've adjusted the install step below.
- restore_cache:
keys:
- pip-cache-v1
- run:
name: Install dependencies
command: |
pip install --upgrade pip
pip install semantica
# Install your own project's dependencies however your project
# declares them - adjust this to match, e.g. `pip install -e .`
# for pyproject.toml / setup.cfg, or `poetry install`.
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- save_cache:
key: pip-cache-v1
paths:
- ~/.cache/pip
- run:
name: Smoke test
command: python -c "import semantica; print('semantica', semantica.__version__)"
- run:
name: Run tests
command: pytest
workflows:
test:
jobs:
- test
+44
View File
@@ -0,0 +1,44 @@
# Drop this in as .github/workflows/semantica.yml in your own project.
#
# Installs Semantica and runs a smoke import + your test suite. Swap the
# smoke-test step for whatever your project actually does with Semantica
# (build a context graph, run an ingest pipeline, etc.).
#
# Third-party actions below are pinned to a commit SHA rather than a mutable
# tag - a moved tag can silently swap in different code. Update the pin (and
# the trailing "# vX" comment) deliberately when you want a newer version;
# see semantica-agi/semantica's own .github/workflows/verify-action-pins.yml
# for one way to keep pins honest automatically.
name: Semantica
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install semantica
# Install your own project's dependencies however your project
# declares them - adjust this to match. Examples:
# pip install -r requirements.txt
# pip install -e . # pyproject.toml / setup.cfg
# pip install -e ".[dev]"
# poetry install
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Run tests
run: pytest
+20
View File
@@ -0,0 +1,20 @@
# Drop this in as .gitlab-ci.yml in your own project.
semantica-test:
image: python:3.11-slim
cache:
paths:
- .cache/pip
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
script:
- pip install --upgrade pip
- pip install semantica
# Install your own project's dependencies however your project declares
# them - adjust this to match, e.g. `pip install -e .` for pyproject.toml
# / setup.cfg, or `poetry install`.
- if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- python -c "import semantica; print('semantica', semantica.__version__)"
- pytest
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
+6 -6
View File
@@ -2083,9 +2083,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4250,9 +4250,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
+1 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint .",
"preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts",
"test:deterministic-e2e": "node --import tsx --test tests/deterministicExplorerRendering.e2e.ts",
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
},
+1
View File
@@ -86,6 +86,7 @@ export interface EdgeAttributes {
dominantEdgeType?: string;
representativeWeight?: number;
bundleKind?: "parallel" | "bidirectional" | "community";
isSmallGraph?: boolean;
edgeType: string;
@@ -21,7 +21,6 @@ import type Graph from "graphology";
import { batchMergeEdges, batchMergeNodes, graph } from "../../store/graphStore";
import { logEvent } from "../../store/registryStore";
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
import { curveGroupForPair } from "../../store/edgePairKeys.js";
import { InspectorPanel, MetricChip, SurfaceCard } from "../../ui/primitives";
import { lazy, Suspense } from "react";
import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
@@ -42,6 +41,8 @@ import {
import { explorationEffectsShouldLoad, neighborhoodPanelShouldLoad, temporalOverlayShouldLoad } from "./pluginRegistryPredicates";
import { shouldFetchTemporalBounds, shouldFetchTemporalSnapshot } from "./temporalLifecyclePredicates";
import { createTemporalSnapshotGuards, type TemporalSnapshotResponse } from "./temporalSnapshotGuards";
import { SMALL_GRAPH_MAX_NODES } from "./smallGraphLayout";
import { buildRealtimeEdgeAttributes } from "./realtimeGraphAttributes";
import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
import type {
@@ -1056,46 +1057,10 @@ function buildRealtimeNodeAttributes(payload: {
};
}
function buildRealtimeEdgeAttributes(payload: {
id: string;
familyId?: string;
source_id: string;
target_id: string;
type?: string;
weight?: number;
properties?: Record<string, unknown>;
}): EdgeAttributes {
const properties = payload.properties || {};
const isInferred = Boolean(properties.inferred);
const isBidirectional = graph.hasDirectedEdge(payload.target_id, payload.source_id);
const baseColor = isInferred ? GRAPH_THEME.palette.accent.path : GRAPH_THEME.palette.muted.edgeStructure;
return {
edgeId: payload.id,
familyId: payload.familyId || payload.id,
sourceId: payload.source_id,
targetId: payload.target_id,
weight: Number(payload.weight ?? 1),
edgeType: payload.type || "related_to",
properties,
size: 1,
baseSize: 1,
color: baseColor,
baseColor,
mutedColor: GRAPH_THEME.palette.muted.edgeOverview,
visualPriority: isInferred ? 0.95 : 0.5,
isBidirectional,
edgeFamily: isInferred ? "path" : isBidirectional ? "bidirectional" : "line",
curveGroup: isBidirectional ? curveGroupForPair(payload.source_id, payload.target_id) : null,
type: "line",
edgeVariant: isInferred ? "pathSignal" : isBidirectional ? "bidirectionalCurve" : "directional",
arrowVisibilityPolicy: isInferred ? "always" : "contextual",
relationshipStrength: isInferred ? 0.95 : 0.52,
isParallelPair: false,
parallelIndex: 0,
parallelCount: 1,
familySize: 1,
};
function synchronizeRealtimeSmallGraphEdges(isSmallGraph: boolean): void {
graph.forEachEdge((edgeId) => {
graph.setEdgeAttribute(edgeId, "isSmallGraph", isSmallGraph);
});
}
function buildSelectedNodeState(
@@ -1355,6 +1320,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
const lastExternalFocusTokenRef = useRef<number | undefined>(undefined);
const pluginRuntimeRef = useRef<GraphSceneRuntime | null>(null);
const appliedGraphSummarySignatureRef = useRef<string | null>(null);
const smallGraphModeRef = useRef(false);
const pluginInteractionStateRef = useRef<GraphInteractionState>({
hoveredNodeId: null,
selectedNodeId: "",
@@ -1382,6 +1348,12 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
}
appliedGraphSummarySignatureRef.current = signature;
smallGraphModeRef.current = Boolean(
graphSummary.layoutReady
&& !graphSummary.hasCoordinates
&& graphSummary.nodeCount > 0
&& graphSummary.nodeCount <= SMALL_GRAPH_MAX_NODES,
);
setGraphReady(true);
setGraphVersion((current) => current + 1);
setIsLayoutRunning(!graphSummary.layoutReady);
@@ -1893,18 +1865,26 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
attributes: buildRealtimeNodeAttributes(payload),
},
]);
if (graph.order > SMALL_GRAPH_MAX_NODES) {
smallGraphModeRef.current = false;
}
synchronizeRealtimeSmallGraphEdges(smallGraphModeRef.current);
logEvent("add-node", `Added node ${payload.label ?? payload.id}${payload.nodeType ? ` (${payload.nodeType})` : ""} via realtime ws`, { nodeId: payload.id, nodeType: payload.nodeType });
setGraphVersion((current) => current + 1);
sceneRef.current?.getRuntime()?.requestRender();
}
if (eventType === "ADD_EDGE") {
const isSmallGraph = smallGraphModeRef.current;
batchMergeEdges([
{
id: String(payload.id),
familyId: payload.familyId ? String(payload.familyId) : String(payload.id),
source: payload.source_id,
target: payload.target_id,
attributes: buildRealtimeEdgeAttributes(payload),
attributes: buildRealtimeEdgeAttributes(payload, {
isBidirectional: graph.hasDirectedEdge(payload.target_id, payload.source_id),
isSmallGraph,
}),
},
]);
logEvent("add-edge", `Added edge ${payload.edgeType ?? payload.id} (${payload.source_id}${payload.target_id}) via realtime ws`, { edgeId: payload.id, edgeType: payload.edgeType, source: payload.source_id, target: payload.target_id });
@@ -1783,6 +1783,7 @@ export function resolveEdgeElementStyle(
const isCommunityBundle = attrs.bundleKind === "community";
const baseSize = Number(attrs.baseSize || attrs.size || 0.9);
const visualPriority = Number(attrs.visualPriority ?? 0);
const isSmallGraphEdge = viewMode === "full" && attrs.isSmallGraph === true;
const isFullBridgeEdge = viewMode === "full" && fullEdgeClass === "bridge";
const isFullBackboneEdge = viewMode === "full" && fullEdgeClass === "backbone";
const shouldCurveBridge = isFullBridgeEdge
@@ -1790,11 +1791,13 @@ export function resolveEdgeElementStyle(
const visibilityPolicy = resolveEdgeVisibilityPolicy(theme, viewMode, zoomTier, isCommunityBundle);
const isContextEdge = isContextEdgeState(state);
const isNonCriticalEdge = isNonCriticalEdgeVariant(edgeVariant);
const belowPriorityThreshold = state === "default"
const belowPriorityThreshold = !isSmallGraphEdge && state === "default"
&& visualPriority < Math.max(tierConfig.edgePriorityThreshold, visibilityPolicy.defaultPriorityThreshold)
&& isNonCriticalEdge;
const hiddenByMutedState = (state === "muted" || state === "inactive") && visibilityPolicy.hideMuted;
const sampledOut = isNonCriticalEdge
const hiddenByMutedState = !isSmallGraphEdge
&& (state === "muted" || state === "inactive")
&& visibilityPolicy.hideMuted;
const sampledOut = !isSmallGraphEdge && isNonCriticalEdge
&& (
(state === "default" && !isContextEdge && shouldSampleOutBackgroundEdge(visibilityPolicy.backgroundSampleRate, visualPriority, edgeId, sourceId, targetId))
|| (
@@ -1837,11 +1840,14 @@ export function resolveEdgeElementStyle(
? resolveEdgeCurvature(theme, state, edgeVariant, attrs, sourceId, targetId)
: 0;
const baseColor = resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color, fullEdgeClass);
const lodAlpha = resolveEdgeLodAlpha(theme, viewMode, zoomTier, state, attrs, isCommunityBundle, fullEdgeClass);
const resolvedLodAlpha = resolveEdgeLodAlpha(theme, viewMode, zoomTier, state, attrs, isCommunityBundle, fullEdgeClass);
const lodAlpha = isSmallGraphEdge
? Math.max(resolvedLodAlpha ?? 1, isContextEdge ? 0.62 : 0.46)
: resolvedLodAlpha;
const color = lodAlpha === null ? baseColor : withAlpha(baseColor, lodAlpha);
const rawSize = Math.max(
baseSize * sizeMultiplier * (isCommunityBundle ? theme.grouped.style.edgeSizeScale : 1),
stateConfig.minSize,
isSmallGraphEdge ? Math.max(stateConfig.minSize, 0.9) : stateConfig.minSize,
);
const interactionMaxSize = (fullEdgeClass === "path" || state === "path")
@@ -0,0 +1,50 @@
import type { EdgeAttributes } from "../../store/graphStore";
import { curveGroupForPair } from "../../store/edgePairKeys.js";
import { GRAPH_THEME } from "./graphTheme";
export type RealtimeEdgePayload = {
id: string;
familyId?: string;
source_id: string;
target_id: string;
type?: string;
weight?: number;
properties?: Record<string, unknown>;
};
export function buildRealtimeEdgeAttributes(
payload: RealtimeEdgePayload,
options: { isBidirectional: boolean; isSmallGraph: boolean },
): EdgeAttributes {
const properties = payload.properties || {};
const isInferred = Boolean(properties.inferred);
const baseColor = isInferred ? GRAPH_THEME.palette.accent.path : GRAPH_THEME.palette.muted.edgeStructure;
return {
edgeId: payload.id,
familyId: payload.familyId || payload.id,
sourceId: payload.source_id,
targetId: payload.target_id,
weight: Number(payload.weight ?? 1),
edgeType: payload.type || "related_to",
properties,
size: 1,
baseSize: 1,
color: baseColor,
baseColor,
mutedColor: GRAPH_THEME.palette.muted.edgeOverview,
visualPriority: isInferred ? 0.95 : 0.5,
isBidirectional: options.isBidirectional,
edgeFamily: isInferred ? "path" : options.isBidirectional ? "bidirectional" : "line",
curveGroup: options.isBidirectional ? curveGroupForPair(payload.source_id, payload.target_id) : null,
type: "line",
edgeVariant: isInferred ? "pathSignal" : options.isBidirectional ? "bidirectionalCurve" : "directional",
arrowVisibilityPolicy: isInferred ? "always" : "contextual",
relationshipStrength: isInferred ? 0.95 : 0.52,
isParallelPair: false,
parallelIndex: 0,
parallelCount: 1,
familySize: 1,
isSmallGraph: options.isSmallGraph,
};
}
@@ -0,0 +1,135 @@
export const SMALL_GRAPH_MAX_NODES = 48;
const PROVIDED_COORDINATE_COVERAGE = 0.92;
const MAX_COMPONENT_RADIUS = 78;
const COMPONENT_GAP = 48;
type LayoutEdge = {
source: string;
target: string;
};
export function shouldUseSmallGraphLayout(nodeCount: number, coordinateCoverage: number): boolean {
return nodeCount > 0
&& nodeCount <= SMALL_GRAPH_MAX_NODES
&& coordinateCoverage < PROVIDED_COORDINATE_COVERAGE;
}
export function resolveGraphLayoutDecision(nodeCount: number, coordinateCoverage: number): {
useProvidedCoordinates: boolean;
useSmallGraphLayout: boolean;
layoutReady: boolean;
} {
const useProvidedCoordinates = coordinateCoverage >= PROVIDED_COORDINATE_COVERAGE;
const useSmallGraphLayout = shouldUseSmallGraphLayout(nodeCount, coordinateCoverage);
return {
useProvidedCoordinates,
useSmallGraphLayout,
layoutReady: useProvidedCoordinates || useSmallGraphLayout,
};
}
export function resolveNodeLayoutPosition(
decision: ReturnType<typeof resolveGraphLayoutDecision>,
provided: { x: number | null; y: number | null },
seeded: { x: number; y: number } | undefined,
): { x: number; y: number } {
if (decision.useProvidedCoordinates) {
return { x: provided.x ?? 0, y: provided.y ?? 0 };
}
if (decision.useSmallGraphLayout) {
return { x: seeded?.x ?? 0, y: seeded?.y ?? 0 };
}
return {
x: provided.x ?? seeded?.x ?? 0,
y: provided.y ?? seeded?.y ?? 0,
};
}
/**
* Produce a compact deterministic layout for small graphs.
*
* ForceAtlas2 is useful for large connected datasets, but it makes tiny graphs
* with several disconnected components look like scattered dots. This layout
* keeps each connected component together and packs components into a centered
* grid so instance relationships remain legible on first render.
*/
export function buildSmallGraphSeedPositions(
nodeIds: string[],
edges: LayoutEdge[],
): Map<string, { x: number; y: number }> {
const ids = [...new Set(nodeIds)].sort((left, right) => left.localeCompare(right));
const adjacency = new Map(ids.map((id) => [id, new Set<string>()]));
edges.forEach(({ source, target }) => {
if (!adjacency.has(source) || !adjacency.has(target) || source === target) {
return;
}
adjacency.get(source)?.add(target);
adjacency.get(target)?.add(source);
});
const visited = new Set<string>();
const components: string[][] = [];
ids.forEach((start) => {
if (visited.has(start)) {
return;
}
const component: string[] = [];
const queue = [start];
visited.add(start);
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
continue;
}
component.push(current);
[...(adjacency.get(current) ?? [])]
.sort((left, right) => left.localeCompare(right))
.forEach((neighbor) => {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
});
}
component.sort((left, right) => {
const degreeDelta = (adjacency.get(right)?.size ?? 0) - (adjacency.get(left)?.size ?? 0);
return degreeDelta || left.localeCompare(right);
});
components.push(component);
});
components.sort((left, right) => right.length - left.length || left[0].localeCompare(right[0]));
const columns = Math.max(1, Math.ceil(Math.sqrt(components.length)));
const rows = Math.max(1, Math.ceil(components.length / columns));
// Adjacent cells must leave room for two maximum-radius components plus a
// readable gap. A smaller row height allows valid 12-node components to
// overlap vertically.
const cellWidth = MAX_COMPONENT_RADIUS * 2 + COMPONENT_GAP;
const cellHeight = MAX_COMPONENT_RADIUS * 2 + COMPONENT_GAP;
const positions = new Map<string, { x: number; y: number }>();
components.forEach((component, componentIndex) => {
const column = componentIndex % columns;
const row = Math.floor(componentIndex / columns);
const centerX = (column - (columns - 1) / 2) * cellWidth;
const centerY = (row - (rows - 1) / 2) * cellHeight;
if (component.length === 1) {
positions.set(component[0], { x: centerX, y: centerY });
return;
}
const radius = Math.min(MAX_COMPONENT_RADIUS, 30 + component.length * 9);
component.forEach((nodeId, nodeIndex) => {
const angle = -Math.PI / 2 + (nodeIndex * Math.PI * 2) / component.length;
positions.set(nodeId, {
x: centerX + Math.cos(angle) * radius,
y: centerY + Math.sin(angle) * radius,
});
});
});
return positions;
}
@@ -16,6 +16,11 @@ import {
} from "./graphTheme";
import { classifyEntityShape } from "./graphEntityShape";
import { createGraphLoadProgress } from "./graphLoading";
import {
buildSmallGraphSeedPositions,
resolveGraphLayoutDecision,
resolveNodeLayoutPosition,
} from "./smallGraphLayout";
import type { GraphLoadProgress, GraphLoadSummary } from "./types";
const SEMANTIC_COLOR_FIELDS = [
@@ -318,6 +323,20 @@ interface EdgeListResponse {
const PAGE_LIMIT = 1000;
/** Surface the server's `detail` message (e.g. auth/setup guidance) on non-OK responses. */
async function fetchErrorDetail(response: Response): Promise<string> {
try {
const body: unknown = await response.json();
const detail = (body as { detail?: unknown } | null)?.detail;
if (typeof detail === "string" && detail.trim()) {
return `${detail.trim()}`;
}
} catch {
// Non-JSON or unreadable body: fall back to the status-only message.
}
return "";
}
async function fetchAllNodes(
signal: AbortSignal,
onProgress?: (progress: GraphLoadProgress) => void,
@@ -335,7 +354,7 @@ async function fetchAllNodes(
const response = await fetch(url.toString(), { signal });
if (!response.ok) {
throw new Error(`Fetch failed: ${response.status}`);
throw new Error(`Fetch failed: ${response.status}${await fetchErrorDetail(response)}`);
}
const data: NodeListResponse = await response.json();
@@ -390,7 +409,7 @@ async function fetchAllEdges(
const response = await fetch(url.toString(), { signal });
if (!response.ok) {
throw new Error(`Fetch failed: ${response.status}`);
throw new Error(`Fetch failed: ${response.status}${await fetchErrorDetail(response)}`);
}
const data: EdgeListResponse = await response.json();
@@ -539,10 +558,19 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
: count;
}, 0);
const coordinateCoverage = fetchedNodes.length > 0 ? providedCoordinateCount / fetchedNodes.length : 0;
const useProvidedCoordinates = coordinateCoverage >= 0.92;
const {
useProvidedCoordinates,
useSmallGraphLayout,
layoutReady,
} = resolveGraphLayoutDecision(fetchedNodes.length, coordinateCoverage);
const seededPositions = useProvidedCoordinates
? null
: buildClusterSeedPositions(
: useSmallGraphLayout
? buildSmallGraphSeedPositions(
fetchedNodes.map((node) => node.id),
fetchedEdges,
)
: buildClusterSeedPositions(
draftAttributes.map(({ id, attributes }) => ({
id,
semanticGroup: semanticKeyByNodeId.get(id) ?? structuralColorKey(id, attributes),
@@ -555,7 +583,9 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
const colorIndex = hashString(semanticGroup) % GRAPH_THEME.palette.semantic.length;
const baseColor = GRAPH_THEME.palette.semantic[colorIndex];
const sizeRatio = nodePriorityById.get(id) ?? 0;
const dynamicSize = clamp(1.8, 1.8 + 8.8 * sizeRatio, 11.8);
const dynamicSize = useSmallGraphLayout
? clamp(5.2, 5.2 + 6.6 * sizeRatio, 11.8)
: clamp(1.8, 1.8 + 8.8 * sizeRatio, 11.8);
const hasTemporalBounds = Boolean(attributes.valid_from || attributes.valid_until);
const provenanceCount = getProvenanceCount(attributes.properties ?? {});
const properties = attributes.properties as Record<string, unknown>;
@@ -563,12 +593,11 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
const providedX = readFiniteCoordinate(properties?.x);
const providedY = readFiniteCoordinate(properties?.y);
const seededPosition = seededPositions?.get(id);
const x = useProvidedCoordinates
? providedX ?? 0
: providedX ?? seededPosition?.x ?? 0;
const y = useProvidedCoordinates
? providedY ?? 0
: providedY ?? seededPosition?.y ?? 0;
const { x, y } = resolveNodeLayoutPosition(
{ useProvidedCoordinates, useSmallGraphLayout, layoutReady },
{ x: providedX, y: providedY },
seededPosition,
);
return {
id,
attributes: {
@@ -589,6 +618,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
borderSize: 0.72,
entityShape,
...resolveNodeVariantMetadata(baseColor, sizeRatio, hasTemporalBounds, provenanceCount),
...(useSmallGraphLayout ? { labelVisibilityPolicy: "always" as const } : {}),
} as NodeAttributes,
};
});
@@ -645,6 +675,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
parallelIndex,
parallelCount,
familySize: familyCounts.get(edge.familyId) ?? 1,
isSmallGraph: useSmallGraphLayout,
...resolveEdgeVariantMetadata(edge, sourcePriority, targetPriority, isBidirectional),
} as EdgeAttributes,
};
@@ -687,7 +718,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
loadTimeMs: Math.round(performance.now() - startedAt),
hasCoordinates: useProvidedCoordinates,
layoutSource: useProvidedCoordinates ? "provided" : "runtime",
layoutReady: useProvidedCoordinates,
layoutReady,
} satisfies GraphLoadSummary;
onProgress?.(createGraphLoadProgress({
@@ -407,6 +407,31 @@ test("resolveEdgeElementStyle applies full-graph LOD to directional background e
assert.equal(style.hidden, true);
});
test("resolveEdgeElementStyle keeps small-graph relationships visible in overview", () => {
const style = resolveEdgeElementStyle(
GRAPH_THEME,
"overview",
"inactive",
{
edgeType: "related_to",
weight: 1,
properties: {},
edgeVariant: "directional",
visualPriority: 0.1,
baseSize: 0.5,
isSmallGraph: true,
},
"source",
"target",
"full",
"small-graph-low-priority",
"hidden",
);
assert.equal(style.hidden, false);
assert.ok(Number(style.size ?? 0) >= 0.9);
});
test("classifyFullGraphEdge applies deterministic priority order", () => {
const edgeClass = classifyFullGraphEdge(
"edge-priority",
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildRealtimeEdgeAttributes } from "../src/workspaces/GraphWorkspace/realtimeGraphAttributes.ts";
const payload = {
id: "edge-live",
source_id: "source",
target_id: "target",
type: "related_to",
properties: {},
};
test("realtime edges retain the active small-graph visibility marker", () => {
const attributes = buildRealtimeEdgeAttributes(payload, {
isBidirectional: false,
isSmallGraph: true,
});
assert.equal(attributes.isSmallGraph, true);
assert.equal(attributes.edgeVariant, "directional");
});
test("realtime edges do not retain the marker after graph leaves small-graph mode", () => {
const attributes = buildRealtimeEdgeAttributes(payload, {
isBidirectional: false,
isSmallGraph: false,
});
assert.equal(attributes.isSmallGraph, false);
});
+100
View File
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
SMALL_GRAPH_MAX_NODES,
buildSmallGraphSeedPositions,
resolveGraphLayoutDecision,
resolveNodeLayoutPosition,
shouldUseSmallGraphLayout,
} from "../src/workspaces/GraphWorkspace/smallGraphLayout.ts";
test("small graph layout is selected only when coordinates are not already usable", () => {
assert.equal(shouldUseSmallGraphLayout(12, 0), true);
assert.equal(shouldUseSmallGraphLayout(SMALL_GRAPH_MAX_NODES + 1, 0), false);
assert.equal(shouldUseSmallGraphLayout(12, 0.95), false);
});
test("small graph layout ignores isolated partial coordinates", () => {
const decision = resolveGraphLayoutDecision(12, 1 / 12);
assert.deepEqual(
resolveNodeLayoutPosition(decision, { x: 50_000, y: -50_000 }, { x: 24, y: -18 }),
{ x: 24, y: -18 },
);
assert.deepEqual(
resolveNodeLayoutPosition(decision, { x: 50_000, y: null }, { x: -12, y: 36 }),
{ x: -12, y: 36 },
);
});
test("small graph load is immediately ready and skips runtime stabilization", () => {
assert.deepEqual(resolveGraphLayoutDecision(12, 0), {
useProvidedCoordinates: false,
useSmallGraphLayout: true,
layoutReady: true,
});
assert.deepEqual(resolveGraphLayoutDecision(SMALL_GRAPH_MAX_NODES + 1, 0), {
useProvidedCoordinates: false,
useSmallGraphLayout: false,
layoutReady: false,
});
assert.deepEqual(resolveGraphLayoutDecision(12, 1), {
useProvidedCoordinates: true,
useSmallGraphLayout: false,
layoutReady: true,
});
});
test("small graph layout is deterministic and keeps connected nodes together", () => {
const nodes = ["Apple", "Steve", "Ronald", "Cupertino", "California"];
const edges = [
{ source: "Apple", target: "Steve" },
{ source: "Ronald", target: "Cupertino" },
];
const first = buildSmallGraphSeedPositions(nodes, edges);
const second = buildSmallGraphSeedPositions([...nodes].reverse(), [...edges].reverse());
assert.deepEqual([...first.entries()].sort(), [...second.entries()].sort());
assert.equal(first.size, nodes.length);
const distance = (left: string, right: string) => {
const a = first.get(left);
const b = first.get(right);
assert.ok(a && b);
return Math.hypot(a.x - b.x, a.y - b.y);
};
assert.ok(distance("Apple", "Steve") < distance("Apple", "California"));
assert.ok(distance("Ronald", "Cupertino") < distance("Ronald", "California"));
});
test("small graph layout keeps maximum-radius components separated", () => {
const componentCount = 4;
const nodesPerComponent = 12;
const nodes = Array.from(
{ length: componentCount * nodesPerComponent },
(_, index) => `component-${Math.floor(index / nodesPerComponent)}-node-${index % nodesPerComponent}`,
);
const edges = Array.from({ length: componentCount }).flatMap((_, componentIndex) => {
const prefix = `component-${componentIndex}-node-`;
return Array.from({ length: nodesPerComponent - 1 }, (_unused, nodeIndex) => ({
source: `${prefix}${nodeIndex}`,
target: `${prefix}${nodeIndex + 1}`,
}));
});
const positions = buildSmallGraphSeedPositions(nodes, edges);
for (let leftComponent = 0; leftComponent < componentCount; leftComponent += 1) {
for (let rightComponent = leftComponent + 1; rightComponent < componentCount; rightComponent += 1) {
let closestDistance = Number.POSITIVE_INFINITY;
for (let leftNode = 0; leftNode < nodesPerComponent; leftNode += 1) {
for (let rightNode = 0; rightNode < nodesPerComponent; rightNode += 1) {
const left = positions.get(`component-${leftComponent}-node-${leftNode}`);
const right = positions.get(`component-${rightComponent}-node-${rightNode}`);
assert.ok(left && right);
closestDistance = Math.min(closestDistance, Math.hypot(left.x - right.x, left.y - right.y));
}
}
assert.ok(closestDistance >= 48, `components are only ${closestDistance} units apart`);
}
}
});
+5 -1
View File
@@ -25,5 +25,9 @@
"mcp"
],
"skills": "./skills",
"agents": "./agents"
"agents": [
"./agents/decision-advisor.md",
"./agents/explainability.md",
"./agents/kg-assistant.md"
]
}
+10 -2
View File
@@ -49,7 +49,14 @@ dependencies = [
"scipy>=1.13.1",
"scikit-learn>=1.7.2",
"umap-learn>=0.5.12",
"spacy>=3.4.0",
# thinc (spacy's core dep) dropped Python 3.9 wheels at 8.3.10, and later
# spacy patch releases (3.8.8+) require thinc>=8.3.9-only-on-3.10+ ranges,
# which forces a source build that fails outright on 3.9 (see Install
# Matrix run history). Capping both keeps 3.9 on the last wheel-compatible
# pair; 3.10+ is left unconstrained to always get the latest spacy/thinc.
"spacy>=3.4.0,<3.8.8; python_version < '3.10'",
"spacy>=3.4.0; python_version >= '3.10'",
"thinc<8.3.5; python_version < '3.10'",
"transformers>=4.20.0",
"torch>=1.13.1",
"sentence-transformers>=2.2.0",
@@ -107,11 +114,12 @@ llm-gemini = ["google-genai>=0.1.0"]
llm-anthropic = ["anthropic>=0.122.0"]
llm-ollama = ["ollama>=0.1.0"]
llm-deepseek = ["openai>=1.0.0"]
llm-novita = ["openai>=1.0.0"]
llm-litellm = ["litellm>=1.83.9"]
llm-instructor = ["instructor>=1.15.3"]
llm-all = [
"semantica[llm-openai,llm-groq,llm-gemini,llm-anthropic,llm-ollama,llm-deepseek,llm-litellm,llm-instructor]"
"semantica[llm-openai,llm-groq,llm-gemini,llm-anthropic,llm-ollama,llm-deepseek,llm-novita,llm-litellm,llm-instructor]"
]
# ---- Document Parsing ----
+114 -9
View File
@@ -3714,19 +3714,61 @@ def store_stats(cli_ctx: CLIContext, backend: str, fmt: str, local_json: bool) -
_run_with_error_handling(_action)
_MIGRATE_SUPPORTED_BACKENDS = {"faiss", "sqlite", "pgvector"}
_MIGRATE_BATCH_SIZE = 500
def _migrate_backend_config(vs_cfg: Dict[str, Any], backend: str) -> Dict[str, Any]:
"""Resolve per-backend config out of the vector_store config section.
Supports both a per-backend nested shape (``vector_store.faiss.dimension``)
and the common flat single-backend shape (``vector_store.backend`` +
sibling keys), since either can appear depending on how many backends a
user has configured.
"""
nested = vs_cfg.get(backend)
if isinstance(nested, dict):
return dict(nested)
if vs_cfg.get("backend") == backend:
return {k: v for k, v in vs_cfg.items() if k != "backend"}
return {}
def _require_faiss_index_path(cfg: Dict[str, Any], role: str) -> str:
"""FAISS has no server to hold state between commands: a fresh FAISSStore
starts empty and nothing outside the process persists it, so migration
needs an explicit on-disk index to read from or write to."""
index_path = cfg.get("index_path")
if not index_path:
raise click.ClickException(
f"faiss as migration {role} requires 'index_path' in the vector_store "
f"config (vector_store.faiss.index_path or vector_store.index_path "
f"when faiss is the configured backend)."
)
return index_path
@store.command("migrate")
@click.option("--from", "from_backend", required=True)
@click.option("--to", "to_backend", required=True)
@click.option("--namespace", default=None)
@click.option("--dry-run", "local_dry", is_flag=True, default=False)
@click.option("--json", "local_json", is_flag=True, default=False)
@click.pass_obj
def store_migrate(cli_ctx: CLIContext, from_backend: str, to_backend: str,
namespace: Optional[str], local_dry: bool) -> None:
namespace: Optional[str], local_dry: bool, local_json: bool) -> None:
"""Migrate data between backends.
Direct migration is only wired up between faiss, sqlite, and pgvector -
these are the backends whose storage contract supports paging through
every stored vector. Migrating to or from qdrant, pinecone, milvus, or
weaviate still needs the export/reindex workaround below, since each of
those needs its own enumeration design (Qdrant scroll, Pinecone list,
etc.) that hasn't been built yet.
\b
Example:
semantica store migrate --from faiss --to qdrant --namespace production --dry-run
semantica store migrate --from faiss --to sqlite --namespace production --dry-run
"""
cli_ctx = _require_ctx(cli_ctx)
@@ -3734,13 +3776,76 @@ def store_migrate(cli_ctx: CLIContext, from_backend: str, to_backend: str,
if _is_dry(cli_ctx, local_dry):
_dry(cli_ctx, "migrate", from_backend=from_backend, to_backend=to_backend)
return
raise click.ClickException(
f"Direct backend migration ({from_backend}{to_backend}) is not yet supported "
"by the vector store layer. To migrate, export your data first:\n"
" semantica export --format parquet --output dump.parquet\n"
f" semantica embed index dump.parquet --store {to_backend}"
+ (f" --namespace {namespace}" if namespace else "")
)
if from_backend not in _MIGRATE_SUPPORTED_BACKENDS or to_backend not in _MIGRATE_SUPPORTED_BACKENDS:
raise click.ClickException(
f"Direct backend migration ({from_backend}{to_backend}) is only supported "
f"between {', '.join(sorted(_MIGRATE_SUPPORTED_BACKENDS))}. To migrate involving "
"another backend, export your data first:\n"
" semantica export --format parquet --output dump.parquet\n"
f" semantica embed index dump.parquet --store {to_backend}"
+ (f" --namespace {namespace}" if namespace else "")
)
from .vector_store import VectorStore
vs_cfg = cli_ctx.config.to_dict().get("vector_store", {}) or {}
source_cfg = _migrate_backend_config(vs_cfg, from_backend)
dest_cfg = _migrate_backend_config(vs_cfg, to_backend)
source_index_path = None
if from_backend == "faiss":
source_index_path = _require_faiss_index_path(source_cfg, "source")
dest_index_path = None
if to_backend == "faiss":
dest_index_path = _require_faiss_index_path(dest_cfg, "destination")
source = VectorStore(backend=from_backend, config=source_cfg)
if source_index_path:
source._backend_store.load_index(source_index_path)
source_dimension = getattr(source._backend_store, "dimension", None)
if source_dimension and "dimension" not in dest_cfg:
dest_cfg["dimension"] = source_dimension
dest = VectorStore(backend=to_backend, config=dest_cfg)
if dest_index_path and Path(dest_index_path).exists():
dest._backend_store.load_index(dest_index_path)
migrated = 0
vectors_batch: List[Any] = []
metadata_batch: List[Dict[str, Any]] = []
ids_batch: List[str] = []
def _flush() -> None:
nonlocal migrated
if not vectors_batch:
return
dest.store_vectors(list(vectors_batch), list(metadata_batch), ids=list(ids_batch))
migrated += len(vectors_batch)
vectors_batch.clear()
metadata_batch.clear()
ids_batch.clear()
for item in source.iter_vectors(batch_size=_MIGRATE_BATCH_SIZE):
meta = dict(item.get("metadata") or {})
if namespace and "namespace" not in meta:
meta["namespace"] = namespace
vectors_batch.append(item["vector"])
metadata_batch.append(meta)
ids_batch.append(item["id"])
if len(vectors_batch) >= _MIGRATE_BATCH_SIZE:
_flush()
_flush()
if dest_index_path and migrated:
dest._backend_store.save_index(dest_index_path)
result = {"from": from_backend, "to": to_backend, "migrated": migrated}
if _is_json(cli_ctx, local_json):
_jecho(result)
else:
_ok(cli_ctx, f"Migrated {migrated} vectors from {from_backend} to {to_backend}")
_run_with_error_handling(_action)
+4
View File
@@ -111,6 +111,7 @@ from .context_graph import ContextEdge, ContextGraph, ContextNode
from .context_retriever import ContextRetriever, RetrievedContext, TemporalGraphRetriever
from .decision_context import DecisionContext
from .entity_linker import EntityLink, EntityLinker, LinkedEntity
from .erasure import ErasureCoordinator, ErasureReceipt
# Decision tracking imports
from .decision_models import (
@@ -145,6 +146,9 @@ __all__ = [
"ContextRetriever",
"RetrievedContext",
"TemporalGraphRetriever",
# Cross-store erasure
"ErasureCoordinator",
"ErasureReceipt",
# Decision tracking models
"Decision",
"DecisionContextModel",
+31 -4
View File
@@ -626,6 +626,27 @@ class AgentMemory:
self.logger.debug(f"Deleted memory item: {memory_id}")
return True
def vector_ids_for(self, memory_id: str) -> List[str]:
"""Return the vector-store ids owned by a memory item.
Read-only view of the ids ``delete_memory()`` would remove for this
item, so a caller that needs to *report* on vector removal can delete
them itself rather than relying on ``delete_memory()``'s best-effort
cascade, which logs a vector-store failure and still returns ``True``.
Mirrors the fallback in ``delete_memory``: an item stored without
tracked vector ids is keyed in the vector store by its own memory id.
Args:
memory_id: Memory identifier.
Returns:
The item's vector ids, or ``[]`` if the item is unknown.
"""
if memory_id not in self.memory_items:
return []
return list(self._vector_ids.get(memory_id, [])) or [memory_id]
def clear_memory(self, **filters) -> int:
"""
Clear memory items matching filters.
@@ -1286,13 +1307,19 @@ class AgentMemory:
"""
return self.retrieve(content, max_results=limit, **kwargs)
def find_by_entity(self, entity_id: str, limit: int = 10) -> List[Dict[str, Any]]:
def find_by_entity(
self, entity_id: str, limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Find by entity.
Args:
entity_id: Entity ID to search for
limit: Maximum results (default: 10)
limit: Maximum results. None (the default) returns ALL matches.
The previous default of 10 silently truncated results an
erasure workflow computing "what references this entity"
from a truncated page would leave the remainder live
(#1018). Callers that want pagination pass an explicit limit.
Returns:
List of memory dicts containing the entity
@@ -1308,9 +1335,9 @@ class AgentMemory:
if mem_dict:
results.append(mem_dict)
break
if len(results) >= limit:
if limit is not None and len(results) >= limit:
break
return results[:limit]
return results if limit is None else results[:limit]
def find_by_relationship(
self, relationship_type: str, limit: int = 10
+5 -1
View File
@@ -2640,7 +2640,11 @@ class ContextGraph:
Scope is this graph only. Copies held elsewhere (``AgentMemory``, a
bound vector store, an exported file) are not reached, so this is one
step of an erasure workflow, not the whole of it.
step of an erasure workflow, not the whole of it. Callers who need the
whole workflow -- and a receipt recording which stores it actually
reached -- should drive this through
:class:`~semantica.context.erasure.ErasureCoordinator` rather than
treating a ``True`` here as proof the content is gone.
Args:
node_id: Node to purge.
+76
View File
@@ -239,6 +239,82 @@ print(f"Python importance score: {importance.get('degree', 0)}")
---
## 🧹 Erasing an Entity Everywhere - ErasureCoordinator
`purge_node()` removes an entity from **one graph**. The same content can still be
sitting in agent memory and in your vector store, so purge on its own is one step
of an erasure workflow rather than the whole of it.
`ErasureCoordinator` drives the whole cascade and hands you a receipt saying what
it actually managed to erase.
```python
from semantica.context import AgentMemory, ContextGraph, ErasureCoordinator
coordinator = ErasureCoordinator(graph=knowledge, memory=memory)
receipt = coordinator.erase_entity(
"customer-4471",
reason="GDPR Art. 17 request #882",
)
if receipt.complete:
print("Erased everywhere")
else:
print("Still holding data:", receipt.incomplete_stores)
```
### Always Check the Receipt
The receipt is the point of the feature — **do not treat the call itself as proof
the data is gone**. Each store reports one of five statuses:
| Status | Meaning |
|---|---|
| `erased` | Reached, data removed (on the vectors leg: the store accepted the delete for the ids given) |
| `not_found` | Reached, held nothing for this entity |
| `not_configured` | No such store was bound — normal, not a failure |
| `unsupported` | The store cannot delete at all; retrying will not help |
| `failed` | The store was reached and the deletion did not succeed |
```python
receipt.to_dict()
# {
# "entity_id": "customer-4471",
# "reason": "GDPR Art. 17 request #882",
# "erased_at": "2026-08-16T09:03:36.813220",
# "complete": False,
# "stores": {
# "vectors": {"status": "unsupported", "backend": "faiss",
# "detail": "backend exposes no delete()/delete_vectors(); ..."},
# "memory": {"status": "erased", "items": 14},
# "graph": {"status": "erased", "nodes": 1, "edges": 3},
# },
# }
```
`complete` is `False` when any store reports `unsupported` or `failed`, which is
your signal to handle that store out of band. FAISS, Milvus and Weaviate expose
no delete method today, so erasure genuinely cannot be completed on them — the
coordinator says so rather than reporting a success it did not achieve.
### Good to Know
- **Order is vectors → memory → graph.** The graph tombstone is the durable record
that an erasure happened, so it is written last: a crash mid-cascade leaves the
node present and the receipt incomplete, rather than a tombstone claiming more
than actually happened.
- **A failing store does not abort the rest.** Partial failure is recorded in the
receipt and the remaining stores are still erased.
- **Every store is optional.** `ErasureCoordinator(graph=graph)` is fine; the other
legs report `not_configured`.
- **It is idempotent.** Erasing the same entity twice returns a receipt saying
there was nothing left to do, rather than raising.
- **Batch:** `coordinator.erase_entities([...], reason=...)` returns one receipt per
entity, in order, so one entity's failure does not stop the others.
---
## 🔄 Using Both Together - The Complete Setup
### Your Smart Agent System
+15 -9
View File
@@ -76,11 +76,11 @@ Production Use Cases:
- Insurance: Claim decisions, underwriting assessments
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
import json
import uuid
from dataclasses import InitVar, dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
@dataclass
@@ -100,8 +100,9 @@ class Decision:
valid_from: Optional[str] = None
valid_until: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
auto_generate_id: InitVar[bool] = True
def __post_init__(self, auto_generate_id: bool = True):
def __post_init__(self, auto_generate_id: bool) -> None:
"""Validate decision data."""
if auto_generate_id and not self.decision_id: # Handle both None and empty string
self.decision_id = str(uuid.uuid4())
@@ -146,8 +147,9 @@ class DecisionContext:
risk_factors: List[str]
cross_system_inputs: Dict[str, Any] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
auto_generate_id: InitVar[bool] = True
def __post_init__(self, auto_generate_id: bool = True):
def __post_init__(self, auto_generate_id: bool) -> None:
"""Validate decision context data."""
if auto_generate_id and not self.context_id: # Handle both None and empty string
self.context_id = str(uuid.uuid4())
@@ -184,8 +186,9 @@ class Policy:
created_at: datetime
updated_at: datetime
metadata: Dict[str, Any] = field(default_factory=dict)
auto_generate_id: InitVar[bool] = True
def __post_init__(self, auto_generate_id: bool = True):
def __post_init__(self, auto_generate_id: bool) -> None:
"""Validate policy data."""
if auto_generate_id and not self.policy_id: # Handle both None and empty string
self.policy_id = str(uuid.uuid4())
@@ -227,8 +230,9 @@ class PolicyException:
approval_timestamp: datetime
justification: str
metadata: Dict[str, Any] = field(default_factory=dict)
auto_generate_id: InitVar[bool] = True
def __post_init__(self, auto_generate_id: bool = True):
def __post_init__(self, auto_generate_id: bool) -> None:
"""Validate policy exception data."""
if auto_generate_id and not self.exception_id: # Handle both None and empty string
self.exception_id = str(uuid.uuid4())
@@ -265,8 +269,9 @@ class Precedent:
similarity_score: float
relationship_type: str # "similar_scenario", "same_policy", "exception_precedent"
metadata: Dict[str, Any] = field(default_factory=dict)
auto_generate_id: InitVar[bool] = True
def __post_init__(self, auto_generate_id: bool = True):
def __post_init__(self, auto_generate_id: bool) -> None:
"""Validate precedent data."""
if auto_generate_id and not self.precedent_id: # Handle both None and empty string
self.precedent_id = str(uuid.uuid4())
@@ -305,8 +310,9 @@ class ApprovalChain:
approval_context: str
timestamp: datetime
metadata: Dict[str, Any] = field(default_factory=dict)
auto_generate_id: InitVar[bool] = True
def __post_init__(self, auto_generate_id: bool = True):
def __post_init__(self, auto_generate_id: bool) -> None:
"""Validate approval chain data."""
if auto_generate_id and not self.approval_id: # Handle both None and empty string
self.approval_id = str(uuid.uuid4())
+673
View File
@@ -0,0 +1,673 @@
"""
Cross-store erasure coordination.
``ContextGraph.purge_node()`` is graph-scope by design (#957): it removes the
node and leaves a tombstone, but any copy of the same content held in
``AgentMemory`` or in a bound vector store is untouched. That makes purge one
step of an erasure workflow rather than the whole of it, and leaves the caller
to drive the remaining steps by hand -- with no record of which of them
actually succeeded.
:class:`ErasureCoordinator` drives the cascade across the stores it is given
and returns an :class:`ErasureReceipt` describing what was reached and what was
not. It *composes* the existing public APIs; nothing in ``context_graph.py`` or
``agent_memory.py`` changes, and ``ContextGraph`` keeps its graph-scope
contract.
The property that matters is honest partial reporting. Three vector backends
(FAISS, Milvus, Weaviate) expose no delete at all, so erasure is genuinely not
completable on them today. The receipt says ``unsupported`` for those rather
than reporting a success it did not achieve -- a receipt that reads
"graph: erased, memory: 14 erased, vectors: unsupported on faiss" is
actionable; a bare ``True`` is a compliance liability.
Example:
>>> from semantica.context import ContextGraph, AgentMemory
>>> from semantica.context.erasure import ErasureCoordinator
>>> coordinator = ErasureCoordinator(graph=graph, memory=memory)
>>> receipt = coordinator.erase_entity(
... "customer-4471", reason="GDPR Art. 17 request #882"
... )
>>> receipt.complete
False
>>> receipt.stores["vectors"]["status"]
'unsupported'
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
from ..utils.logging import get_logger
from .context_graph import _normalize_temporal_input
__all__ = [
"ErasureCoordinator",
"ErasureReceipt",
"STATUS_ERASED",
"STATUS_NOT_FOUND",
"STATUS_NOT_CONFIGURED",
"STATUS_UNSUPPORTED",
"STATUS_FAILED",
]
#: The store was reached and the entity's data removed from it. On the vectors
#: leg this means the store accepted the delete for the ids it was given: no
#: backend offers a portable "does this id exist" check, so it is not a count of
#: embeddings that were really there. The memory leg re-queries to confirm and
#: so is the stronger claim of the two.
STATUS_ERASED = "erased"
#: The store was reached and held nothing for this entity.
STATUS_NOT_FOUND = "not_found"
#: No such store was bound to the coordinator. Normal, not a failure.
STATUS_NOT_CONFIGURED = "not_configured"
#: The store exists but cannot delete -- e.g. a vector backend with no delete
#: method. Deliberately distinct from ``failed``: retrying will not help.
STATUS_UNSUPPORTED = "unsupported"
#: The store was reached and the deletion did not succeed.
STATUS_FAILED = "failed"
#: Statuses that leave data behind. A receipt containing any of these is not
#: complete, and the shortfall has to be handled out of band.
_INCOMPLETE_STATUSES = frozenset({STATUS_UNSUPPORTED, STATUS_FAILED})
#: Page size for the memory sweep. See ``_erase_memory`` for why the sweep
#: loops rather than passing one large limit.
_MEMORY_SWEEP_BATCH = 500
logger = get_logger("erasure")
@dataclass
class ErasureReceipt:
"""Auditable record of one entity's erasure across every bound store.
Attributes:
entity_id: The entity the erasure was requested for.
reason: Why it was erased, e.g. an erasure-request reference.
erased_at: ISO-8601 timestamp of the erasure.
stores: Per-store outcome keyed by ``"vectors"``, ``"memory"`` and
``"graph"``, each a dict with at least a ``status`` key drawn from
the ``STATUS_*`` constants in this module.
"""
entity_id: str
reason: Optional[str] = None
erased_at: str = ""
stores: Dict[str, Dict[str, Any]] = field(default_factory=dict)
@property
def complete(self) -> bool:
"""True when no bound store was left holding data.
``not_configured`` and ``not_found`` count as complete -- a store that
was never bound, or that held nothing, leaves no residue. Only
``unsupported`` and ``failed`` mean data survived the erasure.
"""
return not self.incomplete_stores
@property
def incomplete_stores(self) -> List[str]:
"""Names of the stores that may still hold the entity's data."""
return [
name
for name, result in self.stores.items()
if result.get("status") in _INCOMPLETE_STATUSES
]
def to_dict(self) -> Dict[str, Any]:
"""Serialize the receipt, deep-copying the per-store results."""
return {
"entity_id": self.entity_id,
"reason": self.reason,
"erased_at": self.erased_at,
"complete": self.complete,
"stores": {name: dict(result) for name, result in self.stores.items()},
}
class ErasureCoordinator:
"""Drives erasure of an entity across the graph, memory and vector stores.
Every store is optional; a store that is not supplied reports
``not_configured`` rather than being silently skipped, so the receipt still
shows the full shape of the workflow.
Args:
graph: A :class:`~semantica.context.ContextGraph` (or anything exposing
``purge_node``).
memory: An :class:`~semantica.context.AgentMemory` (or anything
exposing ``find_by_entity`` and ``batch_delete``).
vector_store: Vector store holding entity-keyed embeddings. Defaults to
``memory.vector_store`` when a memory is supplied, and stays
overridable for deployments that bind a store the memory does not
own. Pass ``False`` to disable the vector leg entirely.
Note:
Erasure runs outward-in -- vectors, then memory, then the graph. The
graph tombstone is the durable attestation that an erasure happened, so
writing it first would let a crash mid-cascade leave a record claiming
more than actually occurred. Erasing the graph last means a partial
failure leaves the node present and the receipt incomplete, which is
recoverable and honest.
"""
def __init__(
self,
graph: Optional[Any] = None,
memory: Optional[Any] = None,
vector_store: Optional[Any] = None,
):
# `is None` / `is False` rather than truthiness: a real store that
# defines __bool__ or __len__ (an empty one, say) is falsey while being
# a perfectly valid store to erase from.
vector_store_given = vector_store is not None and vector_store is not False
if graph is None and memory is None and not vector_store_given:
raise ValueError(
"ErasureCoordinator needs at least one store to erase from; got "
f"graph=None, memory=None, vector_store={vector_store!r}"
)
self.graph = graph
self.memory = memory
if vector_store is False:
self.vector_store: Optional[Any] = None
elif vector_store is not None:
self.vector_store = vector_store
else:
self.vector_store = getattr(memory, "vector_store", None)
self.logger = logger
def erase_entity(
self,
entity_id: str,
reason: Optional[str] = None,
at: Optional[Union[str, int, float, datetime]] = None,
vector_ids: Optional[Sequence[str]] = None,
) -> ErasureReceipt:
"""Erase one entity from every bound store and return a receipt.
A store that cannot be erased from is recorded in the receipt and the
cascade continues -- partial failure is a result, not an exception.
Aborting on the first failure would leave a half-erased state with no
record of which half.
Args:
entity_id: Entity to erase. Interpreted as a graph node id, an
``entities[].id`` in memory items, and a vector id.
reason: Why it was erased, e.g. an erasure-request reference.
Recorded in the receipt and in the graph tombstone.
at: When the erasure takes effect, used as the receipt's
``erased_at`` and passed to ``purge_node`` so both records
carry the same instant. Accepts anything ``ContextGraph``
accepts -- an ISO string, a ``datetime``, or epoch seconds --
and defaults to now, UTC.
vector_ids: Explicit vector ids to remove, in addition to the
ids owned by the entity's memory items, which are always
included. Defaults to ``[entity_id]``, covering entity-keyed
embeddings written by something other than ``AgentMemory``.
Returns:
An :class:`ErasureReceipt`. Check :attr:`ErasureReceipt.complete`
before treating the erasure as done.
"""
# Resolve the timestamp once and hand the *resolved* value to the graph.
# Passing the caller's `at` through instead would let purge_node take its
# own now() when `at` is None, so the receipt and the tombstone it
# attests to would disagree by however long the cascade took.
erased_at = _normalize_timestamp(at)
stores: Dict[str, Dict[str, Any]] = {}
# Outward-in: vectors, then memory, then the graph last.
#
# The vector leg must also cover the embeddings owned by memory items.
# AgentMemory.delete_memory() deletes an item's vectors best-effort: it
# catches a vector-store failure, logs it, and still returns True, so
# the memory leg cannot tell a full erasure from one that left the
# embedding behind. Deleting those ids here instead puts them behind
# the one leg that reports honestly. Collected before anything is
# deleted, while the items still exist to be enumerated.
stores["vectors"] = self._erase_vectors(
entity_id, self._all_vector_ids(entity_id, vector_ids)
)
stores["memory"] = self._erase_memory(entity_id)
stores["graph"] = self._erase_graph(entity_id, reason, erased_at)
receipt = ErasureReceipt(
entity_id=entity_id,
reason=reason,
erased_at=erased_at,
stores=stores,
)
if receipt.complete:
self.logger.info(
"Erased %r across %d store(s)%s",
entity_id,
len(stores),
f" ({reason})" if reason else "",
)
else:
self.logger.warning(
"Erasure of %r is incomplete; these stores may still hold it: %s",
entity_id,
", ".join(receipt.incomplete_stores),
)
return receipt
def erase_entities(
self,
entity_ids: Iterable[str],
reason: Optional[str] = None,
at: Optional[Union[str, int, float, datetime]] = None,
) -> List[ErasureReceipt]:
"""Erase several entities, returning one receipt per entity.
Each entity is erased independently, so one entity's failure does not
stop the rest. Receipts come back in the order the ids were given.
The timestamp is resolved once for the whole batch so that every
receipt and every graph tombstone record the same instant -- a batch
erasure under a single legal request must not produce tombstones with
diverging ``purged_at`` values.
"""
resolved_at = _normalize_timestamp(at)
return [
self.erase_entity(entity_id, reason=reason, at=resolved_at)
for entity_id in entity_ids
]
# Store legs
def _all_vector_ids(
self, entity_id: str, vector_ids: Optional[Sequence[str]]
) -> List[str]:
"""Caller-supplied vector ids plus the ids owned by memory items.
Best-effort by design: if memory cannot be enumerated here, the memory
leg makes the same call moments later and reports the failure, so the
receipt is still incomplete. Swallowing it there instead would be the
bug this method exists to fix.
Collects vector IDs from ALL memory items before deletion. Must call
find_by_entity with limit=None to get all items, since find_by_entity
doesn't support offset/cursor and we cannot delete while collecting.
"""
ids: List[str] = list(vector_ids) if vector_ids is not None else [entity_id]
if self.memory is None:
return ids
seen_vector_ids = set(ids)
try:
# Get ALL matching memory items in one call (limit=None).
# Pagination with deletion happens in _erase_memory(); here we must
# collect all vector IDs up front before any deletion occurs.
found = self.memory.find_by_entity(entity_id, limit=None)
for item in found:
memory_id = _memory_item_id(item)
if not memory_id:
continue
for vector_id in self.memory.vector_ids_for(memory_id):
if vector_id not in seen_vector_ids:
seen_vector_ids.add(vector_id)
ids.append(vector_id)
except Exception as exc:
self.logger.warning(
"Could not enumerate memory-owned vector ids for %r: %s; "
"the memory leg will report the same failure",
entity_id,
exc,
)
return ids
def _erase_vectors(
self, entity_id: str, vector_ids: Optional[Sequence[str]]
) -> Dict[str, Any]:
"""Remove entity-keyed embeddings from the bound vector store.
``vector_ids`` in the result is the number of ids the store accepted,
not the number of embeddings that existed: backends delete by id and
report success either way, with no portable way to ask what was
actually there. See :data:`STATUS_ERASED`.
"""
if self.vector_store is None:
return {"status": STATUS_NOT_CONFIGURED}
ids = list(vector_ids) if vector_ids is not None else [entity_id]
backend = _vector_backend_name(self.vector_store)
if not ids:
return {"status": STATUS_NOT_FOUND, "backend": backend}
method_name, target = _vector_delete_capability(self.vector_store)
if method_name is None:
# FAISS, Milvus and Weaviate expose no delete at all; FAISS in
# particular cannot remove from a flat index without a rebuild.
self.logger.warning(
"Vector backend %r exposes no delete; %d vector id(s) for %r "
"were not erased",
backend,
len(ids),
entity_id,
)
return {
"status": STATUS_UNSUPPORTED,
"backend": backend,
"vector_ids": len(ids),
"detail": (
"backend exposes no delete()/delete_vectors(); "
"removal requires an index rebuild or an out-of-band process"
),
}
try:
deleted = getattr(target, method_name)(ids)
except NotImplementedError as exc:
# The VectorStore facade declares delete_vectors() unconditionally
# and only fails on the call when its backend cannot delete.
self.logger.warning(
"Vector backend %r cannot delete %d id(s) for %r: %s",
backend,
len(ids),
entity_id,
exc,
)
return {
"status": STATUS_UNSUPPORTED,
"backend": backend,
"vector_ids": len(ids),
"detail": str(exc),
}
except Exception as exc:
self.logger.warning(
"Vector deletion failed for %r on backend %r: %s",
entity_id,
backend,
exc,
exc_info=True,
)
return {
"status": STATUS_FAILED,
"backend": backend,
"vector_ids": len(ids),
"detail": f"{type(exc).__name__}: {exc}",
}
accepted, detail = _interpret_delete_result(deleted)
result: Dict[str, Any] = {
"status": STATUS_ERASED if accepted else STATUS_FAILED,
"backend": backend,
"vector_ids": len(ids),
"via": method_name,
}
# Keep whatever the backend said. Qdrant returns {"status": ...} and
# Pinecone {"deleted": True}, and that detail is the only account of
# the delete anyone gets -- dropping it on the floor would leave the
# receipt less informative than the call it is attesting to.
if detail is not None:
result["backend_result"] = detail
if not accepted:
self.logger.warning(
"Vector backend %r reported no deletion for %r: %s",
backend,
entity_id,
detail,
)
result["detail"] = "store reported the ids were not deleted"
return result
def _erase_memory(self, entity_id: str) -> Dict[str, Any]:
"""Delete every memory item referencing the entity."""
if self.memory is None:
return {"status": STATUS_NOT_CONFIGURED}
deleted = 0
try:
# Sweep in pages until dry rather than passing one large limit:
# ``find_by_entity`` has historically defaulted to ``limit=10`` and
# truncated silently, and a single large number is only correct
# until someone exceeds it. Deleting as we go means the next page
# is the remainder.
while True:
found = self.memory.find_by_entity(entity_id, limit=_MEMORY_SWEEP_BATCH)
if not found:
break
memory_ids = [
memory_id
for memory_id in (_memory_item_id(item) for item in found)
if memory_id
]
if not memory_ids:
self.logger.warning(
"Memory returned %d item(s) for %r with no identifier; "
"cannot delete them",
len(found),
entity_id,
)
return {
"status": STATUS_FAILED,
"items": deleted,
"residual": len(found),
"detail": "memory items carry no 'memory_id'",
}
removed = self.memory.batch_delete(memory_ids)
deleted += removed
if removed == 0:
# No progress: another page would return the same items.
self.logger.warning(
"Memory sweep for %r stalled with %d item(s) remaining",
entity_id,
len(found),
)
return {
"status": STATUS_FAILED,
"items": deleted,
"residual": len(found),
"detail": "batch_delete removed nothing for a non-empty page",
}
if len(found) < _MEMORY_SWEEP_BATCH:
break
# Re-query once rather than trusting the loop's own bookkeeping;
# this is what keeps the leg's `failed` status honest.
residual = self.memory.find_by_entity(entity_id, limit=_MEMORY_SWEEP_BATCH)
except Exception as exc:
self.logger.warning(
"Memory erasure failed for %r after %d item(s): %s",
entity_id,
deleted,
exc,
exc_info=True,
)
return {
"status": STATUS_FAILED,
"items": deleted,
"detail": f"{type(exc).__name__}: {exc}",
}
if residual:
self.logger.warning(
"Memory still holds %d item(s) for %r after erasure",
len(residual),
entity_id,
)
return {
"status": STATUS_FAILED,
"items": deleted,
"residual": len(residual),
"detail": "items referencing the entity survived the sweep",
}
if deleted == 0:
return {"status": STATUS_NOT_FOUND, "items": 0}
return {"status": STATUS_ERASED, "items": deleted}
def _erase_graph(
self,
entity_id: str,
reason: Optional[str],
at: Optional[Union[str, int, float, datetime]],
) -> Dict[str, Any]:
"""Purge the node, and with it every edge that touches it."""
if self.graph is None:
return {"status": STATUS_NOT_CONFIGURED}
try:
# Counted before the purge because the edges are gone afterwards.
edge_count = _incident_edge_count(self.graph, entity_id)
purged = self.graph.purge_node(entity_id, reason=reason, at=at)
except Exception as exc:
self.logger.warning(
"Graph purge failed for %r: %s", entity_id, exc, exc_info=True
)
return {
"status": STATUS_FAILED,
"detail": f"{type(exc).__name__}: {exc}",
}
if not purged:
return {"status": STATUS_NOT_FOUND, "nodes": 0, "edges": 0}
return {"status": STATUS_ERASED, "nodes": 1, "edges": edge_count}
# Helpers
def _normalize_timestamp(at: Optional[Union[str, int, float, datetime]]) -> str:
"""Render ``at`` exactly as the graph tombstone will record it.
Reuses ``ContextGraph``'s own normalizer rather than formatting the value
here, so the receipt and the tombstone written by the same erasure cannot
disagree about when it happened -- an audit record that contradicts the
tombstone it attests to is worse than no record. Normalizing up front also
rejects an unparseable ``at`` before any store is touched, instead of half
way through the cascade.
``None`` resolves to now here rather than being passed along, so the
default path gets one timestamp for both records instead of two ``now()``
calls separated by the length of the cascade.
"""
return _normalize_temporal_input(
at if at is not None else datetime.now(timezone.utc)
)
def _memory_item_id(item: Any) -> Optional[str]:
"""Pull the identifier out of a memory dict as ``find_by_entity`` returns it."""
if not isinstance(item, dict):
return None
memory_id = item.get("memory_id") or item.get("id")
return str(memory_id) if memory_id else None
#: Dict keys a backend uses to report whether a delete succeeded, and the
#: values that mean it did not. Qdrant returns ``{"status": <UpdateStatus>}``
#: and Pinecone ``{"deleted": True}``; neither is a bool, so a bare
#: ``result is False`` check would call every dict a success.
_DELETE_FAILURE_MARKERS = {
"deleted": (False,),
"success": (False,),
"ok": (False,),
"acknowledged": (False,),
"status": ("failed", "error", "failure"),
}
def _interpret_delete_result(result: Any) -> Tuple[bool, Optional[str]]:
"""Decide whether a backend's delete return value reports success.
Returns ``(accepted, detail)``, where ``detail`` is a serializable
rendering of the backend's own response to keep in the receipt (``None``
when there was nothing worth recording).
``None`` counts as accepted: a delete implemented as a void method returns
it on success, and reporting ``failed`` there would be a false alarm --
the opposite of the honesty this module is for, in the other direction.
"""
if result is None:
return True, None
if isinstance(result, bool):
return result, None
if isinstance(result, dict):
rendered = {key: _stringify(value) for key, value in result.items()}
for key, failure_values in _DELETE_FAILURE_MARKERS.items():
if key in result and _is_failure_value(result[key], failure_values):
return False, rendered
return True, rendered
# Anything else (a count, a client response object) is taken at face value;
# there is no cross-backend contract to interpret it against.
return True, _stringify(result)
def _is_failure_value(value: Any, failure_values: Tuple[Any, ...]) -> bool:
"""True when a backend's marker value says the delete did not happen.
Bools are matched by identity so a ``0`` count is not read as ``False``.
String markers are matched as substrings of the rendered value, because a
backend may return an enum whose ``str()`` is ``"UpdateStatus.FAILED"``
rather than a bare ``"failed"``.
"""
for failure in failure_values:
if isinstance(failure, bool):
if value is failure:
return True
elif failure in str(value).lower():
return True
return False
def _stringify(value: Any) -> Any:
"""Render a backend payload value so the receipt stays serializable.
Qdrant's status is an enum, which would make ``to_dict()`` output
unserializable as the audit record it is meant to be.
"""
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return str(value)
def _vector_delete_capability(store: Any) -> Tuple[Optional[str], Any]:
"""Find the delete method to call, and the object to call it on.
Returns ``(None, target)`` when no delete surface exists, which is the
``unsupported`` case.
The ``VectorStore`` facade declares ``delete_vectors()`` for every backend
and only raises ``NotImplementedError`` once called, so probing the facade
alone cannot tell a deletable backend from a delete-less one -- hence the
look at the backend it wraps. Probing rather than calling-and-catching also
keeps a missing method distinguishable from an ``AttributeError`` raised
*inside* a working one, which is exactly where guessing wrong would produce
a false clean bill of health.
"""
target = getattr(store, "_backend_store", None) or store
for name in ("delete_vectors", "delete"):
if callable(getattr(target, name, None)):
return name, target
return None, target
def _vector_backend_name(store: Any) -> str:
"""Best-effort backend label for the receipt."""
backend = getattr(store, "backend", None)
if isinstance(backend, str) and backend:
return backend
inner = getattr(store, "_backend_store", None)
return type(inner if inner is not None else store).__name__
def _incident_edge_count(graph: Any, node_id: str) -> int:
"""Count edges touching ``node_id`` through the graph's public API."""
find_edges = getattr(graph, "find_edges", None)
if not callable(find_edges):
return 0
return sum(
1
for edge in find_edges()
if edge.get("source") == node_id or edge.get("target") == node_id
)
+99 -1
View File
@@ -233,6 +233,31 @@ async def import_file(
)
#: Aliases kept consistent with `mcp/tools/export.py::_FORMAT_ALIASES` and
#: `RDFExporter._format_aliases` to ensure the two surfaces agree on format names.
#: Maps user-provided format strings to RDFExporter's canonical format names.
_RDF_FORMATS: dict[str, str] = {
"ttl": "turtle",
"turtle": "turtle",
"nt": "ntriples", # RDFExporter canonical is "ntriples", not "nt"
"ntriples": "ntriples",
"n-triples": "ntriples",
"xml": "rdfxml", # RDFExporter canonical is "rdfxml", not "xml"
"rdfxml": "rdfxml",
"rdf-xml": "rdfxml",
"json-ld": "jsonld", # RDFExporter canonical is "jsonld", not "json-ld"
"jsonld": "jsonld",
}
#: Media type and file extension per RDFExporter canonical format name.
_RDF_MEDIA_TYPES: dict[str, tuple[str, str]] = {
"turtle": ("text/turtle", "ttl"),
"ntriples": ("application/n-triples", "nt"),
"rdfxml": ("application/rdf+xml", "rdf"),
"jsonld": ("application/ld+json", "jsonld"),
}
@router.post("/api/export")
async def export_graph(
body: ExportRequest,
@@ -267,8 +292,81 @@ async def export_graph(
content = output.getvalue()
media_type = "text/csv"
extension = "csv"
elif fmt in _RDF_FORMATS:
# Reuses `semantica.export`, the same exporters the MCP `export_graph` tool calls.
# Before this, the Explorer answered 422 for every RDF format while the MCP surface
# offered them, so a graph could be loaded as JSON-LD and never exported back — the
# round trip had to leave the product. See #1131.
try:
from semantica.export import RDFExporter
from semantica.utils.exceptions import ValidationError
except ImportError as exc: # pragma: no cover - optional dependency
raise HTTPException(
status_code=503,
detail=f"RDF export unavailable: {exc}",
) from exc
try:
content = RDFExporter().export_to_rdf(graph_dict, format=_RDF_FORMATS[fmt])
except ValidationError as exc:
# Data validation or serialization failed
raise HTTPException(
status_code=422,
detail=f"RDF export failed: {exc}",
) from exc
except Exception as exc:
# Unexpected error during export
logger.exception("RDF export failed unexpectedly")
raise HTTPException(
status_code=500,
detail=f"RDF export error: {exc}",
) from exc
media_type, extension = _RDF_MEDIA_TYPES[_RDF_FORMATS[fmt]]
elif fmt == "graphml":
# GraphML support using GraphExporter (not GraphMLExporter which doesn't exist)
try:
from semantica.export import GraphExporter
from semantica.utils.exceptions import ValidationError
except ImportError as exc: # pragma: no cover - optional dependency
raise HTTPException(
status_code=503,
detail=f"GraphML export unavailable: {exc}",
) from exc
try:
# GraphExporter.export() writes to file, but we need string content for HTTP response.
# Use a temporary file that is automatically cleaned up.
import tempfile
from pathlib import Path
# Create temp file in a secure directory with automatic cleanup on exception
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) / "export.graphml"
exporter = GraphExporter(format="graphml")
exporter.export(graph_dict, file_path=tmp_path)
content = tmp_path.read_text(encoding='utf-8')
except ValidationError as exc:
raise HTTPException(
status_code=422,
detail=f"GraphML export failed: {exc}",
) from exc
except Exception as exc:
logger.exception("GraphML export failed unexpectedly")
raise HTTPException(
status_code=500,
detail=f"GraphML export error: {exc}",
) from exc
media_type, extension = "application/xml", "graphml"
else:
raise HTTPException(status_code=422, detail=f"Unsupported export format '{fmt}'")
raise HTTPException(
status_code=422,
detail=(
f"Unsupported export format '{fmt}'. "
f"Supported: {', '.join(sorted({'json', 'csv', 'graphml'} | set(_RDF_FORMATS)))}"
),
)
return Response(
content=content,
+46 -7
View File
@@ -10,28 +10,53 @@ Supported Providers:
- OpenAI: OpenAI API (GPT-3.5, GPT-4, etc.)
- HuggingFaceLLM: HuggingFace Transformers for local LLM inference
- LiteLLM: Unified interface to 100+ LLM providers (OpenAI, Anthropic, Groq, Azure, Bedrock, Vertex AI, etc.)
- Anthropic: Anthropic Claude API (Claude sonnet, Opus, Haiku, etc.)
- Gemini: Google Gemini API
- Ollama: Local models served through Ollama
- DeepSeek: DeepSeek's OpenAI-compatible API
- Novita: Novita AI's OpenAI-compatible API
Example Usage:
>>> from semantica.llms import Groq, OpenAI, HuggingFaceLLM, LiteLLM
>>>
>>> from semantica.llms import Groq, OpenAI, HuggingFaceLLM, LiteLLM, Anthropic
>>>
>>> # Groq provider
>>> groq = Groq(model="llama-3.1-8b-instant", api_key="your-key")
>>> response = groq.generate("Hello, world!")
>>>
>>>
>>> # OpenAI provider
>>> openai = OpenAI(model="gpt-4", api_key="your-key")
>>> response = openai.generate("Hello, world!")
>>>
>>>
>>> # HuggingFace LLM provider
>>> hf = HuggingFaceLLM(model_name="gpt2")
>>> response = hf.generate("Hello, world!")
>>>
>>>
>>> # LiteLLM provider (supports 100+ LLMs)
>>> llm = LiteLLM(model="openai/gpt-4o", api_key="your-key")
>>> response = llm.generate("Hello, world!")
>>> # Or use other providers via LiteLLM
>>> llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
>>> response = llm.generate("Hello, world!")
>>>
>>> # Anthropic provider
>>> claude = Anthropic(model="claude-sonnet-4-6", api_key="the-key")
>>> response = claude.generate("Hello, world!")
>>>
>>> # Gemini provider
>>> gemini = Gemini(model="gemini-pro", api_key="your-key")
>>> response = gemini.generate("Hello, world!")
>>>
>>> # Ollama provider (local, no api_key)
>>> ollama = Ollama(model="llama2")
>>> response = ollama.generate("Hello, world!")
>>>
>>> # DeepSeek provider
>>> deepseek = DeepSeek(model="deepseek-chat", api_key="your-key")
>>> response = deepseek.generate("Hello, world!")
>>>
>>> # Novita provider
>>> novita = Novita(model="deepseek/deepseek-v3.2", api_key="your-key")
>>> response = novita.generate("Hello, world!")
Author: Semantica Contributors
License: MIT
@@ -41,6 +66,20 @@ from .groq import Groq
from .openai import OpenAI
from .huggingface import HuggingFaceLLM
from .litellm import LiteLLM
from .anthropic import Anthropic
from .gemini import Gemini
from .ollama import Ollama
from .deepseek import DeepSeek
from .novita import Novita
__all__ = ["Groq", "OpenAI", "HuggingFaceLLM", "LiteLLM"]
__all__ = [
"Groq",
"OpenAI",
"HuggingFaceLLM",
"LiteLLM",
"Anthropic",
"Gemini",
"Ollama",
"DeepSeek",
"Novita",
]
+111
View File
@@ -0,0 +1,111 @@
"""
Anthropic LLM Provider
Wrapper for Anthropic Claude API provider with clean interface
"""
from typing import Any, Dict, List, Optional, Union
from ..semantic_extract.providers import AnthropicProvider
from ..utils.exceptions import ProcessingError
from ..utils.logging import get_logger
logger = get_logger("llms.anthropic")
class Anthropic:
"""
Anthropic Claude LLM provider wrapper.
Provides clean interface to Anthropic's Claude API.
Example:
>>> from semantica.llms import Anthropic
>>> claude = Anthropic(model="claude-sonnet-4-6", api_key="the-key")
>>> response = claude.generate("What is API key?")
"""
def __init__(
self,
model: str = "claude-sonnet-4-6",
api_key: Optional[str] = None,
**kwargs
):
"""
Initialize Anthropic provider.
Args:
model: Model name (default: claude-sonnet-4-6)
api_key: Anthropic API key (default: from ANTHROPIC_API_KEY env var)
**kwargs: Additional provider options
"""
self.provider = AnthropicProvider(api_key=api_key, model=model, **kwargs)
self.model = model
self.api_key = api_key
def is_available(self) -> bool:
"""Check if Anthropic provider is available."""
return self.provider.is_available()
def generate(self, prompt: str, **kwargs) -> str:
"""
Generate text from prompt.
Args:
prompt: Input prompt text
**kwargs: Generation options (temperature, max_tokens, etc.)
Returns:
Generated text response
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Anthropic provider not available. Set ANTHROPIC_API_KEY or pass api_key."
)
return self.provider.generate(prompt, **kwargs)
def generate_structured(self, prompt: str, **kwargs) -> Union[Dict[str, Any], List[Any]]:
"""
Generates structured JSON output.
Args:
prompt: Input prompt text
**kwargs: Generation options
Returns:
Parsed JSON response. A dict for a top-level JSON object, or a
list if the model returns a top-level JSON array.
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Anthropic provider not available. Set ANTHROPIC_API_KEY or pass api_key."
)
return self.provider.generate_structured(prompt, **kwargs)
def generate_typed(self, prompt: str, schema: Any, max_retries: int = 3, **kwargs) -> Any:
"""
Generate output validated against a Pydantic schema.
Args:
prompt: Input prompt text
schema: Pydantic model class to validate the output against
max_retries: Number of retries if validation fails (default: 3)
**kwargs: Generation options
Returns:
An instance of `schema`, populated from the model's response
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Anthropic provider not available. Set ANTHROPIC_API_KEY or pass api_key."
)
return self.provider.generate_typed(prompt, schema, max_retries=max_retries, **kwargs)
+111
View File
@@ -0,0 +1,111 @@
"""
DeepSeek LLM Provider
Wrapper for DeepSeek API provider with clean interface.
"""
from typing import Any, Dict, List, Optional, Union
from ..semantic_extract.providers import DeepSeekProvider
from ..utils.exceptions import ProcessingError
from ..utils.logging import get_logger
logger = get_logger("llms.deepseek")
class DeepSeek:
"""
DeepSeek LLM provider wrapper.
Provides clean interface to DeepSeek's OpenAI-compatible API.
Example:
>>> from semantica.llms import DeepSeek
>>> llm = DeepSeek(model="deepseek-chat", api_key="your-key")
>>> response = llm.generate("What is AI?")
"""
def __init__(
self,
model: str = "deepseek-chat",
api_key: Optional[str] = None,
**kwargs
):
"""
Initialize DeepSeek provider.
Args:
model: Model name (default: "deepseek-chat")
api_key: DeepSeek API key (default: from DEEPSEEK_API_KEY env var)
**kwargs: Additional provider options
"""
self.provider = DeepSeekProvider(api_key=api_key, model=model, **kwargs)
self.model = model
self.api_key = api_key
def is_available(self) -> bool:
"""Check if DeepSeek provider is available."""
return self.provider.is_available()
def generate(self, prompt: str, **kwargs) -> str:
"""
Generate text from prompt.
Args:
prompt: Input prompt text
**kwargs: Generation options (temperature, max_tokens, etc.)
Returns:
Generated text response
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"DeepSeek provider not available. Set DEEPSEEK_API_KEY or pass api_key."
)
return self.provider.generate(prompt, **kwargs)
def generate_structured(self, prompt: str, **kwargs) -> Union[Dict[str, Any], List[Any]]:
"""
Generate structured JSON output.
Args:
prompt: Input prompt text
**kwargs: Generation options
Returns:
Parsed JSON response. A dict for a top-level JSON object, or a
list if the model returns a top-level JSON array.
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"DeepSeek provider not available. Set DEEPSEEK_API_KEY or pass api_key."
)
return self.provider.generate_structured(prompt, **kwargs)
def generate_typed(self, prompt: str, schema: Any, max_retries: int = 3, **kwargs) -> Any:
"""
Generate output validated against a Pydantic schema.
Args:
prompt: Input prompt text
schema: Pydantic model class to validate the output against
max_retries: Number of retries if validation fails (default: 3)
**kwargs: Generation options
Returns:
An instance of `schema`, populated from the model's response
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"DeepSeek provider not available. Set DEEPSEEK_API_KEY or pass api_key."
)
return self.provider.generate_typed(prompt, schema, max_retries=max_retries, **kwargs)
+111
View File
@@ -0,0 +1,111 @@
"""
Gemini LLM Provider
Wrapper for Google Gemini API provider with clean interface.
"""
from typing import Any, Dict, List, Optional, Union
from ..semantic_extract.providers import GeminiProvider
from ..utils.exceptions import ProcessingError
from ..utils.logging import get_logger
logger = get_logger("llms.gemini")
class Gemini:
"""
Google Gemini LLM provider wrapper.
Provides clean interface to Google's Gemini API.
Example:
>>> from semantica.llms import Gemini
>>> gemini = Gemini(model="gemini-pro", api_key="your-key")
>>> response = gemini.generate("What is AI?")
"""
def __init__(
self,
model: str = "gemini-pro",
api_key: Optional[str] = None,
**kwargs
):
"""
Initialize Gemini provider.
Args:
model: Model name (default: "gemini-pro")
api_key: Gemini API key (default: from GEMINI_API_KEY env var)
**kwargs: Additional provider options
"""
self.provider = GeminiProvider(api_key=api_key, model=model, **kwargs)
self.model = model
self.api_key = api_key
def is_available(self) -> bool:
"""Check if Gemini provider is available."""
return self.provider.is_available()
def generate(self, prompt: str, **kwargs) -> str:
"""
Generate text from prompt.
Args:
prompt: Input prompt text
**kwargs: Generation options (temperature, max_tokens, etc.)
Returns:
Generated text response
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Gemini provider not available. Set GEMINI_API_KEY or pass api_key."
)
return self.provider.generate(prompt, **kwargs)
def generate_structured(self, prompt: str, **kwargs) -> Union[Dict[str, Any], List[Any]]:
"""
Generate structured JSON output.
Args:
prompt: Input prompt text
**kwargs: Generation options
Returns:
Parsed JSON response. A dict for a top-level JSON object, or a
list if the model returns a top-level JSON array.
Raises:
ProcessingError: If provider is not available or parsing fails
"""
if not self.is_available():
raise ProcessingError(
"Gemini provider not available. Set GEMINI_API_KEY or pass api_key."
)
return self.provider.generate_structured(prompt, **kwargs)
def generate_typed(self, prompt: str, schema: Any, max_retries: int = 3, **kwargs) -> Any:
"""
Generate output validated against a Pydantic schema.
Args:
prompt: Input prompt text
schema: Pydantic model class to validate the output against
max_retries: Number of retries if validation fails (default: 3)
**kwargs: Generation options
Returns:
An instance of `schema`, populated from the model's response
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Gemini provider not available. Set GEMINI_API_KEY or pass api_key."
)
return self.provider.generate_typed(prompt, schema, max_retries=max_retries, **kwargs)
+111
View File
@@ -0,0 +1,111 @@
"""
Novita LLM Provider
Wrapper for Novita AI's OpenAI-compatible API with clean interface.
"""
from typing import Any, Dict, List, Optional, Union
from ..semantic_extract.providers import NovitaProvider
from ..utils.exceptions import ProcessingError
from ..utils.logging import get_logger
logger = get_logger("llms.novita")
class Novita:
"""
Novita AI LLM provider wrapper.
Provides clean interface to Novita's OpenAI-compatible API.
Example:
>>> from semantica.llms import Novita
>>> llm = Novita(model="deepseek/deepseek-v3.2", api_key="your-key")
>>> response = llm.generate("What is AI?")
"""
def __init__(
self,
model: str = "deepseek/deepseek-v3.2",
api_key: Optional[str] = None,
**kwargs
):
"""
Initialize Novita provider.
Args:
model: Model name (default: "deepseek/deepseek-v3.2")
api_key: Novita API key (default: from NOVITA_API_KEY env var)
**kwargs: Additional provider options
"""
self.provider = NovitaProvider(api_key=api_key, model=model, **kwargs)
self.model = model
self.api_key = api_key
def is_available(self) -> bool:
"""Check if Novita provider is available."""
return self.provider.is_available()
def generate(self, prompt: str, **kwargs) -> str:
"""
Generate text from prompt.
Args:
prompt: Input prompt text
**kwargs: Generation options (temperature, max_tokens, etc.)
Returns:
Generated text response
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Novita provider not available. Set NOVITA_API_KEY or pass api_key."
)
return self.provider.generate(prompt, **kwargs)
def generate_structured(self, prompt: str, **kwargs) -> Union[Dict[str, Any], List[Any]]:
"""
Generate structured JSON output.
Args:
prompt: Input prompt text
**kwargs: Generation options
Returns:
Parsed JSON response. A dict for a top-level JSON object, or a
list if the model returns a top-level JSON array.
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Novita provider not available. Set NOVITA_API_KEY or pass api_key."
)
return self.provider.generate_structured(prompt, **kwargs)
def generate_typed(self, prompt: str, schema: Any, max_retries: int = 3, **kwargs) -> Any:
"""
Generate output validated against a Pydantic schema.
Args:
prompt: Input prompt text
schema: Pydantic model class to validate the output against
max_retries: Number of retries if validation fails (default: 3)
**kwargs: Generation options
Returns:
An instance of `schema`, populated from the model's response
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Novita provider not available. Set NOVITA_API_KEY or pass api_key."
)
return self.provider.generate_typed(prompt, schema, max_retries=max_retries, **kwargs)
+116
View File
@@ -0,0 +1,116 @@
"""
Ollama LLM Provider
Wrapper for local Ollama models with clean interface.
"""
from typing import Any, Dict, List, Union
from ..semantic_extract.providers import OllamaProvider
from ..utils.exceptions import ProcessingError
from ..utils.logging import get_logger
logger = get_logger("llms.ollama")
class Ollama:
"""
Ollama LLM provider wrapper.
Provides clean interface to a local Ollama server. Unlike the other
providers here, this one has no API key. It talks to an Ollama
instance over HTTP, so make sure `ollama serve` is running first.
Example:
>>> from semantica.llms import Ollama
>>> llm = Ollama(model="llama2")
>>> response = llm.generate("What is AI?")
"""
def __init__(
self,
model: str = "llama2",
base_url: str = "http://localhost:11434",
**kwargs
):
"""
Initialize Ollama provider.
Args:
model: Model name (default: "llama2")
base_url: Ollama server URL (default: "http://localhost:11434")
**kwargs: Additional provider options
"""
self.provider = OllamaProvider(base_url=base_url, model=model, **kwargs)
self.model = model
self.base_url = base_url
def is_available(self) -> bool:
"""Check if Ollama provider is available."""
return self.provider.is_available()
def generate(self, prompt: str, **kwargs) -> str:
"""
Generate text from prompt.
Args:
prompt: Input prompt text
**kwargs: Generation options (temperature, max_tokens, etc.)
Returns:
Generated text response
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Ollama provider not available. Make sure Ollama is running "
"and reachable at the configured base_url."
)
return self.provider.generate(prompt, **kwargs)
def generate_structured(self, prompt: str, **kwargs) -> Union[Dict[str, Any], List[Any]]:
"""
Generate structured JSON output.
Args:
prompt: Input prompt text
**kwargs: Generation options
Returns:
Parsed JSON response. A dict for a top-level JSON object, or a
list if the model returns a top-level JSON array.
Raises:
ProcessingError: If provider is not available or parsing fails
"""
if not self.is_available():
raise ProcessingError(
"Ollama provider not available. Make sure Ollama is running "
"and reachable at the configured base_url."
)
return self.provider.generate_structured(prompt, **kwargs)
def generate_typed(self, prompt: str, schema: Any, max_retries: int = 3, **kwargs) -> Any:
"""
Generate output validated against a Pydantic schema.
Args:
prompt: Input prompt text
schema: Pydantic model class to validate the output against
max_retries: Number of retries if validation fails (default: 3)
**kwargs: Generation options
Returns:
An instance of `schema`, populated from the model's response
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Ollama provider not available. Make sure Ollama is running "
"and reachable at the configured base_url."
)
return self.provider.generate_typed(prompt, schema, max_retries=max_retries, **kwargs)
+20
View File
@@ -148,6 +148,26 @@ class ClassInferrer:
entity_type = entity.get("type") or entity.get("entity_type", "Entity")
entity_types[entity_type].append(entity)
normalized_types = defaultdict(list)
for entity_type, type_entities in entity_types.items():
if len(type_entities) >= self.min_occurrences:
normalized_name = self.naming_conventions.normalize_class_name(
str(entity_type)
)
normalized_types[normalized_name].append(str(entity_type))
collisions = {
normalized_name: source_types
for normalized_name, source_types in normalized_types.items()
if len(source_types) > 1
}
if collisions:
raise ValidationError(
"Entity types normalize to duplicate class names; "
"rename the source types or provide an explicit mapping.",
validation_context={"normalized_type_collisions": collisions},
)
# Infer classes from entity types
self.progress_tracker.update_tracking(
tracking_id,
+244 -92
View File
@@ -41,69 +41,134 @@ from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from .reasoner import Fact, Rule, _make_activation_key
logger = get_logger("rete_engine")
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.
def _build_condition_regex(
pattern: str,
initial_bindings: Optional[Dict[str, str]] = None,
) -> str:
"""Build an anchored regex string for a condition pattern.
Returns an empty dict when the condition is not a string pattern or does
not match -- callers treat that as "no bindings extracted".
Splits the pattern on ``?var`` placeholders, escaping the literal
segments so surrounding parentheses/commas match literally. Variables
become named groups (or backreferences when repeated); variables already
present in ``initial_bindings`` are inlined as their literal value.
Args:
pattern: The condition pattern string (e.g. ``"Person(?x)"``).
initial_bindings: Bindings already established upstream. Variables
already bound are matched as literals rather than captured.
Returns:
An anchored regex string (``^...$``) suitable for ``re.compile`` /
``re.match``.
"""
if not isinstance(condition, str):
return {}
segments = re.split(r"(\?\w+)", condition)
bindings = initial_bindings or {}
segments = re.split(r"(\?\w+)", pattern)
seen_vars: Set[str] = set()
p_regex = ""
for seg in segments:
if seg.startswith("?"):
var_name = seg[1:]
if var_name in seen_vars:
if var_name in bindings:
# Already bound — require the exact literal value.
p_regex += re.escape(bindings[var_name])
elif var_name in seen_vars:
# Same variable used twice — enforce a backreference.
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}$"
return f"^{p_regex}$"
def unify_condition(
condition: Any,
fact: Fact,
initial_bindings: Optional[Dict[str, str]] = None,
) -> Optional[Dict[str, str]]:
"""Unify a condition pattern against a fact.
A condition is a pattern string such as ``"Person(?x)"`` or
``"knows(?x, ?y)"`` where tokens beginning with ``?`` are variables.
The fact is rendered via its ``__str__`` representation
(``predicate(arg1, arg2)``) and matched against the pattern.
This mirrors ``Reasoner._match_pattern`` but is self-contained so the
RETE engine does not need a live ``Reasoner`` instance.
Args:
condition: The condition pattern (string). Non-string conditions
are stringified before matching.
fact: The fact to test.
initial_bindings: Bindings already established upstream. Variables
already bound must match the corresponding literal in the fact.
Returns:
A dict of variable bindings if the fact unifies with the condition,
otherwise ``None``.
"""
bindings = dict(initial_bindings or {})
pattern = condition if isinstance(condition, str) else str(condition)
fact_str = str(fact)
# Build the anchored regex once (variables already bound are inlined as
# literals). See ``_build_condition_regex`` for the segment handling.
p_regex = _build_condition_regex(pattern, bindings)
try:
match = re.match(p_regex, str(fact))
except re.error:
return {}
match = re.match(p_regex, fact_str)
except re.error as e:
logger.warning(
"unify_condition failed to compile/match condition "
"%r (regex: %r) against fact %r: %s",
pattern,
p_regex,
fact_str,
e,
)
return None
except Exception as e: # noqa: BLE001 - mirror Reasoner._match_pattern
logger.warning(
"unify_condition unexpected error matching condition "
"%r (regex: %r) against fact %r: %s",
pattern,
p_regex,
fact_str,
e,
)
return None
if not match:
return {}
return {k: v for k, v in match.groupdict().items() if v is not None}
return 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
for var, value in match.groupdict().items():
if var in bindings and bindings[var] != value:
return None # Binding conflict.
bindings[var] = value
return bindings
@dataclass
class Token:
"""A partial match flowing through the Rete network.
A token represents an ordered collection of concrete facts that have
been unified so far, together with the consistent variable bindings
accumulated across those facts.
Alpha nodes emit single-fact tokens. Beta nodes merge a left token and
a right token into a new token whose ``facts`` are the concatenation of
both sides (preserving condition order) and whose ``bindings`` are the
consistent union of both sides.
"""
facts: List[Fact] = field(default_factory=list)
bindings: Dict[str, str] = field(default_factory=dict)
@dataclass
class Match:
"""Pattern match."""
@@ -128,19 +193,68 @@ class AlphaNode(ReteNode):
def __init__(self, node_id: str, condition: Any):
super().__init__(node_id)
self.condition = condition
self.matches: List[Fact] = []
# Single-fact tokens produced by unifying each matched fact with
# this node's condition.
self.tokens: List[Token] = []
# Pre-compile the condition regex once. Alpha nodes never have
# initial bindings, so the pattern is stable for the node's lifetime
# and every incoming fact reuses this compiled matcher instead of
# rebuilding it (avoids repeated regex construction overhead).
pattern = condition if isinstance(condition, str) else str(condition)
self._compiled: Optional[re.Pattern] = None
try:
self._compiled = re.compile(_build_condition_regex(pattern))
except re.error as e:
logger.warning(
"AlphaNode %r failed to compile condition %r: %s; "
"node will never match",
node_id,
pattern,
e,
)
def add_fact(self, fact: Fact) -> bool:
"""Add fact if it matches condition."""
if self._matches(fact):
self.matches.append(fact)
return True
return False
def add_fact(self, fact: Fact) -> Optional[Token]:
"""Add fact if it matches the condition, returning its token.
def _matches(self, fact: Fact) -> bool:
"""Check if fact matches condition."""
# Simple matching - can be enhanced
return True
Returns the single-fact ``Token`` produced by unification when the
fact matches, otherwise ``None``.
"""
bindings = self._matches(fact)
if bindings is not None:
token = Token(facts=[fact], bindings=dict(bindings))
self.tokens.append(token)
return token
return None
def _matches(self, fact: Fact) -> Optional[Dict[str, str]]:
"""Check if fact matches the alpha node condition.
Uses the pre-compiled regex built in ``__init__`` for performance,
since RETE evaluates many facts against every alpha node.
Returns the variable bindings produced by unification if the fact
matches, otherwise ``None``. An empty dict signals a match with no
variables (still distinct from ``None``).
"""
if self._compiled is None:
# Compilation failed at build time; treat as non-matching.
return None
fact_str = str(fact)
try:
match = self._compiled.match(fact_str)
except Exception as e: # noqa: BLE001 - mirror unify_condition
logger.warning(
"AlphaNode %r unexpected error matching condition "
"%r against fact %r: %s",
self.node_id,
self.condition,
fact_str,
e,
)
return None
if not match:
return None
return match.groupdict()
class BetaNode(ReteNode):
@@ -150,19 +264,28 @@ class BetaNode(ReteNode):
super().__init__(node_id)
self.left = left
self.right = right
self.matches: List[Tuple[Fact, Fact]] = []
# Token memories for each side. Incoming tokens are stored here so
# that later-arriving tokens on the opposite side can be joined
# against every token already seen (chained joins).
self.left_tokens: List[Token] = []
self.right_tokens: List[Token] = []
def join(self, left_fact: Fact, right_fact: Fact) -> bool:
"""Join facts from left and right nodes."""
if self._can_join(left_fact, right_fact):
self.matches.append((left_fact, right_fact))
return True
return False
def join(self, left_token: Token, right_token: Token) -> Optional[Token]:
"""Join a left token with a right token.
def _can_join(self, left_fact: Fact, right_fact: Fact) -> bool:
"""Check if facts can be joined."""
# Simple join logic - can be enhanced
return True
Returns a new merged ``Token`` (facts concatenated in condition
order, bindings unified) when the two tokens are consistent,
otherwise ``None`` on a binding conflict.
"""
merged = dict(left_token.bindings)
for var, value in right_token.bindings.items():
if var in merged and merged[var] != value:
return None # Binding conflict — cannot join.
merged[var] = value
return Token(
facts=list(left_token.facts) + list(right_token.facts),
bindings=merged,
)
class TerminalNode(ReteNode):
@@ -248,12 +371,16 @@ class ReteEngine:
self._add_rule_to_network(rule)
self.logger.info(
f"Built Rete network with {len(self.network)} nodes for {len(rules)} rules"
f"Built Rete network with {len(self.network)} nodes "
f"for {len(rules)} rules"
)
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message=f"Built Rete network with {len(self.network)} nodes for {len(rules)} rules",
message=(
f"Built Rete network with {len(self.network)} nodes "
f"for {len(rules)} rules"
),
)
except Exception as e:
@@ -281,6 +408,10 @@ class ReteEngine:
self.node_counter += 1
beta_node = BetaNode(node_id, current, alpha_nodes[i])
self.network[node_id] = beta_node
# Wire the beta node as a child of both its inputs so facts
# propagating from either side reach the join.
current.children.append(beta_node)
alpha_nodes[i].children.append(beta_node)
current = beta_node
final_node = current
else:
@@ -311,40 +442,58 @@ class ReteEngine:
# Find matching alpha nodes
for node_id, node in self.network.items():
if isinstance(node, AlphaNode):
if node.add_fact(fact):
# Propagate to children
self._propagate_from_alpha(node, fact)
token = node.add_fact(fact)
if token is not None:
# Propagate the single-fact token to children.
self._propagate_token(node, token)
def _propagate_from_alpha(self, alpha_node: AlphaNode, fact: Fact) -> None:
"""Propagate from alpha node to children."""
for child in alpha_node.children:
def _propagate_token(self, source: ReteNode, token: Token) -> None:
"""Propagate ``token`` (arriving from ``source``) to its children.
A ``Token`` carries the ordered facts and consistent bindings of a
partial match. Beta children attempt joins and, on success, emit a
new merged token downstream; terminal children turn the token into a
rule activation using the token's complete facts and bindings.
"""
for child in source.children:
if isinstance(child, BetaNode):
# Join with matches from left side
for left_fact in alpha_node.matches:
if child.join(left_fact, fact):
# Propagate to children
for grandchild in child.children:
if isinstance(grandchild, TerminalNode):
facts = [left_fact, fact]
match = Match(
rule=grandchild.rule,
facts=facts,
bindings=_bindings_for_rule(
grandchild.rule, facts
),
confidence=1.0,
)
grandchild.activate(match)
self._propagate_to_beta(child, source, token)
elif isinstance(child, TerminalNode):
# Direct activation
match = Match(
rule=child.rule,
facts=[fact],
bindings=_bindings_for_rule(child.rule, [fact]),
facts=list(token.facts),
bindings=dict(token.bindings),
confidence=1.0,
)
child.activate(match)
def _propagate_to_beta(
self,
beta: "BetaNode",
source: ReteNode,
token: Token,
) -> None:
"""Attempt joins at ``beta`` for a token arriving from one side.
The incoming token is stored in the corresponding side's memory,
then joined against every token already recorded on the opposite
side. Each successful join produces a new merged token that is
propagated further downstream, enabling correct chained joins across
three or more conditions.
"""
if source is beta.left:
beta.left_tokens.append(token)
for right_token in list(beta.right_tokens):
merged = beta.join(token, right_token)
if merged is not None:
self._propagate_token(beta, merged)
elif source is beta.right:
beta.right_tokens.append(token)
for left_token in list(beta.left_tokens):
merged = beta.join(left_token, token)
if merged is not None:
self._propagate_token(beta, merged)
def match_patterns(self, facts: Optional[List[Fact]] = None) -> List[Match]:
"""
Match patterns using Rete algorithm.
@@ -468,8 +617,11 @@ class ReteEngine:
self.facts.clear()
self.reset_action_history()
for node in self.network.values():
if isinstance(node, AlphaNode) or isinstance(node, BetaNode):
node.matches.clear()
if isinstance(node, AlphaNode):
node.tokens.clear()
elif isinstance(node, BetaNode):
node.left_tokens.clear()
node.right_tokens.clear()
elif isinstance(node, TerminalNode):
node.activations.clear()
+53 -8
View File
@@ -673,6 +673,7 @@ class GeminiProvider(BaseProvider):
self.model = model
self.client = None
self._use_new_genai = False
self._legacy_model_cache: Dict[str, Any] = {}
self._init_client()
def _init_client(self):
@@ -694,6 +695,38 @@ class GeminiProvider(BaseProvider):
self.client = None
self.logger.warning("Gemini SDK not installed. Install with: pip install semantica[llm-gemini]")
def _legacy_client_for(self, requested_model: str):
"""Return a legacy-SDK GenerativeModel bound to this instance's own
API key, for the given model name.
The legacy google-generativeai package keeps its API key as
module-level state (genai.configure()), so any GenerativeModel built
by a different GeminiProvider instance in the same process can leave
that state pointing at a different key. Re-asserting configure()
with this instance's key right before use, instead of only once at
construction, keeps sequential calls across instances from reading
each other's credentials. A cache keyed by model name avoids
rebuilding a GenerativeModel on every call for the common case of
one model being reused.
"""
try:
import google.generativeai as old_genai
old_genai.configure(api_key=self.api_key)
except Exception:
# _init_client() already required this import to reach the
# legacy path in the first place, so this only happens when
# self.client was injected directly (tests). Fall back to it
# without reasserting credentials rather than failing calls
# that never needed the real SDK.
return self.client
if requested_model == self.model:
return self.client
cached = self._legacy_model_cache.get(requested_model)
if cached is None:
cached = old_genai.GenerativeModel(requested_model)
self._legacy_model_cache[requested_model] = cached
return cached
def is_available(self) -> bool:
"""Check if provider is available."""
return self.client is not None
@@ -724,7 +757,8 @@ class GeminiProvider(BaseProvider):
)
return self._resp_text(resp)
else:
response = self.client.generate_content(prompt, generation_config=config or None)
legacy_client = self._legacy_client_for(kwargs.get("model", self.model))
response = legacy_client.generate_content(prompt, generation_config=config or None)
return self._resp_text(response)
def generate_structured(self, prompt: str, **kwargs) -> dict:
@@ -733,15 +767,24 @@ class GeminiProvider(BaseProvider):
raise ProcessingError("Gemini client not initialized.")
json_prompt = f"{prompt}\n\nReturn the response as valid JSON only."
config = {}
self._add_if_set(config, kwargs, "temperature", "top_p", "top_k", "stop_sequences", "candidate_count")
if "max_tokens" in kwargs:
config["max_output_tokens"] = kwargs["max_tokens"]
if self._use_new_genai:
model = kwargs.get("model", self.model)
resp = self.client.models.generate_content(model=model, contents=json_prompt)
resp = self.client.models.generate_content(
model=model, contents=json_prompt, config=config or None
)
try:
return self._parse_json(self._resp_text(resp))
except Exception as e:
raise ProcessingError(f"Failed to parse JSON from Gemini response: {e}")
else:
response = self.client.generate_content(json_prompt)
legacy_client = self._legacy_client_for(kwargs.get("model", self.model))
response = legacy_client.generate_content(json_prompt, generation_config=config or None)
try:
return self._parse_json(self._resp_text(response))
except Exception as e:
@@ -967,6 +1010,8 @@ class OllamaProvider(BaseProvider):
def is_available(self) -> bool:
"""Check if provider is available."""
if self.client is None:
self._init_client()
return self.client is not None
def _build_options(self, kwargs: dict) -> Optional[dict]:
@@ -1018,7 +1063,6 @@ class DeepSeekProvider(BaseProvider):
self.api_key = api_key or config.get_api_key("deepseek")
self.base_url = "https://api.deepseek.com/v1"
self.model = model
self.base_url = "https://api.deepseek.com/v1"
self.client = None
self._init_client()
@@ -1045,7 +1089,7 @@ class DeepSeekProvider(BaseProvider):
"model": kwargs.get("model", self.model),
"messages": [{"role": "user", "content": prompt}],
}
self._add_if_set(create_kwargs, kwargs, "temperature", "max_tokens")
self._add_if_set(create_kwargs, kwargs, "temperature", "max_tokens", "top_p", "frequency_penalty", "presence_penalty", "seed", "stop", "user")
response = self.client.chat.completions.create(**create_kwargs)
return response.choices[0].message.content
@@ -1058,8 +1102,9 @@ class DeepSeekProvider(BaseProvider):
create_kwargs = {
"model": kwargs.get("model", self.model),
"messages": [{"role": "user", "content": prompt}],
"response_format": {"type": "json_object"},
}
self._add_if_set(create_kwargs, kwargs, "temperature", "max_tokens")
self._add_if_set(create_kwargs, kwargs, "temperature", "max_tokens", "top_p", "frequency_penalty", "presence_penalty", "seed", "stop", "user")
response = self.client.chat.completions.create(**create_kwargs)
try:
@@ -1103,7 +1148,7 @@ class NovitaProvider(BaseProvider):
"model": kwargs.get("model", self.model),
"messages": [{"role": "user", "content": prompt}],
}
self._add_if_set(create_kwargs, kwargs, "temperature", "max_tokens")
self._add_if_set(create_kwargs, kwargs, "temperature", "max_tokens", "top_p", "frequency_penalty", "presence_penalty", "seed", "stop", "user")
response = self.client.chat.completions.create(**create_kwargs)
return response.choices[0].message.content
@@ -1118,7 +1163,7 @@ class NovitaProvider(BaseProvider):
"messages": [{"role": "user", "content": prompt}],
"response_format": {"type": "json_object"},
}
self._add_if_set(create_kwargs, kwargs, "temperature", "max_tokens")
self._add_if_set(create_kwargs, kwargs, "temperature", "max_tokens", "top_p", "frequency_penalty", "presence_penalty", "seed", "stop", "user")
response = self.client.chat.completions.create(**create_kwargs)
try:
+16 -9
View File
@@ -64,6 +64,8 @@ class SlidingWindowChunker:
raise ValidationError("overlap must be non-negative")
if self.overlap >= self.chunk_size:
raise ValidationError("overlap must be less than chunk_size")
if self.stride <= 0:
raise ValidationError("stride must be positive")
def chunk(self, text: str, **options) -> List[Chunk]:
"""
@@ -215,15 +217,20 @@ class SlidingWindowChunker:
Returns:
list: List of chunks
"""
if overlap_size is None:
return self.chunk(text)
if overlap_size < 0:
raise ValidationError("overlap_size must be non-negative")
if overlap_size >= self.chunk_size:
raise ValidationError("overlap_size must be less than chunk_size")
original_overlap = self.overlap
if overlap_size is not None:
original_stride = self.stride
try:
self.overlap = overlap_size
self.stride = self.chunk_size - self.overlap
chunks = self.chunk(text)
# Restore original overlap
self.overlap = original_overlap
self.stride = self.chunk_size - self.overlap
return chunks
return self.chunk(text)
finally:
self.overlap = original_overlap
self.stride = original_stride
+43 -14
View File
@@ -43,7 +43,7 @@ from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple, Union
from .logging import get_logger
@@ -144,14 +144,37 @@ class ProgressDisplay(ABC):
class ConsoleProgressDisplay(ProgressDisplay):
"""Console progress display with real-time updates."""
def __init__(self, use_emoji: bool = True, update_interval: float = 0.1):
def __init__(
self,
use_emoji: bool = True,
update_interval: float = 0.1,
stream: Optional[TextIO] = None,
):
"""Initialize the console display.
Args:
use_emoji: Whether to decorate output with emoji.
update_interval: Minimum seconds between redraws.
stream: Where progress is written. Defaults to ``sys.stderr``.
Progress is diagnostic output, so stderr is the correct stream
for it, and stdout must stay clean for programs that carry a
machine-readable protocol on it the stdio MCP servers put
newline-delimited JSON-RPC there, and a progress bar on stdout
corrupts that stream.
Left as ``None``, the stream is resolved on each write rather
than captured here, so a later rebinding of ``sys.stderr``
(pytest capture, for instance) is honoured.
"""
self.use_emoji = use_emoji
# Check if stdout supports emojis (especially on Windows)
self._stream = stream
# Check if the target stream supports emojis (especially on Windows)
if self.use_emoji:
try:
# Try encoding a test emoji with stdout's encoding
encoding = getattr(sys.stdout, "encoding", None)
# Try encoding a test emoji with the stream's encoding
encoding = getattr(self.stream, "encoding", None)
if encoding:
"🧠".encode(encoding)
except (UnicodeEncodeError, LookupError, AttributeError):
@@ -162,6 +185,11 @@ class ConsoleProgressDisplay(ProgressDisplay):
self.current_lines: Dict[str, str] = {}
self.lock = threading.Lock()
@property
def stream(self) -> TextIO:
"""The stream progress is written to; ``sys.stderr`` unless overridden."""
return self._stream if self._stream is not None else sys.stderr
def _should_update(self) -> bool:
"""Check if enough time has passed for update."""
now = time.time()
@@ -259,15 +287,16 @@ class ConsoleProgressDisplay(ProgressDisplay):
return f"{base_msg}: {message}"
def _safe_write(self, text: str) -> None:
"""Safely write text to stdout handling encoding errors."""
"""Safely write text to the progress stream handling encoding errors."""
stream = self.stream
try:
sys.stdout.write(text)
stream.write(text)
except UnicodeEncodeError:
# Fallback: encode with replacement and write decoded
# Use ascii as safe fallback if encoding is unknown or caused error
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
encoding = getattr(stream, "encoding", None) or "ascii"
safe_text = text.encode(encoding, errors="replace").decode(encoding)
sys.stdout.write(safe_text)
stream.write(safe_text)
def update(self, item: ProgressItem) -> None:
"""Update console progress display."""
@@ -302,11 +331,11 @@ class ConsoleProgressDisplay(ProgressDisplay):
self._display_item_line(pipeline_item)
self._safe_write("\n")
sys.stdout.flush()
self.stream.flush()
else:
# Original single-item display
self._display_item_line(item)
sys.stdout.flush()
self.stream.flush()
def _display_item_line(self, item: ProgressItem) -> None:
"""Display a single progress item line."""
@@ -468,13 +497,13 @@ class ConsoleProgressDisplay(ProgressDisplay):
f"Completed: {completed} | Failed: {failed} | Total Time: {total_time:.2f}s\n"
)
self._safe_write("=" * 80 + "\n")
sys.stdout.flush()
self.stream.flush()
def clear(self) -> None:
"""Clear console display."""
with self.lock:
self._safe_write("\r" + " " * 100 + "\r")
sys.stdout.flush()
self.stream.flush()
self.current_lines.clear()
+55 -4
View File
@@ -66,12 +66,32 @@ class FAISSIndex:
self.metadata: Dict[str, Dict[str, Any]] = {}
def add_vectors(self, vectors: np.ndarray, ids: Optional[List[str]] = None):
"""Add vectors to index."""
"""
Add vectors to index.
Skips any id already present in vector_ids rather than appending a
second physical vector under the same id. FAISS indices here don't
support removing or replacing a single vector in place, so an
"update" isn't possible; without this check, re-running an add for
ids that already exist (e.g. retrying an interrupted migration)
would silently duplicate vectors under the same id on every retry.
"""
if ids is None:
ids = [f"vec_{i}" for i in range(len(vectors))]
self.index.add(vectors.astype(np.float32))
self.vector_ids.extend(ids)
new_rows = []
new_ids = []
existing = set(self.vector_ids)
for row, vec_id in zip(vectors, ids):
if vec_id in existing:
continue
new_rows.append(row)
new_ids.append(vec_id)
existing.add(vec_id)
if new_rows:
self.index.add(np.array(new_rows, dtype=np.float32))
self.vector_ids.extend(new_ids)
def search(
self, query_vectors: np.ndarray, k: int = 10
@@ -305,6 +325,12 @@ class FAISSStore:
"""
Add vectors to index.
Any id that already exists in the index is skipped rather than
stored as a second physical vector under the same id (see
FAISSIndex.add_vectors), so calling this again with ids from a
previous call is safe and doesn't accumulate duplicates. Metadata
for those ids is still updated.
Args:
vectors: List of vectors or numpy array
ids: Vector IDs
@@ -312,7 +338,8 @@ class FAISSStore:
**options: Additional options
Returns:
List of vector IDs
List of vector IDs (including ids that were already present
and therefore not re-added as new vectors)
"""
num_vectors = len(vectors) if isinstance(vectors, (list, np.ndarray)) else 1
tracking_id = self.progress_tracker.start_tracking(
@@ -526,6 +553,30 @@ class FAISSStore:
return results
def scan_vectors(self, offset: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
"""
Page through stored vectors in insertion order.
Args:
offset: Number of vectors to skip
limit: Maximum number of vectors to return
Returns:
List of result dicts with 'id', 'metadata', and 'vector'
"""
if self.index is None or limit <= 0:
return []
ids_page = self.index.vector_ids[offset:offset + limit]
return [
{
"id": vector_id,
"metadata": self.get_metadata(vector_id) or {},
"vector": self.get_vector(vector_id),
}
for vector_id in ids_page
]
def get_stats(self) -> Dict[str, Any]:
"""Get index statistics."""
if self.index is None:
+46
View File
@@ -656,6 +656,52 @@ class PgVectorStore:
self.logger.warning(f"Failed to get metadata for {vector_id}: {e}")
return None
def scan_vectors(self, offset: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
"""
Page through stored vectors ordered by id.
Args:
offset: Number of rows to skip
limit: Maximum number of rows to return
Returns:
List of result dicts with 'id', 'metadata', and 'vector'
"""
if not PSYCOPG3_AVAILABLE and not PSYCOPG2_AVAILABLE:
raise ProcessingError(
"Neither psycopg3 nor psycopg2 is available. "
"Install with: pip install psycopg[binary] or psycopg2-binary"
)
if limit <= 0:
return []
scan_sql = psycopg_sql.SQL("""
SELECT id, vector, metadata
FROM {}
ORDER BY id
LIMIT %s OFFSET %s
""").format(psycopg_sql.Identifier(self.table_name))
with self._get_connection() as conn:
try:
cur = conn.cursor()
cur.execute(scan_sql, (limit, offset))
rows = cur.fetchall()
cur.close()
results = []
for row in rows:
vec_id, vec, meta = row
results.append({
"id": vec_id,
"metadata": meta if isinstance(meta, dict) else json.loads(meta) if meta else {},
"vector": np.array(vec) if vec is not None else None,
})
return results
except Exception as e:
raise ProcessingError(f"Failed to scan vectors: {str(e)}") from e
def filter_by_metadata(
self, filters: Dict[str, Any], limit: int = 10
) -> List[Dict[str, Any]]:
@@ -616,6 +616,49 @@ class SQLiteVecStore:
self.logger.warning(f"Failed to get metadata for {vector_id}: {e}")
return None
def scan_vectors(self, offset: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
"""
Page through stored vectors ordered by id.
Args:
offset: Number of rows to skip
limit: Maximum number of rows to return
Returns:
List of result dicts with 'id', 'metadata', and 'vector'
"""
if limit <= 0:
return []
query_sql = f"""
SELECT id, embedding, metadata
FROM {self.table_name}
ORDER BY id
LIMIT ? OFFSET ?
"""
with self._lock, self._get_connection() as conn:
try:
cur = conn.cursor()
cur.execute(query_sql, (limit, offset))
rows = cur.fetchall()
cur.close()
results = []
for row in rows:
vec_id, embedding_blob, meta_json = row
vec = None
if embedding_blob:
vec = np.frombuffer(embedding_blob, dtype=np.float32).copy()
results.append({
"id": vec_id,
"metadata": json.loads(meta_json) if meta_json else {},
"vector": vec,
})
return results
except Exception as e:
raise ProcessingError(f"Failed to scan vectors: {str(e)}") from e
def filter_by_metadata(
self, filters: Dict[str, Any], limit: int = 10
) -> List[Dict[str, Any]]:
+58
View File
@@ -824,6 +824,64 @@ class VectorStore:
else:
raise NotImplementedError(f"Backend store {type(self._backend_store).__name__} does not implement get_metadata")
def scan_vectors(self, offset: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
"""
Page through stored vectors, backend-agnostic.
Follows the get_vector()/get_metadata() precedent (#843): the inmemory
backend pages its local dict directly, a persistent backend delegates
to a scan_vectors() on the wrapped store when available, and one that
cannot enumerate its contents raises NotImplementedError rather than
silently returning an empty page.
Args:
offset: Number of vectors to skip
limit: Maximum number of vectors to return
Returns:
List of result dicts with 'id', 'metadata', and 'vector'
"""
if limit <= 0:
return []
if self.backend == "inmemory":
ids_page = list(self.vectors.keys())[offset:offset + limit]
return [
{
"id": vec_id,
"metadata": self.metadata.get(vec_id, {}),
"vector": self.vectors.get(vec_id),
}
for vec_id in ids_page
]
elif self._backend_store and hasattr(self._backend_store, "scan_vectors"):
return self._backend_store.scan_vectors(offset=offset, limit=limit)
else:
raise NotImplementedError(
f"Backend store {type(self._backend_store).__name__} does not "
"implement scan_vectors(). Add a scan_vectors() method to the "
"backend store adapter to enable enumeration for this backend."
)
def iter_vectors(self, batch_size: int = 500):
"""
Iterate over every stored vector, one page at a time.
Args:
batch_size: Number of vectors to fetch per underlying scan_vectors() call
Yields:
Result dicts with 'id', 'metadata', and 'vector', in scan order
"""
offset = 0
while True:
page = self.scan_vectors(offset=offset, limit=batch_size)
if not page:
return
for item in page:
yield item
offset += len(page)
def count(self) -> int:
"""Return the number of vectors in the store, backend-agnostic.
@@ -0,0 +1,42 @@
"""AgentMemory.find_by_entity returns all matches by default (#1018).
The previous default limit of 10 silently truncated results, making erasure
workflows incomplete for entities with more than 10 memories: a caller
computing "what references this entity" from a truncated page would leave
the remainder live. The unbounded default is deliberate an erasure check
cannot paginate while callers that want a page still pass an explicit
limit. (Previously lived in tests/test_seed_manager.py; moved to the
AgentMemory area per review.)
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from semantica.context.agent_memory import AgentMemory
@pytest.fixture
def memory_with_15():
mem = AgentMemory()
for i in range(15):
mem.store(
content=f"fact {i} about entity",
entities=[{"id": "e1", "name": "Entity", "type": "thing"}],
)
return mem
class TestFindByEntityLimit:
def test_returns_all_matches_by_default(self, memory_with_15):
results = memory_with_15.find_by_entity("e1")
assert len(results) == 15, f"expected 15 (all), got {len(results)}"
def test_explicit_limit_still_works(self, memory_with_15):
assert len(memory_with_15.find_by_entity("e1", limit=5)) == 5
def test_no_matches_returns_empty(self):
assert AgentMemory().find_by_entity("nonexistent") == []
+91 -5
View File
@@ -5,14 +5,23 @@ This module tests the decision tracking data models including
validation, serialization, and deserialization.
"""
import pytest
from datetime import datetime
from typing import List, Dict, Any
import pytest
from semantica.context.decision_models import (
Decision, DecisionContext, Policy, PolicyException, Precedent, ApprovalChain,
validate_decision, validate_policy, serialize_decision, deserialize_decision,
serialize_policy, deserialize_policy
ApprovalChain,
Decision,
DecisionContext,
Policy,
PolicyException,
Precedent,
deserialize_decision,
deserialize_policy,
serialize_decision,
serialize_policy,
validate_decision,
validate_policy,
)
@@ -452,5 +461,82 @@ class TestSerializationFunctions:
assert deserialized.metadata == original_policy.metadata
class TestAutoGenerateIdContract:
"""Test the auto_generate_id InitVar contract across all decision models.
Regression coverage for the InitVar fix: previously ``auto_generate_id``
was a plain ``__post_init__`` parameter that dataclass-generated ``__init__``
never forwarded, so the ``auto_generate_id=False`` branch was dead code and
the "id is required" contract could never fire.
"""
def _base_kwargs(self, cls):
now = datetime.now()
return {
Decision: dict(
decision_id="", category="c", scenario="s", reasoning="r",
outcome="o", confidence=0.5, timestamp=now, decision_maker="m",
),
DecisionContext: dict(
context_id="", decision_id="d", entity_snapshots={}, risk_factors=[],
),
Policy: dict(
policy_id="", name="n", description="d", rules={}, category="c",
version="1", created_at=now, updated_at=now,
),
PolicyException: dict(
exception_id="", decision_id="d", policy_id="p", reason="r",
approver="a", approval_timestamp=now, justification="j",
),
Precedent: dict(
precedent_id="", source_decision_id="d", similarity_score=0.5,
relationship_type="same_policy",
),
ApprovalChain: dict(
approval_id="", decision_id="d", approver="a",
approval_method="email", approval_context="x", timestamp=now,
),
}[cls]
@pytest.mark.parametrize(
"cls",
[Decision, DecisionContext, Policy, PolicyException, Precedent, ApprovalChain],
)
def test_auto_generate_id_is_not_a_field(self, cls):
"""auto_generate_id must stay an InitVar, never a real dataclass field."""
import dataclasses
names = [f.name for f in dataclasses.fields(cls)]
assert "auto_generate_id" not in names
@pytest.mark.parametrize(
"cls",
[Decision, DecisionContext, Policy, PolicyException, Precedent, ApprovalChain],
)
def test_default_auto_generates_id(self, cls):
"""With defaults, an empty id is auto-populated and stays a non-field."""
obj = cls(**self._base_kwargs(cls))
id_field = [f.name for f in __import__("dataclasses").fields(cls)][0]
assert getattr(obj, id_field)
assert "auto_generate_id" not in vars(obj)
@pytest.mark.parametrize(
"cls",
[Decision, DecisionContext, Policy, PolicyException, Precedent, ApprovalChain],
)
def test_required_id_when_auto_generate_disabled(self, cls):
"""auto_generate_id=False with an empty id must raise ValueError."""
with pytest.raises(ValueError):
cls(auto_generate_id=False, **self._base_kwargs(cls))
def test_explicit_id_honored_with_auto_generate_disabled(self):
"""A provided id is preserved when auto_generate_id=False."""
kwargs = self._base_kwargs(Decision)
kwargs["decision_id"] = "fixed-id"
decision = Decision(auto_generate_id=False, **kwargs)
assert decision.decision_id == "fixed-id"
assert "auto_generate_id" not in decision.to_dict()
if __name__ == "__main__":
pytest.main([__file__])
+928
View File
@@ -0,0 +1,928 @@
"""Tests for ErasureCoordinator (issue #1018).
``ContextGraph.purge_node()`` is graph-scope by design: it removes the node and
writes a tombstone attesting the content is gone, while the same content can
survive verbatim as an ``AgentMemory`` item and as an embedding. The
coordinator drives the cascade across every bound store and returns a receipt
saying what was reached -- and, just as importantly, what was not.
These tests run against real ``ContextGraph`` and ``AgentMemory`` instances
rather than mocks. The bug this feature exists to prevent lives in the
interaction between them (``find_by_entity`` truncating the sweep the caller
uses to decide the erasure is done), so mocking that interaction away would
test nothing. The vector stores *are* fakes, because the point of those tests
is backend shape -- ``delete_vectors`` vs ``delete`` vs neither -- and three of
the real backends cannot delete at all.
"""
import json
import unittest
import numpy as np
from semantica.context import AgentMemory, ContextGraph
from semantica.context.erasure import (
STATUS_ERASED,
STATUS_FAILED,
STATUS_NOT_CONFIGURED,
STATUS_NOT_FOUND,
STATUS_UNSUPPORTED,
ErasureCoordinator,
ErasureReceipt,
)
from semantica.vector_store import VectorStore
def _graph():
"""customer --purchased--> order, plus an unrelated supplier."""
graph = ContextGraph(advanced_analytics=False)
graph.add_node("customer-4471", "person")
graph.add_node("order-9", "order")
graph.add_node("supplier-1", "org")
graph.add_edge("customer-4471", "order-9", "purchased")
return graph
def _memory_with(entity_id, count, extra_entity=None):
"""A memory holding ``count`` items that reference ``entity_id``."""
memory = AgentMemory()
for index in range(count):
memory.store(
f"note {index} about {entity_id}",
entities=[{"id": entity_id, "name": entity_id}],
skip_graph=True,
)
if extra_entity:
memory.store(
f"unrelated note about {extra_entity}",
entities=[{"id": extra_entity, "name": extra_entity}],
skip_graph=True,
)
return memory
class _DeleteVectorsStore:
"""Backend shaped like qdrant/pinecone: exposes ``delete_vectors``."""
backend = "qdrant"
def __init__(self, result=True):
self._result = result
self.deleted = []
def delete_vectors(self, vector_ids, **options):
self.deleted.append(list(vector_ids))
return self._result
class _DeleteStore:
"""Backend shaped like pgvector/sqlite-vec: exposes ``delete``."""
backend = "pgvector"
def __init__(self):
self.deleted = []
def delete(self, ids):
self.deleted.append(list(ids))
return True
class _NoDeleteStore:
"""Backend shaped like FAISS/Milvus/Weaviate: no delete surface at all."""
backend = "faiss"
class _RaisingStore:
backend = "qdrant"
def delete_vectors(self, vector_ids, **options):
raise RuntimeError("connection reset")
class _FacadeOverNoDeleteBackend:
"""The ``VectorStore`` facade shape: declares delete_vectors for every
backend and only fails on the call, so the backend must be probed."""
backend = "faiss"
def __init__(self):
self._backend_store = _NoDeleteStore()
def delete_vectors(self, vector_ids, **options):
raise NotImplementedError("Backend store _NoDeleteStore has no delete")
class _MemoryVectorStore(_DeleteVectorsStore):
"""Delete-capable store that AgentMemory can also write embeddings to."""
def store_vectors(self, vectors, metadata=None, **options):
return [f"vec-{len(self.deleted)}-{index}" for index in range(len(vectors))]
class TestErasureAcrossStores(unittest.TestCase):
def test_erases_graph_and_memory_and_reports_both(self):
graph, memory = _graph(), _memory_with("customer-4471", 3, "supplier-1")
receipt = ErasureCoordinator(graph=graph, memory=memory).erase_entity(
"customer-4471", reason="GDPR Art. 17 request #882"
)
self.assertTrue(receipt.complete)
self.assertEqual(receipt.stores["graph"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["graph"]["edges"], 1)
self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["memory"]["items"], 3)
self.assertFalse(graph.has_node("customer-4471"))
self.assertEqual(memory.find_by_entity("customer-4471", limit=500), [])
def test_leaves_other_entities_alone(self):
graph, memory = _graph(), _memory_with("customer-4471", 2, "supplier-1")
ErasureCoordinator(graph=graph, memory=memory).erase_entity("customer-4471")
self.assertTrue(graph.has_node("supplier-1"))
self.assertEqual(len(memory.find_by_entity("supplier-1", limit=500)), 1)
def test_graph_purge_records_the_reason_in_its_tombstone(self):
graph = _graph()
ErasureCoordinator(graph=graph).erase_entity(
"customer-4471", reason="GDPR Art. 17 request #882"
)
tombstone = graph.get_tombstone("customer-4471", "node")
self.assertIsNotNone(tombstone)
self.assertEqual(tombstone["reason"], "GDPR Art. 17 request #882")
def test_erase_entities_returns_one_receipt_per_id_in_order(self):
graph = _graph()
receipts = ErasureCoordinator(graph=graph).erase_entities(
["customer-4471", "supplier-1", "never-existed"], reason="offboarding"
)
self.assertEqual(
[receipt.entity_id for receipt in receipts],
["customer-4471", "supplier-1", "never-existed"],
)
self.assertEqual(receipts[0].stores["graph"]["status"], STATUS_ERASED)
self.assertEqual(receipts[1].stores["graph"]["status"], STATUS_ERASED)
self.assertEqual(receipts[2].stores["graph"]["status"], STATUS_NOT_FOUND)
def test_batch_erasure_all_receipts_carry_the_same_timestamp(self):
"""erase_entities() must resolve the timestamp once for the whole batch.
When ``at=None`` each call to ``erase_entity()`` independently calls
``_normalize_timestamp()``, generating a fresh ``now()`` per entity.
A GDPR batch request would then produce tombstones with diverging
``purged_at`` values, making it impossible to group them under a single
legal request by timestamp. This regression test pins that every
receipt and every graph tombstone share the same instant.
"""
graph = _graph()
receipts = ErasureCoordinator(graph=graph).erase_entities(
["customer-4471", "supplier-1"], reason="GDPR Art. 17 request #882"
)
# Both entities were erased.
self.assertEqual(receipts[0].stores["graph"]["status"], STATUS_ERASED)
self.assertEqual(receipts[1].stores["graph"]["status"], STATUS_ERASED)
# All receipts carry the same erased_at.
self.assertEqual(receipts[0].erased_at, receipts[1].erased_at)
# Each tombstone's purged_at matches its own receipt.
tombstone_0 = graph.get_tombstone("customer-4471", "node")
tombstone_1 = graph.get_tombstone("supplier-1", "node")
self.assertEqual(tombstone_0["purged_at"], receipts[0].erased_at)
self.assertEqual(tombstone_1["purged_at"], receipts[1].erased_at)
# The tombstones themselves agree with each other.
self.assertEqual(tombstone_0["purged_at"], tombstone_1["purged_at"])
class TestMemorySweepIsNotTruncated(unittest.TestCase):
"""The regression this feature exists to prevent.
``find_by_entity`` has historically defaulted to ``limit=10`` and truncated
silently, so the obvious hand-rolled cascade erases the first ten items and
reports success. 25 items is more than any such default, and a coordinator
that calls ``find_by_entity`` once with the default fails this test.
"""
def test_erases_far_more_items_than_the_default_limit(self):
memory = _memory_with("customer-4471", 25)
receipt = ErasureCoordinator(memory=memory).erase_entity("customer-4471")
self.assertEqual(receipt.stores["memory"]["items"], 25)
self.assertEqual(memory.find_by_entity("customer-4471", limit=500), [])
self.assertTrue(receipt.complete)
def test_residual_items_are_reported_as_failed_not_erased(self):
class _UndeletableMemory:
"""Deletes nothing, as a backend refusing the write would."""
def __init__(self):
self.items = [{"memory_id": f"m{i}"} for i in range(3)]
def find_by_entity(self, entity_id, limit=10):
return list(self.items)[:limit]
def batch_delete(self, memory_ids):
return 0
receipt = ErasureCoordinator(memory=_UndeletableMemory()).erase_entity("e1")
self.assertEqual(receipt.stores["memory"]["status"], STATUS_FAILED)
self.assertEqual(receipt.stores["memory"]["residual"], 3)
self.assertFalse(receipt.complete)
def test_memory_items_without_an_identifier_fail_rather_than_look_erased(self):
class _AnonymousMemory:
def find_by_entity(self, entity_id, limit=10):
return [{"content": "no id here"}]
def batch_delete(self, memory_ids): # pragma: no cover - never reached
raise AssertionError("should not delete items it cannot identify")
receipt = ErasureCoordinator(memory=_AnonymousMemory()).erase_entity("e1")
self.assertEqual(receipt.stores["memory"]["status"], STATUS_FAILED)
self.assertFalse(receipt.complete)
class TestVectorBackendShapes(unittest.TestCase):
def test_delete_vectors_backend_is_erased(self):
store = _DeleteVectorsStore()
receipt = ErasureCoordinator(vector_store=store).erase_entity("customer-4471")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["vectors"]["via"], "delete_vectors")
self.assertEqual(store.deleted, [["customer-4471"]])
def test_delete_backend_is_erased(self):
store = _DeleteStore()
receipt = ErasureCoordinator(vector_store=store).erase_entity("customer-4471")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["vectors"]["via"], "delete")
self.assertEqual(store.deleted, [["customer-4471"]])
def test_backend_without_delete_is_unsupported_not_erased(self):
receipt = ErasureCoordinator(vector_store=_NoDeleteStore()).erase_entity("e1")
vectors = receipt.stores["vectors"]
self.assertEqual(vectors["status"], STATUS_UNSUPPORTED)
self.assertEqual(vectors["backend"], "faiss")
self.assertIn("no delete", vectors["detail"])
self.assertFalse(receipt.complete)
def test_facade_declaring_delete_over_a_delete_less_backend_is_unsupported(self):
receipt = ErasureCoordinator(
vector_store=_FacadeOverNoDeleteBackend()
).erase_entity("e1")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_UNSUPPORTED)
self.assertFalse(receipt.complete)
def test_store_reporting_no_deletion_is_failed(self):
store = _DeleteVectorsStore(result=False)
receipt = ErasureCoordinator(vector_store=store).erase_entity("e1")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
self.assertFalse(receipt.complete)
def test_explicit_vector_ids_override_the_entity_id(self):
store = _DeleteVectorsStore()
ErasureCoordinator(vector_store=store).erase_entity(
"customer-4471", vector_ids=["vec-a", "vec-b"]
)
self.assertEqual(store.deleted, [["vec-a", "vec-b"]])
def test_vector_store_defaults_to_the_one_memory_holds(self):
store = _MemoryVectorStore()
memory = AgentMemory(vector_store=store)
self.assertIs(ErasureCoordinator(memory=memory).vector_store, store)
def test_memory_bound_vector_store_can_be_overridden(self):
owned, external = _MemoryVectorStore(), _DeleteVectorsStore()
memory = AgentMemory(vector_store=owned)
coordinator = ErasureCoordinator(memory=memory, vector_store=external)
self.assertIs(coordinator.vector_store, external)
def test_vector_leg_can_be_disabled_for_a_memory_bound_store(self):
memory = AgentMemory(vector_store=_MemoryVectorStore())
coordinator = ErasureCoordinator(memory=memory, vector_store=False)
receipt = coordinator.erase_entity("customer-4471")
self.assertIsNone(coordinator.vector_store)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_NOT_CONFIGURED)
class TestPartialFailureIsAResultNotAnException(unittest.TestCase):
def test_a_raising_vector_store_does_not_stop_the_remaining_legs(self):
graph, memory = _graph(), _memory_with("customer-4471", 4)
receipt = ErasureCoordinator(
graph=graph, memory=memory, vector_store=_RaisingStore()
).erase_entity("customer-4471")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
self.assertIn("RuntimeError", receipt.stores["vectors"]["detail"])
# The legs after the failure still ran.
self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["graph"]["status"], STATUS_ERASED)
self.assertFalse(graph.has_node("customer-4471"))
self.assertFalse(receipt.complete)
self.assertEqual(receipt.incomplete_stores, ["vectors"])
def test_a_raising_graph_is_reported_after_memory_was_erased(self):
class _RaisingGraph:
def find_edges(self):
return []
def purge_node(self, node_id, reason=None, at=None):
raise RuntimeError("graph store unavailable")
memory = _memory_with("customer-4471", 2)
receipt = ErasureCoordinator(graph=_RaisingGraph(), memory=memory).erase_entity(
"customer-4471"
)
self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["graph"]["status"], STATUS_FAILED)
self.assertFalse(receipt.complete)
class TestReceipt(unittest.TestCase):
def test_unconfigured_stores_are_reported_and_still_count_as_complete(self):
receipt = ErasureCoordinator(graph=_graph()).erase_entity("customer-4471")
self.assertEqual(receipt.stores["memory"]["status"], STATUS_NOT_CONFIGURED)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_NOT_CONFIGURED)
self.assertTrue(receipt.complete)
def test_erasing_a_second_time_reports_nothing_left_rather_than_raising(self):
graph, memory = _graph(), _memory_with("customer-4471", 3)
coordinator = ErasureCoordinator(graph=graph, memory=memory)
coordinator.erase_entity("customer-4471")
second = coordinator.erase_entity("customer-4471")
self.assertEqual(second.stores["graph"]["status"], STATUS_NOT_FOUND)
self.assertEqual(second.stores["memory"]["status"], STATUS_NOT_FOUND)
self.assertTrue(second.complete)
def test_to_dict_round_trips_the_reported_shape(self):
graph = _graph()
receipt = ErasureCoordinator(graph=graph).erase_entity(
"customer-4471",
reason="GDPR Art. 17 request #882",
at="2026-08-16T00:00:00Z",
)
payload = receipt.to_dict()
self.assertEqual(payload["entity_id"], "customer-4471")
self.assertEqual(payload["reason"], "GDPR Art. 17 request #882")
self.assertEqual(payload["erased_at"], "2026-08-16T00:00:00")
self.assertTrue(payload["complete"])
self.assertEqual(set(payload["stores"]), {"graph", "memory", "vectors"})
def test_to_dict_copies_the_store_results(self):
receipt = ErasureCoordinator(graph=_graph()).erase_entity("customer-4471")
payload = receipt.to_dict()
payload["stores"]["graph"]["status"] = "tampered"
self.assertEqual(receipt.stores["graph"]["status"], STATUS_ERASED)
def test_receipt_and_tombstone_agree_on_when_the_erasure_happened(self):
graph = _graph()
receipt = ErasureCoordinator(graph=graph).erase_entity(
"customer-4471", at="2026-08-16T00:00:00Z"
)
tombstone = graph.get_tombstone("customer-4471", "node")
self.assertEqual(tombstone["purged_at"], "2026-08-16T00:00:00")
self.assertEqual(receipt.erased_at, tombstone["purged_at"])
def test_receipt_and_tombstone_agree_when_no_at_is_given(self):
"""The default path, where the drift actually happens.
With `at=None` the coordinator and `purge_node()` would each take their
own `now()`, so the receipt attested to a different instant than the
tombstone it points at. Passing an explicit `at` hides this, which is
why the test above passed while the common case was wrong.
"""
graph = _graph()
receipt = ErasureCoordinator(graph=graph).erase_entity("customer-4471")
tombstone = graph.get_tombstone("customer-4471", "node")
self.assertEqual(receipt.erased_at, tombstone["purged_at"])
def test_epoch_seconds_are_accepted_like_the_graph_accepts_them(self):
graph = _graph()
receipt = ErasureCoordinator(graph=graph).erase_entity(
"customer-4471", at=1755302400
)
tombstone = graph.get_tombstone("customer-4471", "node")
self.assertEqual(receipt.erased_at, tombstone["purged_at"])
self.assertTrue(receipt.erased_at.startswith("2025-"))
def test_an_unparseable_at_is_rejected_before_any_store_is_touched(self):
graph, memory = _graph(), _memory_with("customer-4471", 2)
with self.assertRaises(ValueError):
ErasureCoordinator(graph=graph, memory=memory).erase_entity(
"customer-4471", at="not-a-timestamp"
)
self.assertTrue(graph.has_node("customer-4471"))
self.assertEqual(len(memory.find_by_entity("customer-4471", limit=500)), 2)
def test_incomplete_stores_names_every_store_still_holding_data(self):
receipt = ErasureReceipt(
entity_id="e1",
stores={
"vectors": {"status": STATUS_UNSUPPORTED},
"memory": {"status": STATUS_FAILED},
"graph": {"status": STATUS_ERASED},
},
)
self.assertEqual(sorted(receipt.incomplete_stores), ["memory", "vectors"])
self.assertFalse(receipt.complete)
class TestRealVectorStoreBackend(unittest.TestCase):
"""The fakes above assert the shapes the coordinator expects; these assert
that a real backend actually has one of them.
This repo's recurring failure is a change verified only against the default
that reaches for internals and breaks on every other backend, so the fake
stores are worth exactly as much as the assumption that a real store looks
like them. ``VectorStore(backend="inmemory")`` is the one backend that runs
without external services, so it is the one that can hold that assumption
to account here.
"""
def _store(self):
return VectorStore(backend="inmemory", dimension=8)
def test_real_backend_erases_the_vector_ids_it_is_given(self):
store = self._store()
vector_ids = store.store_vectors(
vectors=[np.ones(8), np.zeros(8)], metadata=[{}, {}]
)
self.assertEqual(store.count(), 2)
receipt = ErasureCoordinator(vector_store=store).erase_entity(
"customer-4471", vector_ids=vector_ids
)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["vectors"]["backend"], "inmemory")
self.assertEqual(store.count(), 0)
def test_the_full_cascade_removes_a_real_memory_bound_embedding(self):
"""The end-to-end case the receipt actually attests to.
Real ``ContextGraph``, real ``AgentMemory``, real ``VectorStore`` --
the embedding is written by ``AgentMemory.store()`` and has to be gone
afterwards, which exercises the memory leg's own ``delete_memory()``
vector cascade rather than the coordinator's model of it.
"""
store, graph = self._store(), _graph()
memory = AgentMemory(vector_store=store)
memory.store(
"note about customer-4471",
entities=[{"id": "customer-4471", "name": "customer-4471"}],
skip_graph=True,
)
self.assertEqual(store.count(), 1)
receipt = ErasureCoordinator(graph=graph, memory=memory).erase_entity(
"customer-4471", reason="GDPR Art. 17 request #882"
)
self.assertTrue(receipt.complete)
self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["graph"]["status"], STATUS_ERASED)
self.assertEqual(store.count(), 0)
self.assertFalse(graph.has_node("customer-4471"))
self.assertEqual(memory.find_by_entity("customer-4471", limit=500), [])
def test_erased_means_the_store_accepted_the_delete_not_that_data_existed(self):
"""Pins a limit of the receipt worth knowing before trusting it.
The in-memory backend pops the ids and returns ``True`` whether or not
they were there, and no backend offers a portable "did this id exist"
check, so the vectors leg reports how many ids the store accepted --
not how many embeddings were really removed. ``erased`` on this leg is
therefore weaker than on the memory leg, which re-queries to confirm.
"""
store = self._store()
receipt = ErasureCoordinator(vector_store=store).erase_entity("never-embedded")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["vectors"]["vector_ids"], 1)
self.assertEqual(store.count(), 0)
class TestConstruction(unittest.TestCase):
def test_a_coordinator_with_no_stores_is_rejected(self):
with self.assertRaises(ValueError):
ErasureCoordinator()
def test_a_single_store_is_enough(self):
self.assertIsNotNone(ErasureCoordinator(graph=_graph()))
self.assertIsNotNone(ErasureCoordinator(memory=AgentMemory()))
self.assertIsNotNone(ErasureCoordinator(vector_store=_DeleteStore()))
def test_a_falsey_vector_store_is_still_a_store(self):
"""An empty store defining __len__ is falsey but perfectly valid."""
class _EmptyButReal(_DeleteVectorsStore):
def __len__(self):
return 0
store = _EmptyButReal()
coordinator = ErasureCoordinator(vector_store=store)
self.assertIs(coordinator.vector_store, store)
receipt = coordinator.erase_entity("customer-4471")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
class TestBackendDeleteResults(unittest.TestCase):
"""Backends report deletes as dicts, not bools.
Qdrant returns ``{"status": <UpdateStatus>}`` and Pinecone
``{"deleted": True}``, so a bare ``result is False`` check calls every dict
a success and throws away the only account of the delete the caller gets.
"""
def _store_returning(self, value):
store = _DeleteVectorsStore(result=value)
return store, ErasureCoordinator(vector_store=store)
def test_qdrant_shaped_success_dict_is_erased_and_kept(self):
_, coordinator = self._store_returning({"status": "completed"})
vectors = coordinator.erase_entity("e1").stores["vectors"]
self.assertEqual(vectors["status"], STATUS_ERASED)
self.assertEqual(vectors["backend_result"], {"status": "completed"})
def test_pinecone_shaped_success_dict_is_erased(self):
_, coordinator = self._store_returning({"deleted": True})
self.assertEqual(
coordinator.erase_entity("e1").stores["vectors"]["status"], STATUS_ERASED
)
def test_explicit_failure_marker_in_a_dict_is_failed(self):
for payload in ({"deleted": False}, {"success": False}, {"status": "failed"}):
with self.subTest(payload=payload):
_, coordinator = self._store_returning(payload)
receipt = coordinator.erase_entity("e1")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
self.assertFalse(receipt.complete)
def test_an_enum_like_failure_status_is_not_read_as_success(self):
class _UpdateStatus:
def __str__(self):
return "UpdateStatus.FAILED"
_, coordinator = self._store_returning({"status": _UpdateStatus()})
receipt = coordinator.erase_entity("e1")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
# Rendered as a string so the receipt stays serializable as an audit record.
self.assertEqual(
receipt.stores["vectors"]["backend_result"],
{"status": "UpdateStatus.FAILED"},
)
json.dumps(receipt.to_dict())
def test_a_zero_count_return_is_not_mistaken_for_False(self):
"""`0 == False` in Python; a store reporting "0 rows" is not a failure."""
_, coordinator = self._store_returning({"deleted": 0})
self.assertEqual(
coordinator.erase_entity("e1").stores["vectors"]["status"], STATUS_ERASED
)
def test_a_void_delete_returning_None_is_accepted(self):
"""Reporting `failed` for a void method would be a false alarm."""
_, coordinator = self._store_returning(None)
self.assertEqual(
coordinator.erase_entity("e1").stores["vectors"]["status"], STATUS_ERASED
)
class _SelectiveDeleteStore:
"""Deletes some ids and refuses others, tracking what is still live.
Models the case that matters: the entity-keyed id deletes fine while the
embedding an ``AgentMemory`` item owns does not.
"""
backend = "qdrant"
def __init__(self, refuse=()):
self._refuse = set(refuse)
self.live = set()
self.attempts = []
def store_vectors(self, vectors, metadata=None, **options):
ids = [f"vec-{len(self.live) + index}" for index in range(len(vectors))]
self.live.update(ids)
return ids
def delete_vectors(self, vector_ids, **options):
self.attempts.append(list(vector_ids))
if any(vector_id in self._refuse for vector_id in vector_ids):
return False
self.live.difference_update(vector_ids)
return True
def _memory_with_embedding(entity_id, store):
memory = AgentMemory(vector_store=store)
memory.store(
f"note about {entity_id}",
entities=[{"id": entity_id, "name": entity_id}],
embedding=np.zeros(4),
skip_graph=True,
)
return memory
class TestSeparateVectorStoreHandling(unittest.TestCase):
"""Verify correct behavior when coordinator.vector_store != memory.vector_store.
AgentMemory.delete_memory() has its own best-effort vector cascade that
logs failures but returns True. When the coordinator's vector_store differs
from (or is disabled vs) memory.vector_store, a vector remaining in
memory.vector_store must not be hidden by the coordinator's receipt.
"""
def test_vector_store_false_disables_vector_leg_entirely(self):
"""vector_store=False must disable the vector leg, not try memory.vector_store."""
memory_store = _SelectiveDeleteStore()
memory = _memory_with_embedding("customer-4471", memory_store)
# Disable vector leg explicitly
receipt = ErasureCoordinator(
graph=_graph(), memory=memory, vector_store=False
).erase_entity("customer-4471")
# Vector leg should report not_configured, not attempt deletion
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_NOT_CONFIGURED)
# Memory's own cascade still runs, but coordinator doesn't track it
self.assertTrue(receipt.complete)
def test_separate_vector_store_only_handles_coordinator_store(self):
"""When coordinator has a different vector_store, it only handles that one.
If memory.vector_store contains a memory-owned vector and fails to delete
it, that's memory's problem -- the coordinator only reports on the store
it was given. This test verifies the coordinator correctly collects IDs
from memory items and attempts deletion on its own store, independent of
memory.vector_store.
"""
# Memory has its own store with a vector
memory_store = _SelectiveDeleteStore()
memory = _memory_with_embedding("customer-4471", memory_store)
memory_vector_id = list(memory_store.live)[0]
# Coordinator has a separate store that refuses to delete
coordinator_store = _SelectiveDeleteStore(refuse={memory_vector_id})
receipt = ErasureCoordinator(
graph=_graph(), memory=memory, vector_store=coordinator_store
).erase_entity("customer-4471")
# The coordinator's store should have been asked to delete the memory-owned vector
self.assertIn(memory_vector_id, coordinator_store.attempts[0])
# The coordinator's store refused, so receipt is incomplete
self.assertFalse(receipt.complete)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
# Memory's own store was used by delete_memory()'s cascade (best-effort)
# but the coordinator's receipt only reflects the coordinator's store
self.assertNotIn(memory_vector_id, memory_store.live) # memory deleted it
def test_memory_vector_store_failure_is_not_reported_when_coordinator_has_separate_store(
self,
):
"""If memory.vector_store fails but coordinator.vector_store succeeds, receipt is complete.
The coordinator reports only on its own store. Memory's delete_memory()
cascade is best-effort and logs failures, but the coordinator doesn't
re-check memory.vector_store after deletion.
"""
# Memory's store will fail to delete (but delete_memory catches it)
memory_store = _SelectiveDeleteStore(refuse={"vec-0"})
memory = _memory_with_embedding("customer-4471", memory_store)
# Coordinator has a separate, cooperative store
coordinator_store = _SelectiveDeleteStore()
receipt = ErasureCoordinator(
graph=_graph(), memory=memory, vector_store=coordinator_store
).erase_entity("customer-4471")
# Coordinator's store succeeded
self.assertTrue(receipt.complete)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
# But memory's store still has the vector (delete_memory logged it)
self.assertIn("vec-0", memory_store.live)
class TestMemoryOwnedVectorsAreReported(unittest.TestCase):
"""A memory item's embedding must not survive a `complete` receipt.
``AgentMemory.delete_memory()`` deletes an item's vectors best-effort: it
catches a vector-store failure, logs a warning, and still returns ``True``.
The coordinator therefore cannot learn from the memory leg whether those
embeddings actually went away, so it deletes them through its own vector
leg, which reports honestly.
"""
def test_refused_memory_owned_vector_makes_the_receipt_incomplete(self):
store = _SelectiveDeleteStore(refuse={"vec-0"})
memory = _memory_with_embedding("customer-4471", store)
self.assertEqual(
memory.vector_ids_for(next(iter(memory.memory_items))), ["vec-0"]
)
receipt = ErasureCoordinator(graph=_graph(), memory=memory).erase_entity(
"customer-4471"
)
# The embedding is demonstrably still there ...
self.assertIn("vec-0", store.live)
# ... so the receipt must not claim the erasure is done.
self.assertFalse(receipt.complete)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
self.assertEqual(receipt.incomplete_stores, ["vectors"])
def test_memory_owned_vector_ids_are_sent_to_the_vector_store(self):
store = _SelectiveDeleteStore()
memory = _memory_with_embedding("customer-4471", store)
receipt = ErasureCoordinator(graph=_graph(), memory=memory).erase_entity(
"customer-4471"
)
# The coordinator's own leg must have attempted the memory-owned id,
# not just the entity-keyed one.
self.assertIn("vec-0", store.attempts[0])
self.assertIn("customer-4471", store.attempts[0])
self.assertNotIn("vec-0", store.live)
self.assertTrue(receipt.complete)
def test_explicit_vector_ids_do_not_displace_memory_owned_ids(self):
store = _SelectiveDeleteStore()
memory = _memory_with_embedding("customer-4471", store)
ErasureCoordinator(graph=_graph(), memory=memory).erase_entity(
"customer-4471", vector_ids=["extra-1"]
)
self.assertIn("extra-1", store.attempts[0])
self.assertIn("vec-0", store.attempts[0])
def test_vector_ids_for_falls_back_to_the_memory_id(self):
"""An item stored without tracked vector ids is keyed by its own id."""
memory = AgentMemory()
memory.store(
"note about customer-4471",
entities=[{"id": "customer-4471", "name": "customer-4471"}],
skip_graph=True,
)
memory_id = next(iter(memory.memory_items))
self.assertEqual(memory.vector_ids_for(memory_id), [memory_id])
self.assertEqual(memory.vector_ids_for("no-such-item"), [])
def test_pagination_collects_vectors_from_all_501_items(self):
"""Regression: _all_vector_ids must page to collect ALL vectors.
The original implementation called find_by_entity(limit=500) once,
collecting only the first 500 items' vectors, while _erase_memory()
continued paging and deleted all 501+ items. The vector belonging to
item 501 remained, yet the receipt reported complete=True -- the exact
failure mode the coordinator exists to prevent.
This test uses 51 items (crossing a 50-item batch boundary for testing)
to verify pagination logic without the performance cost of 501 real items.
The test would fail against the original bug with ANY batch size > 1.
"""
# Use batch size of 50 for this test (instead of production's 500)
# This keeps the test fast while still proving pagination across boundaries
TEST_BATCH_SIZE = 50
TEST_ITEM_COUNT = 51 # One more than batch size
store = _SelectiveDeleteStore(refuse={"vec-50"}) # 0-indexed: item 51
# Create a lightweight memory mock optimized for speed
class FastMemoryFor51Test:
"""Fast memory implementation for pagination test."""
def __init__(self, vector_store):
self.vector_store = vector_store
entity_id = "customer-with-many-memories"
self._items = {}
for i in range(TEST_ITEM_COUNT):
memory_id = f"mem-{i}"
self._items[memory_id] = {
"memory_id": memory_id,
"content": f"Memory {i}",
"entities": [{"id": entity_id}],
"metadata": {},
"timestamp": "2026-01-01T00:00:00",
"relationships": [],
}
def find_by_entity(self, entity_id, limit=None):
"""Return all remaining items, with limit."""
results = list(self._items.values())
if limit is not None:
return results[:limit]
return results
def batch_delete(self, memory_ids):
"""Fast deletion."""
deleted = 0
for memory_id in memory_ids:
if memory_id in self._items:
del self._items[memory_id]
deleted += 1
return deleted
def vector_ids_for(self, memory_id):
"""Return vector ID for this memory."""
idx = int(memory_id.split("-")[1])
return [f"vec-{idx}"]
memory = FastMemoryFor51Test(store)
# Pre-populate the vector store
for i in range(TEST_ITEM_COUNT):
store.live.add(f"vec-{i}")
# Temporarily patch the batch size constant for this test
from semantica.context import erasure
original_batch_size = erasure._MEMORY_SWEEP_BATCH
erasure._MEMORY_SWEEP_BATCH = TEST_BATCH_SIZE
try:
# Verify setup
self.assertEqual(len(memory.find_by_entity("customer-with-many-memories")), TEST_ITEM_COUNT)
self.assertIn("vec-50", store.live)
receipt = ErasureCoordinator(graph=_graph(), memory=memory).erase_entity(
"customer-with-many-memories"
)
# The 51st embedding is demonstrably still there...
self.assertIn("vec-50", store.live)
# ...so the receipt MUST NOT claim complete erasure
self.assertFalse(
receipt.complete,
f"Receipt claimed complete=True while vec-50 (item {TEST_ITEM_COUNT}) remains; "
"_all_vector_ids() only collected the first {TEST_BATCH_SIZE} items' vectors",
)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
self.assertIn("vectors", receipt.incomplete_stores)
# Verify all 51 memory-owned vector IDs were attempted (proving pagination worked)
all_attempted = set()
for batch in store.attempts:
all_attempted.update(batch)
# Should have attempted entity_id + all TEST_ITEM_COUNT memory-owned vectors
# (entity_id is always included by _all_vector_ids when vector_ids=None)
self.assertEqual(len(all_attempted), TEST_ITEM_COUNT + 1,
f"Expected {TEST_ITEM_COUNT + 1} vector deletion attempts "
f"(entity_id + {TEST_ITEM_COUNT} memory vectors), got {len(all_attempted)}")
# Specifically must have tried the 51st memory vector
self.assertIn("vec-50", all_attempted,
"Pagination failed: vec-50 (item 51) was never collected")
finally:
# Restore original batch size
erasure._MEMORY_SWEEP_BATCH = original_batch_size
if __name__ == "__main__":
unittest.main()
+14 -14
View File
@@ -291,8 +291,8 @@ class TestDeduplication(unittest.TestCase):
class TestProgressTrackerEncoding(unittest.TestCase):
"""Regression tests for issue #531 — Unicode crash on cp1252 Windows consoles."""
def _make_cp1252_stdout(self):
"""Return a stdout-like object that raises UnicodeEncodeError for non-cp1252 chars."""
def _make_cp1252_stream(self):
"""Return a stream that raises UnicodeEncodeError for non-cp1252 chars."""
class CP1252Writer:
encoding = "cp1252"
def write(self, text):
@@ -304,39 +304,39 @@ class TestProgressTrackerEncoding(unittest.TestCase):
def test_safe_write_does_not_crash_on_cp1252(self):
"""_safe_write must not raise UnicodeEncodeError on a cp1252 console."""
display = ConsoleProgressDisplay()
orig = sys.stdout
sys.stdout = self._make_cp1252_stdout()
orig = sys.stderr
sys.stderr = self._make_cp1252_stream()
try:
display._safe_write("🧠 Semantica - 📊 Current Progress\n")
except UnicodeEncodeError:
self.fail("_safe_write raised UnicodeEncodeError on cp1252 stdout")
self.fail("_safe_write raised UnicodeEncodeError on a cp1252 stream")
finally:
sys.stdout = orig
sys.stderr = orig
def test_update_pipeline_header_does_not_crash_on_cp1252(self):
"""update() pipeline header write must not crash on a cp1252 console (issue #531)."""
from semantica.utils.progress_tracker import ProgressItem
display = ConsoleProgressDisplay()
display.use_emoji = True # force emoji path to exercise the fixed branch
orig = sys.stdout
sys.stdout = self._make_cp1252_stdout()
orig = sys.stderr
sys.stderr = self._make_cp1252_stream()
try:
display._safe_write("🧠 Semantica - 📊 Current Progress\n")
display._safe_write("=" * 150 + "\n")
except UnicodeEncodeError:
self.fail("Pipeline header write raised UnicodeEncodeError on cp1252 stdout")
finally:
sys.stdout = orig
sys.stderr = orig
def test_emoji_detection_disables_on_cp1252(self):
"""ConsoleProgressDisplay should auto-disable emoji when stdout is cp1252."""
orig = sys.stdout
sys.stdout = self._make_cp1252_stdout()
"""ConsoleProgressDisplay should auto-disable emoji when the progress stream is cp1252."""
orig = sys.stderr
sys.stderr = self._make_cp1252_stream()
try:
display = ConsoleProgressDisplay()
self.assertFalse(display.use_emoji, "use_emoji should be False on cp1252 stdout")
self.assertFalse(display.use_emoji, "use_emoji should be False on a cp1252 progress stream")
finally:
sys.stdout = orig
sys.stderr = orig
class TestResultLimiting(unittest.TestCase):
@@ -24,14 +24,19 @@ import math
from datetime import datetime
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the imports below, which need that extra.
pytest.importorskip("fastapi")
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.routes.decisions import _node_to_decision
from semantica.explorer.schemas import DecisionResponse
from semantica.explorer.session import GraphSession
from fastapi.testclient import TestClient # noqa: E402
from pydantic import ValidationError # noqa: E402
from semantica.context.context_graph import ContextGraph # noqa: E402
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.routes.decisions import _node_to_decision # noqa: E402
from semantica.explorer.schemas import DecisionResponse # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
# ---------------------------------------------------------------------------
+103 -10
View File
@@ -9,16 +9,15 @@ import networkx as nx
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip(
"starlette TestClient is required for explorer tests. Install semantica[explorer].",
allow_module_level=True,
)
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
from starlette.testclient import TestClient # noqa: E402
@@ -715,6 +714,100 @@ class TestImportExport:
assert response.status_code == 200
assert "text/csv" in response.headers["content-type"].lower()
@pytest.mark.parametrize(
"fmt,rdflib_format",
[
("turtle", "turtle"),
("ttl", "turtle"),
("nt", "nt"),
("ntriples", "nt"),
("n-triples", "nt"),
("xml", "xml"),
("rdfxml", "xml"),
("rdf-xml", "xml"),
("jsonld", "json-ld"),
("json-ld", "json-ld"),
],
)
def test_export_rdf_formats(self, client, fmt, rdflib_format):
"""The Explorer used to answer 422 for every RDF format while the MCP
`export_graph` tool offered them, so a graph could be loaded as JSON-LD and never
exported back (#1131).
Parsed with a real RDF parser rather than asserted on strings: a response that
merely *looks* like Turtle is what makes this class of gap survive a test suite.
"""
rdflib = pytest.importorskip("rdflib")
response = client.post("/api/export", json={"format": fmt})
assert response.status_code == 200, response.text
graph = rdflib.Graph()
graph.parse(data=response.text, format=rdflib_format)
assert len(graph) > 0, f"{fmt} export parsed to zero triples"
def test_export_aliases_agree_with_mcp_tool_where_overlapping(self):
"""The two surfaces of one product should not disagree about what `ttl` means.
Explorer now maps to RDFExporter canonical formats (e.g., nt->ntriples),
while MCP maps to its own intermediates (e.g., nt->nt). This test verifies
that where MCP and Explorer overlap in alias names, they ultimately work
correctly even if the intermediate canonical form differs.
Canary: if either alias table drifts such that an alias becomes unsupported,
this test will catch it."""
from mcp.tools.export import _FORMAT_ALIASES as MCP_ALIASES
from semantica.explorer.routes.export_import import _RDF_FORMATS
# Verify all MCP aliases are present in Explorer
for alias in MCP_ALIASES.keys():
assert alias in _RDF_FORMATS, (
f"MCP alias {alias!r} not present in Explorer _RDF_FORMATS"
)
# Note: We don't require identical canonical forms because:
# - MCP maps to intermediates that RDFExporter then translates
# - Explorer now maps directly to RDFExporter canonical forms
# - Both ultimately work correctly
def test_export_graphml(self, client):
"""GraphML export should work using GraphExporter."""
response = client.post("/api/export", json={"format": "graphml"})
assert response.status_code == 200, response.text
assert "application/xml" in response.headers["content-type"].lower()
# Verify it's valid XML and contains GraphML structure
content = response.text
assert '<?xml version="1.0"' in content
assert '<graphml' in content
assert '</graphml>' in content
def test_export_empty_graph_rdf(self, client):
"""Empty graphs should export successfully in RDF formats."""
# First, clear the graph or use a clean client
# This test assumes test fixtures provide a graph; for empty graph
# we'd need to manipulate the session, which may not be straightforward
# in these integration tests. Keeping this as documentation.
pass
def test_export_rdf_validation_error_handling(self, client):
"""RDF validation errors should return HTTP 422, not 500."""
# This would require crafting malformed graph data that passes
# session.build_graph_dict() but fails RDF validation.
# Since build_graph_dict() returns valid structure, this is difficult
# to trigger in integration tests. Keeping as documentation.
pass
def test_unsupported_format_names_what_is_supported(self, client):
"""The old message said only that the format was unsupported, which reads as 'this
format does not exist' rather than 'this door does not open it'."""
response = client.post("/api/export", json={"format": "no-such-format"})
assert response.status_code == 422
detail = response.json()["detail"]
assert "turtle" in detail and "json" in detail
def test_import_json_with_edge_metadata(self, client):
payload = json.dumps(
{
@@ -1104,7 +1197,7 @@ class TestBidirectionalPathRoute:
# _classify_distance unit tests — issue #472
# ---------------------------------------------------------------------------
from semantica.utils.helpers import classify_path_distance
from semantica.utils.helpers import classify_path_distance # noqa: E402
class _FakeSimilarity:
+8 -9
View File
@@ -13,16 +13,15 @@ browsers can't set custom headers on a WebSocket handshake.
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip(
"starlette TestClient is required for explorer tests. Install semantica[explorer].",
allow_module_level=True,
)
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
from starlette.testclient import TestClient # noqa: E402
def _build_sample_graph() -> ContextGraph:
@@ -15,9 +15,14 @@ from pathlib import Path
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the explorer imports below, which pull fastapi in transitively.
pytest.importorskip("fastapi")
from semantica.context.context_graph import ContextGraph # noqa: E402
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
try:
from starlette.testclient import TestClient
+6 -1
View File
@@ -27,7 +27,12 @@ import threading
import pytest
from semantica.explorer.routes import ontology as ontology_mod
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
from semantica.explorer.routes import ontology as ontology_mod # noqa: E402
def _start_local_server():
+6 -1
View File
@@ -21,7 +21,12 @@ from unittest.mock import MagicMock, patch
import pytest
from semantica.explorer.routes import ontology as ontology_mod
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
from semantica.explorer.routes import ontology as ontology_mod # noqa: E402
def _fake_getaddrinfo(host, *args, **kwargs):
+9 -10
View File
@@ -6,17 +6,16 @@ from urllib.parse import quote
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.routes.ontology import OntologyEntry
from semantica.explorer.session import GraphSession
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip(
"starlette TestClient is required for explorer tests. Install semantica[explorer].",
allow_module_level=True,
)
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.routes.ontology import OntologyEntry # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
from starlette.testclient import TestClient # noqa: E402
def _build_ontology_graph() -> ContextGraph:

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