Compare commits

..
Author SHA1 Message Date
KaifAhmad1 365391cb5b ci: add integration workflow with a live pgvector service
Adds a live-PostgreSQL integration workflow (.github/workflows/integration.yml)
that runs the existing tests/vector_store/test_pgvector_store.py suite against
a real pgvector/pgvector:pg16 service container, on pull_request/weekly
cron/workflow_dispatch. Kept separate from ci.yml (a required check) so an
image pull, Postgres startup, or occasional service flake can't block
unrelated merges, matching install-matrix.yml's existing precedent.

Design notes:
- Service image pinned by digest (verify-action-pins.sh only checks `uses:`
  entries, not service images, so that's called out explicitly in-workflow).
- The postgres service uses POSTGRES_HOST_AUTH_METHOD: trust rather than a
  password: it's a throwaway container reachable only from this job, so
  trust auth avoids putting any credential in the workflow at all.
- vector extension is created in its own step (PgVectorStore intentionally
  refuses to auto-create it) which doubles as an explicit connectivity gate.
- pg_available() now raises instead of skipping when TEST_PGVECTOR_URL was
  set explicitly (which CI always does), so a genuinely broken service fails
  the job instead of the suite quietly reporting green having run nothing.
- Installs are hash-verified throughout (pep517-build.txt + --no-build-isolation
  + a new pgvector-extra.txt lockfile), matching ci.yml's existing convention
  for the OpenSSF Scorecard Pinned-Dependencies check.
- Two pre-existing test issues the first live run exposed are fixed: a dead
  leftover cleanup block in test_search_empty_store that targeted the wrong
  table through an already-closed connection, and a vacuous
  assert all(uuid.UUID(...)) that could never evaluate False.

History note: this replaces several earlier commits on this branch, squashed
to drop an early revision that briefly hardcoded a throwaway
POSTGRES_PASSWORD for the ephemeral CI-only service container before this
was reworked to trust auth. That value was never reachable outside the
job and protected no real data, but GitGuardian correctly flags any
committed secret-shaped string regardless of real-world risk, so it's
removed from history rather than just superseded.
2026-09-02 20:49:24 +05:30
24 changed files with 3781 additions and 1102 deletions
+2 -1
View File
@@ -30,7 +30,8 @@ each file's own autogenerated header comment for its exact command).
| `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) |
| `pytest-tool.txt` | ci.yml | pytest, for the pre-all-extras deterministic test |
| `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 |
File diff suppressed because it is too large Load Diff
+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
-8
View File
@@ -11,14 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Salesforce ingestor** (#1240) by @Sameer6305
- New `SalesforceConnector` / `SalesforceData` / `SalesforceIngestor` (`semantica.ingest`, lazy export), following the same Connector + Data + Ingestor pattern already used for Snowflake/Databricks/SAP
- Auth covers both landscapes Salesforce actually uses: username + password + security token (SOAP login), session_id + instance_url (reusing an existing session), and username + consumer_key + private key (JWT Bearer); production and sandbox are selected via `domain`, and credentials can come from environment variables. Credential material is never intentionally written to logs, exceptions, or `repr()`
- `ingest_sobject()`, `ingest_query()`, `list_sobjects()`, `get_sobject_schema()`, `export_as_documents()` against standard sObjects, custom objects (`__c`), custom metadata (`__mdt`), platform events (`__e`), namespaced objects, and relationship-field traversal (e.g. `Owner.Name`); pagination follows `nextRecordsUrl`/`query_more()` and stops once a caller's `limit` is satisfied
- New `pip install semantica[db-salesforce]` extra (`simple-salesforce>=1.12.0`)
- New `tests/test_salesforce_ingestor.py`
- Docs: `docs/integrations/salesforce.md`
- **`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
+16 -13
View File
@@ -20,7 +20,7 @@
**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; Governed &nbsp;·&nbsp; Zero Vendor Lock-In**
**Open Source &nbsp;·&nbsp; Self-Hostable &nbsp;·&nbsp; Auditable &nbsp;·&nbsp; Governed &nbsp;·&nbsp; Zero Vendor Lock-In**
**Polyglot Graph Storage &nbsp;·&nbsp; RDF & LPG Support &nbsp;·&nbsp; W3C Standards &nbsp;·&nbsp; Interoperable**
@@ -62,12 +62,12 @@ Most AI agents run on embeddings, not meaning: similarity scores with no structu
**Who it's for:**
- **AI/ML platform teams** shipping agents that make consequential decisions and need structured, queryable context, not just a vector index
- **Data platform teams on Databricks or Snowflake** turning tables already in Unity Catalog or a warehouse into a governed, lineage-tracked knowledge graph, without exporting to a third-party SaaS
- **Compliance, risk, and audit teams** who need a straight answer to "why did the AI do that?" in a format a regulator accepts
- **Regulated enterprises** (finance, healthcare, legal, government, defense) that can't ship a black box or send their data to someone else's SaaS to get one
- **AI/ML platform teams** shipping agents that make consequential decisions and need structured, queryable context built from fragmented raw data, not just a vector index
- **Data platform teams on Databricks or Snowflake** who need to turn tables already sitting in Unity Catalog or a Snowflake warehouse into a governed, lineage-tracked knowledge graph, without exporting that data to a third-party SaaS first
- **Compliance, risk, and audit teams** who need a straight answer to "why did the AI do that?" in a format a regulator will actually accept
- **Regulated enterprises** (finance, healthcare, legal, government, defense) that can't ship a black box, and can't send their data to someone else's SaaS to get one
- **Platform and infra engineers** who want the KG, reasoning, and provenance stack self-hosted and swappable, not locked to one vendor's backend
- **Data and knowledge engineers** building a KG from messy, multi-source data, where conflicting facts get flagged and duplicates get merged, not silently overwritten
- **Data and knowledge engineers** building a KG from messy, multi-source data: entities and relationships get extracted, conflicting or contradictory facts are flagged instead of silently overwritten, and duplicates are merged before they turn into noise
**[Quick Start](#quick-start)** &nbsp;·&nbsp; **[Architecture](#architecture)** &nbsp;·&nbsp; **[What You Get](#what-semantica-gives-you)** &nbsp;·&nbsp; **[Why Semantica](#why-semantica)** &nbsp;·&nbsp; **[Decision Intelligence](#decision-intelligence)** &nbsp;·&nbsp; **[Context Graphs](#context-graphs)** &nbsp;·&nbsp; **[Recipe: Audit Trail](#recipe-audit-trail-for-a-regulated-decision)** &nbsp;·&nbsp; **[Module Reference](#module-reference)** &nbsp;·&nbsp; **[Integrations](#integrations)** &nbsp;·&nbsp; **[CLI](#cli)** &nbsp;·&nbsp; **[Performance](#performance)** &nbsp;·&nbsp; **[Install](#installation)**
@@ -81,7 +81,7 @@ Most AI agents run on embeddings, not meaning: similarity scores with no structu
- **Full Auditability:** W3C PROV-O provenance on every fact, with audit trails exportable to JSON, CSV, or RDF
- **Deterministic Reasoning:** Forward chaining, Rete network, Datalog, and SPARQL with fully explainable paths, not black boxes
- **Knowledge Pipeline:** Multi-source ingestion, entity-aware chunking, NER/relation/event extraction, and knowledge graph construction, with semantic deduplication and provenance-preserving merges throughout
- **Enterprise Data Platforms:** Native connectors for Databricks (Unity Catalog + Delta Lake, PAT/OAuth M2M auth, catalog/schema/table/lineage introspection), Snowflake (warehouse/database/schema, key-pair and OAuth auth), and SAP OData (Business Partners, Sales Orders, OAuth2/Basic auth), so data already living in your lakehouse or warehouse becomes graph nodes with provenance, not another export/import hop
- **Enterprise Data Platforms:** Native connectors for Databricks (Unity Catalog + Delta Lake, PAT/OAuth M2M auth, catalog/schema/table/lineage introspection) and Snowflake (warehouse/database/schema, key-pair and OAuth auth), so tables already living in your lakehouse or warehouse become graph nodes with provenance, not another export/import hop
- **Graph Analytics:** Centrality, community detection, link prediction, and shortest-path queries over the graph you just built
- **Polyglot Graph Storage:** Native RDF (embedded Oxigraph, Blazegraph, Apache Jena, Eclipse RDF4J via SPARQL) and Labeled Property Graphs (Neo4j, FalkorDB, Apache AGE, AWS Neptune via Cypher), plus vector stores, all swappable without touching your code
- **Visualization:** Explore any graph, ontology, or timeline in an interactive browser workbench
@@ -139,6 +139,10 @@ compliant = graph.check_decision_rules({"category": "vendor_selection"}) # poli
```bash
semantica doctor
# Python 3.11.9 pass
# semantica 0.6.7 pass
# faiss vector store pass
# Config file pass ~/.semantica/config.yaml
```
**Running in a script or CI?** Progress bars are written only when stdout is an interactive terminal (or a Jupyter notebook), so piping and redirecting stay clean by default. Override with `SEMANTICA_DISABLE_PROGRESS=1` to silence progress everywhere, or `SEMANTICA_FORCE_PROGRESS=1` to keep it when stdout is redirected. `SEMANTICA_DISABLE_PROGRESS` takes precedence.
@@ -163,7 +167,7 @@ Sources → Ingest → Parse → Normalize → Split → Extract → Conflict De
→ Vector Store + Polyglot Graph Store (RDF & LPG) → Export / Visualize / REST · MCP · CLI
```
- **Ingest:** files, web, databases, enterprise data platforms (Databricks, Snowflake, SAP), cloud (Google Drive, Elasticsearch), streams (Kafka, Kinesis), Git, email, MCP
- **Ingest:** files, web, databases, enterprise data platforms (Databricks, Snowflake), cloud (Google Drive, Elasticsearch), streams (Kafka, Kinesis), Git, email, MCP
- **Parse → Normalize → Split:** document parsing, text/entity/date normalization, GraphRAG-native entity-aware chunking
- **Extract → Conflict Detection → Deduplication:** NER, relations, events, triplets; conflicting facts flagged and resolved before they merge
- **Knowledge Graph:** `GraphBuilder` constructs the graph; bi-temporal facts and full graph analytics (centrality, communities, link prediction) run on top of it
@@ -316,7 +320,7 @@ Every module below is independently importable, with working code samples verifi
| Module | What it does |
| --- | --- |
| [`semantica.ingest`](#semanticaingest-multi-source-ingestion) | Files, web, databases, APIs, streams, email, Git, Parquet, Databricks, Snowflake, SAP, MCP |
| [`semantica.ingest`](#semanticaingest-multi-source-ingestion) | Files, web, databases, APIs, streams, email, Git, Parquet, Databricks, Snowflake, MCP |
| [`semantica.semantic_extract`](#semanticasemantic_extract-ner-relations-events-triplets) | NER, relation extraction, event detection, triplet generation |
| [`semantica.kg`](#semanticakg-knowledge-graph-construction--analysis) | Graph construction, centrality, communities, link prediction |
| [`semantica.reasoning`](#semanticareasoning-forward-chaining-rete-datalog-sparql) | Forward chaining, Rete, Datalog, SPARQL, fully explainable |
@@ -345,7 +349,7 @@ Expand any module below for its runnable example.
<summary><b><code>semantica.ingest</code></b>: Multi-Source Ingestion</summary>
<a id="semanticaingest-multi-source-ingestion"></a>
Ingest from files, web, databases, APIs, streams, email, Git repos, Parquet, Databricks, Snowflake, SAP, or MCP servers, all through a unified interface.
Ingest from files, web, databases, APIs, streams, email, Git repos, Parquet, Databricks, Snowflake, or MCP servers, all through a unified interface.
```python
from semantica.ingest import FileIngestor, WebIngestor, ParquetIngestor, DBIngestor
@@ -396,7 +400,7 @@ orders = snowflake.ingest_table("ORDERS", limit=10_000)
> **Security Note:** Never hardcode credentials (`token`, `password`, `private_key`) in production code; pass them via environment variables (e.g., `DATABRICKS_TOKEN`, `SNOWFLAKE_PASSWORD`) or a secrets manager.
**Supported sources:** Local files (PDF, DOCX, PPTX, HTML, TXT, CSV, JSON, YAML, Excel, XML) · Web pages · RSS/Atom feeds · REST APIs · Databases (PostgreSQL, MySQL, SQLite, Oracle, SQL Server) · Parquet datasets · Databricks (Unity Catalog + Delta Lake) · Snowflake · SAP (OData v2/v4) · Git repositories · Email (IMAP/POP3) · Message streams (Kafka, RabbitMQ, Kinesis, Pulsar) · MCP resources · Apache Arrow/Feather/IPC (`ArrowIngestor`)
**Supported sources:** Local files (PDF, DOCX, PPTX, HTML, TXT, CSV, JSON, YAML, Excel, XML) · Web pages · RSS/Atom feeds · REST APIs · Databases (PostgreSQL, MySQL, SQLite, Oracle, SQL Server) · Parquet datasets · Databricks (Unity Catalog + Delta Lake) · Snowflake · Git repositories · Email (IMAP/POP3) · Message streams (Kafka, RabbitMQ, Kinesis, Pulsar) · MCP resources · Apache Arrow/Feather/IPC (`ArrowIngestor`)
DuckDB, Elasticsearch, Google Drive, HuggingFace, MongoDB, and Pandas ingestion also ship (`DuckDBIngestor`, `ElasticIngestor`, `GDriveIngestor`, `HuggingFaceIngestor`, `MongoIngestor`, `PandasIngestor`) but aren't re-exported from the top-level `semantica.ingest` namespace yet — import them directly: `from semantica.ingest.duckdb_ingestor import DuckDBIngestor`.
@@ -1141,7 +1145,7 @@ if report.valid:
| **Vector Store** | FAISS · Pinecone · Weaviate · Qdrant · Milvus · PgVector · hybrid + filtered search |
| **Graph Databases (LPG)** | Neo4j · FalkorDB · Apache AGE · AWS Neptune |
| **Triple Stores (RDF)** | Oxigraph (embedded) · Blazegraph · Apache Jena · Eclipse RDF4J · unified `TripletStore` interface · SPARQL query & bulk load |
| **Enterprise Data Platforms** | Databricks (`DatabricksIngestor`: Unity Catalog + Delta Lake, PAT/OAuth M2M, table/query ingestion, catalog/schema/table/lineage introspection) · Snowflake (`SnowflakeIngestor`: warehouse/database/schema, password/key-pair/OAuth auth) · SAP (`SAPIngestor`: OData v2/v4, OAuth2/Basic auth, Business Partners/Sales Orders) |
| **Enterprise Data Platforms** | Databricks (`DatabricksIngestor`: Unity Catalog + Delta Lake, PAT/OAuth M2M, table/query ingestion, catalog/schema/table/lineage introspection) · Snowflake (`SnowflakeIngestor`: warehouse/database/schema, password/key-pair/OAuth auth) |
| **LLM Providers** | **All already supported today:** OpenAI (GPT-4o, o1, o3) · Anthropic (Claude) · Google Gemini · Mistral · Meta Llama · Groq · Cohere · Azure OpenAI · AWS Bedrock · Ollama · DeepSeek · Perplexity · Together AI · Fireworks AI · Replicate · HuggingFace · via `semantica.llms` and LiteLLM |
---
@@ -1513,7 +1517,6 @@ pip install semantica[vectorstore-qdrant] # Qdrant vector store
pip install semantica[vectorstore-pinecone] # Pinecone vector store
pip install semantica[db-snowflake] # Snowflake
pip install semantica[db-databricks] # Databricks (SDK + SQL connector)
pip install semantica[ingest-sap] # SAP OData
pip install semantica[ingest-parquet] # Parquet / PyArrow
pip install semantica[ingest-arrow] # Apache Arrow, Feather, IPC
pip install semantica[viz] # HTML interactive visualization
+1 -1
View File
@@ -226,7 +226,7 @@ Pick your goal to see the minimum imports and a working skeleton.
</Tab>
<Tab title="MCP — Claude / Cursor">
Use Semantica from Claude Desktop, Cursor, VS Code, or any MCP-aware tool — no Python code required after setup. 15 tools available instantly.
Use Semantica from Claude Desktop, Cursor, VS Code, or any MCP-aware tool — no Python code required after setup. 12 tools available instantly.
**Step 1 — Install:**
```bash
+2 -2
View File
@@ -53,7 +53,7 @@ python -c "import semantica; print(semantica.__version__)"
- **semantica-server** — Starts the REST API server. Binds to `0.0.0.0:8000`. Use this when another service or application needs programmatic access to Semantica over HTTP.
- **semantica-worker** — Background task processor. Run alongside `semantica-server` when you need async pipeline execution outside the request cycle. Start the server first, then start one or more workers pointing at the same backend.
- **semantica-explorer** — Launches the browser dashboard. Requires `pip install semantica[explorer]`. Use this to explore a saved knowledge graph interactively. See [Explorer Setup](explorer-setup).
- **semantica-mcp** — Runs the MCP server over stdio. Configure it in your MCP client's settings file to expose all 15 tools and 3 resources to Claude Desktop, Cursor, Windsurf, or any MCP-aware client. See [MCP Server](reference/mcp_server).
- **semantica-mcp** — Runs the MCP server over stdio. Configure it in your MCP client's settings file to expose all 12 tools and 3 resources to Claude Desktop, Cursor, Windsurf, or any MCP-aware client. See [MCP Server](reference/mcp_server).
## Usage Examples
@@ -229,6 +229,6 @@ Install the [Microsoft Visual C++ Redistributable](https://aka.ms/vs/17/release/
## Next Steps
- [Explorer Setup](explorer-setup) — Build a graph, save it, and launch the browser dashboard.
- [MCP Server](reference/mcp_server) — All 15 tools and 3 resources exposed over the MCP protocol.
- [MCP Server](reference/mcp_server) — All 12 tools and 3 resources exposed over the MCP protocol.
- [Installation](installation) — Virtual environments, optional extras, and platform-specific notes.
- [Quickstart](quickstart) — End-to-end pipeline walkthrough with working code.
+5 -5
View File
@@ -16,7 +16,7 @@ icon: "circle-question"
| Python version? | 3.8+ (3.11+ recommended) |
| API key required? | Optional: pattern extraction works with no keys |
| Works with LangChain / LlamaIndex? | Yes: Semantica is a layer on top, not a replacement |
| Production-ready? | Yes: 1,000+ tests, security fixes shipped in every release (see [CHANGELOG](https://github.com/semantica-agi/semantica/blob/main/CHANGELOG.md)) |
| Production-ready? | Yes: 1,000+ tests, v0.5.0 ships with 12 security fixes |
| Latest version? | **v0.6.7** (August 2026) |
| Local LLMs? | Yes: Ollama via LiteLLM, HuggingFaceLLM for air-gapped |
@@ -70,9 +70,9 @@ Yes: MIT licensed, no vendor lock-in, no paywalled features. Some capabilities r
<Accordion title="What's the latest version?" icon="star">
**v0.6.7**: released August 2026.
**v0.5.0**: released May 2026.
Highlights: first-class LangChain integration, SAP OData ingestor, human-editable Markdown round-trip persistence for `ContextGraph`, a structured Action layer for the reasoning engine, and a public `run_shacl_validation` entry point. The 0.6.x line also added first-class CrewAI support and the Semantica RDF vocabulary with deterministic IRIs. See the [CHANGELOG](https://github.com/semantica-agi/semantica/blob/main/CHANGELOG.md) for the full history.
Highlights: Ontology Hub, Distance Intelligence, Parquet/XML ingestion, 12 security fixes, Graph Explorer redesign, NER gateway fix.
```bash
pip install --upgrade semantica
@@ -269,13 +269,13 @@ Groq, OpenAI, Anthropic, Google Gemini, Ollama (fully local), DeepSeek, Novita A
<Accordion title="Is Semantica production-ready?" icon="shield-check">
Yes. Every release ships with:
Yes. v0.5.0 ships with:
- 1,000+ passing tests across Python 3.83.12
- `PipelineValidator` and `FailureHandler` with exponential backoff and configurable retry policies
- W3C PROV-O provenance tracking across all modules
- Change management with SHA-256 checksums and full audit trails
- Ongoing security hardening: eval injection, pickle deserialization, SQL injection, XXE, SSRF, ReDoS, and path traversal fixes have all landed across recent releases (see the [CHANGELOG](https://github.com/semantica-agi/semantica/blob/main/CHANGELOG.md) security sections)
- 12 security vulnerability fixes: eval injection, pickle deserialization, SQL injection, XXE, SSRF, ReDoS, path traversal, and more
</Accordion>
+1 -1
View File
@@ -183,7 +183,7 @@ icon: "rocket"
}
```
15 tools available instantly: extract entities, query graph, record decisions, run reasoning, export results.
12 tools available instantly: extract entities, query graph, record decisions, run reasoning, export results.
**Next:** [MCP Server reference →](reference/mcp_server)
</Tab>
+2 -4
View File
@@ -11,7 +11,7 @@ MCP stands for the Model Context Protocol. It is an open standard that allows ex
The Semantica MCP server exposes your knowledge graph as 12 callable tools. By connecting it, any compatible AI client can traverse the graph live, record decisions, run analytics, and export results during a conversation — without you having to write custom tool wrappers.
<Info>
The Semantica MCP server exposes 15 tools and 3 read-only resources. All tools accept and return JSON. No configuration beyond an optional environment variable for graph persistence is required.
The Semantica MCP server exposes 12 tools and 3 read-only resources. All tools accept and return JSON. No configuration beyond an optional environment variable for graph persistence is required.
</Info>
## Architecture & Communication
@@ -132,7 +132,7 @@ docker run --rm -i \
ghcr.io/semantica-agi/semantica-mcp:latest
```
## What the Agent Can Do: The 15 Tools
## What the Agent Can Do: The 12 Tools
Once connected, the LLM can call any of these tools during a conversation. The agent chains them automatically — you do not orchestrate the sequence, you just describe what you want.
@@ -140,8 +140,6 @@ Once connected, the LLM can call any of these tools during a conversation. The a
**Knowledge graph manipulation**`add_entity` adds a node, `add_relationship` adds a directed edge. After extraction, the agent calls these to persist what it found into the live graph.
**Live graph queries and edits**`query_graph` reads the graph without exporting it: fetch one node, walk its neighbours up to five hops, or keyword-search nodes. `update_node` merges properties onto an existing node (for example marking a task node `done`), and `delete_node` archives a node it no longer tracks. When `SEMANTICA_KG_PATH` is set, `update_node` and `delete_node` write their changes back to that file so they survive a restart.
**Decision intelligence**`record_decision` writes a decision as a provenance node with confidence score, reasoning, and decision maker identity. `query_decisions` retrieves past decisions by query or category. `find_precedents` finds the most similar past decisions by semantic similarity. `get_causal_chain` traces decision causality upstream or downstream.
**Reasoning**`run_reasoning` applies forward-chaining IF/THEN rules over a set of facts and returns derived conclusions.
+2 -2
View File
@@ -369,7 +369,7 @@ Semantica was designed for domains where every decision must be explainable and
| `semantica.reasoning` | Forward chaining, Rete, deductive, abductive, SPARQL, Datalog |
| `semantica.ontology` | SHACL, SKOS, alignments, diff/migration, auto-generation, OWL/RDF |
| `semantica.explorer` | FastAPI Knowledge Explorer, Ontology Hub, Distance Intelligence, SHACL Studio |
| `semantica.mcp_server` | MCP stdio server: 15 tools for Claude Desktop, VS Code, Cursor, Windsurf, Cline |
| `semantica.mcp_server` | MCP stdio server: 12 tools for Claude Desktop, VS Code, Cursor, Windsurf, Cline |
| `semantica.vector_store` | FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector |
| `semantica.graph_store` | Neo4j, FalkorDB, Apache AGE, Amazon Neptune |
| `semantica.triplet_store` | In-memory and persistent RDF triple store with SPARQL |
@@ -404,7 +404,7 @@ Semantica was designed for domains where every decision must be explainable and
- 1,000+ passing tests with full regression coverage
- `PipelineValidator` catches configuration errors at startup
- `FailureHandler` with exponential backoff and dead-letter queues
- Ongoing security hardening: fixes shipped in every release ([CHANGELOG](https://github.com/semantica-agi/semantica/blob/main/CHANGELOG.md))
- 12 security vulnerabilities fixed in v0.5.0
**Modular by Design** — Import only what you need.
- Use `NERExtractor` without a graph store
+1 -1
View File
@@ -438,7 +438,7 @@ Exposes Semantica as an MCP stdio server for IDE and agent integrations.
python -m semantica.mcp_server
```
**Integrations:** Claude Desktop, VS Code, Cursor, Windsurf, Cline: 15 MCP tools exposed
**Integrations:** Claude Desktop, VS Code, Cursor, Windsurf, Cline: 12 MCP tools exposed
### Seed
+2 -2
View File
@@ -5,7 +5,7 @@ icon: "rocket"
---
<Info>
**v0.6.7**first-class LangChain integration, SAP OData ingestor, human-editable Markdown persistence for `ContextGraph`, and a structured Action layer for the reasoning engine. <a href="https://github.com/semantica-agi/semantica/releases" style={{color:"#10B981",fontWeight:600,textDecoration:"none"}}>What's new →</a>
**v0.5.0**Ontology Hub, Distance Intelligence, Parquet & XML ingestion, 12 security fixes. <a href="https://github.com/semantica-agi/semantica/releases" style={{color:"#10B981",fontWeight:600,textDecoration:"none"}}>What's new →</a>
</Info>
This guide walks you through the end-to-end pipeline for building your first knowledge graph. Start here after installation. An LLM API key is optional: pattern-based extraction works out of the box.
@@ -35,7 +35,7 @@ Verify:
```bash
python -c "import semantica; print(semantica.__version__)"
# 0.6.7
# 0.5.0
```
+3 -51
View File
@@ -6,7 +6,7 @@ icon: "plug"
**`semantica.mcp_server`** exposes Semantica's knowledge graph, decision intelligence, semantic extraction, and reasoning capabilities as an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) **server over stdio**:
- 15 MCP tools exposed: extract entities, query graph, record decisions, run reasoning, export results
- 12 MCP tools exposed: extract entities, query graph, record decisions, run reasoning, export results
- No Python code required after launch: configure once, use from any MCP-aware client
- Compatible with Claude Desktop, Windsurf, Cline, Continue, VS Code, Roo Code, Cursor
@@ -40,7 +40,7 @@ python -m semantica.mcp_server
## What You Get
- **15 MCP Tools** — Extract entities, extract relations, record decisions, query decisions, find precedents, trace causal chains, add entities, add relationships, run analytics, summarise graph, run reasoning, export graph, query the live graph, update nodes, archive nodes.
- **12 MCP Tools** — Extract entities, extract relations, record decisions, query decisions, find precedents, trace causal chains, add entities, add relationships, run analytics, summarise graph, run reasoning, export graph.
- **3 Readable Resources** — Live graph JSON (`semantica://graph/summary`), decision list, and schema/version info: readable by any MCP client.
- **Zero Infrastructure** — Runs over stdio: no server, no port, no Docker required. One config block to activate in any MCP client.
- **Persistent Graphs** — Point `SEMANTICA_KG_PATH` at a saved graph file to reload it automatically on every server startup.
@@ -159,7 +159,7 @@ The MCP server is included in the base install: no extras required.
## Tools
The MCP server exposes 15 tools that any connected AI assistant can call:
The MCP server exposes 12 tools that any connected AI assistant can call:
| Tool | Category | Description |
| :---- | :-------- | :----------- |
@@ -173,9 +173,6 @@ The MCP server exposes 15 tools that any connected AI assistant can call:
| `add_relationship` | Graph Operations | Add a directed edge between two nodes |
| `get_graph_summary` | Graph Operations | Node count, decision count, graph status |
| `get_graph_analytics` | Graph Operations | PageRank centrality and community detection |
| `query_graph` | Graph Operations | Fetch a node, traverse its neighbours, or keyword-search nodes |
| `update_node` | Graph Operations | Merge properties onto a node and persist to `SEMANTICA_KG_PATH` |
| `delete_node` | Graph Operations | Soft-delete (archive) a node and persist to `SEMANTICA_KG_PATH` |
| `run_reasoning` | Reasoning | Forward-chain IF/THEN rules over facts |
| `export_graph` | Reasoning & Export | Serialise the graph (`turtle`/`ttl`: RDF Turtle aliases, `nt`, `xml`, `json-ld`, `json`) |
@@ -389,51 +386,6 @@ Takes no input parameters.
</Accordion>
<Accordion title="query_graph" icon="magnifying-glass">
Read the live graph in one of three modes, set by `mode`:
- `node` — return a single node by `node_id`.
- `neighbors` (default) — traverse outward and inward from `node_id` up to `depth` hops (clamped to 1-5, default 1). Optional `relationship_types` filters edge types; optional `limit` caps results.
- `search` — keyword match `query` against each node's id and content. Optional `node_type` restricts the scan; `limit` defaults to 50.
**Input:**
```json
{ "mode": "neighbors", "node_id": "apple_inc", "depth": 2 }
```
</Accordion>
<Accordion title="update_node" icon="pen">
Merge a set of properties onto an existing node. The change is applied in memory and, when `SEMANTICA_KG_PATH` is set, written back to that file so it survives a restart. Returns `persisted: false` when no path is configured.
**Input:**
```json
{
"node_id": "task_42",
"properties": { "status": "done", "note": "shipped in v0.6.7" }
}
```
`node_id` and a non-empty `properties` object are required. Updating a missing node returns an error.
</Accordion>
<Accordion title="delete_node" icon="box-archive">
Soft-delete a node: it stays in the graph for history but is marked `status: "archived"`. Persists to `SEMANTICA_KG_PATH` when configured.
**Input:**
```json
{ "node_id": "task_42" }
```
</Accordion>
</AccordionGroup>
### Reasoning
+7 -33
View File
@@ -8,9 +8,8 @@ Connects Claude Code, Cursor, Windsurf, Cline, Continue, VS Code (GitHub Copilot
## Quick start
```bash
# From the repo root — no extra install flag needed; the root mcp/ package is
# part of the repository and does not require an external MCP SDK.
pip install -e .
# From the repo root
pip install -e ".[mcp]"
# Test the server (type a JSON-RPC request, press Enter)
python -m mcp
@@ -90,14 +89,7 @@ python -m mcp [--debug]
## Per-tool configuration
### Claude Code (`~/.claude.json` or `.mcp.json`)
Claude Code supports two MCP configuration scopes:
- **User scope** — `~/.claude.json` applies across all projects for your user account.
- **Project scope** — `.mcp.json` in your project root applies only to that project.
Both files use the same `mcpServers` structure:
### Claude Code (`~/.claude/settings.json`)
```json
{
@@ -105,33 +97,15 @@ Both files use the same `mcpServers` structure:
"semantica": {
"command": "python",
"args": ["-m", "mcp"],
"env": {
"PYTHONPATH": "/path/to/semantica"
}
"cwd": "/path/to/semantica"
}
}
}
```
> **Why `PYTHONPATH`?** The root `mcp/` package is intentionally not included in
> the installed wheel, so `python -m mcp` only works when the repository is on
> Python's import path. Setting `PYTHONPATH` here ensures this works regardless
> of the working directory Claude uses when it launches the server.
Or add it via the CLI (user scope):
Or use the plugin bundle:
```bash
claude mcp add --scope user semantica \
-e PYTHONPATH=/path/to/semantica \
-- python -m mcp
```
Or for project scope (omit `--scope user`):
```bash
claude mcp add semantica \
-e PYTHONPATH=/path/to/semantica \
-- python -m mcp
claude mcp add semantica python -m mcp --cwd /path/to/semantica
```
---
@@ -242,7 +216,7 @@ Add to your Q Developer MCP config:
| Variable | Default | Description |
|---|---|---|
| `SEMANTICA_KG_PATH` | *(in-memory only)* | Path to a JSON file used to **load** the graph on startup and **persist** mutations (record decisions, add entities/relationships) back to disk after each change. When unset the graph lives in memory only and is lost when the server exits. |
| `SEMANTICA_KG_PATH` | *(in-memory)* | Path to persist/load the graph (JSON file) |
---
+7 -35
View File
@@ -16,13 +16,6 @@ log = logging.getLogger("semantica.mcp.session")
_graph: Optional[Any] = None
# Tracks whether the last graph initialisation successfully loaded the
# configured SEMANTICA_KG_PATH file. When True (or no path was configured)
# mutation handlers are allowed to save. When False an existing file failed
# to load; saving would overwrite the original data with an empty graph, so
# persistence is blocked until the process is restarted with a readable file.
_load_ok: bool = True
def get_graph() -> Any:
"""
@@ -31,45 +24,24 @@ def get_graph() -> Any:
The graph is created with advanced_analytics=True so all centrality,
community-detection, and embedding features are available.
"""
global _graph, _load_ok
global _graph
if _graph is None:
from semantica.context import ContextGraph
_graph = ContextGraph(advanced_analytics=True)
_load_ok = True # default: safe to persist
kg_path = os.environ.get("SEMANTICA_KG_PATH", "").strip()
if kg_path and os.path.exists(kg_path):
# Only attempt to load if the file has content. An empty file
# means the path was just created (e.g. a fresh tempfile) and
# should be treated as "start with empty graph" rather than a
# corrupt-file failure.
if os.path.getsize(kg_path) > 0:
try:
_graph.load_from_file(kg_path)
log.info("Graph loaded from %s", kg_path)
except Exception as exc:
log.warning(
"Could not load graph from %s: %s — persistence disabled "
"to protect existing data; restart the server to retry.",
kg_path, exc,
)
_load_ok = False # do not overwrite the original file
try:
_graph.load(kg_path)
log.info("Graph loaded from %s", kg_path)
except Exception as exc:
log.warning("Could not load graph from %s: %s", kg_path, exc)
return _graph
def is_persistence_safe() -> bool:
"""Return True when it is safe to write mutations back to SEMANTICA_KG_PATH.
Returns False after a failed load so that mutation handlers do not
overwrite the original (possibly intact) file with a fresh empty graph.
"""
return _load_ok
def reset_graph() -> None:
"""Reset the singleton (mainly useful in tests)."""
global _graph, _load_ok
global _graph
_graph = None
_load_ok = True
+1 -35
View File
@@ -5,7 +5,6 @@ Decision intelligence tools — record, query, precedents, causal chain, impact.
from __future__ import annotations
import logging
import os
from mcp.schemas import (
ANALYZE_DECISION_IMPACT,
@@ -14,7 +13,7 @@ from mcp.schemas import (
QUERY_DECISIONS,
RECORD_DECISION,
)
from mcp.session import get_graph, is_persistence_safe
from mcp.session import get_graph
log = logging.getLogger("semantica.mcp.tools.decisions")
@@ -38,39 +37,6 @@ def handle_record_decision(args: dict) -> dict:
valid_from=args.get("valid_from"),
valid_until=args.get("valid_until"),
)
# Persist back to disk so the decision survives server restarts.
# Skip when the initial load failed to avoid overwriting original data.
kg_path = os.environ.get("SEMANTICA_KG_PATH", "").strip()
if kg_path:
if not is_persistence_safe():
# Roll back the in-memory mutation so the client-visible state
# matches the persisted state (neither is saved).
if hasattr(graph, "_decisions") and decision_id in graph._decisions:
del graph._decisions[decision_id]
if hasattr(graph, "_decision_index"):
cat = args.get("category", "")
if cat in graph._decision_index:
graph._decision_index[cat].discard(decision_id)
return {
"error": (
"Persistence blocked: the configured SEMANTICA_KG_PATH "
"could not be loaded at startup. Restart the server with "
"a readable graph file to re-enable persistence."
)
}
try:
graph.save_to_file(kg_path)
except Exception as save_exc:
# Atomic write failed. Roll back the in-memory mutation so the
# client-visible and persisted states remain consistent.
if hasattr(graph, "_decisions") and decision_id in graph._decisions:
del graph._decisions[decision_id]
if hasattr(graph, "_decision_index"):
cat = args.get("category", "")
if cat in graph._decision_index:
graph._decision_index[cat].discard(decision_id)
log.exception("save_to_file failed after record_decision; mutation rolled back")
return {"error": f"Mutation rolled back: could not persist graph: {save_exc}"}
return {
"decision_id": decision_id,
"status": "recorded",
+1 -70
View File
@@ -5,10 +5,9 @@ Graph tools — add entities/relationships, search, analytics, summary.
from __future__ import annotations
import logging
import os
from mcp.schemas import ADD_ENTITY, ADD_RELATIONSHIP, EMPTY, GET_ANALYTICS, SEARCH_GRAPH
from mcp.session import get_graph, is_persistence_safe
from mcp.session import get_graph
log = logging.getLogger("semantica.mcp.tools.graph")
@@ -26,35 +25,6 @@ def handle_add_entity(args: dict) -> dict:
node_type=args.get("type", "Entity"),
metadata=args.get("metadata", {}),
)
# Persist back to disk so the entity survives server restarts.
# Skip when the initial load failed to avoid overwriting original data.
kg_path = os.environ.get("SEMANTICA_KG_PATH", "").strip()
if kg_path:
if not is_persistence_safe():
# Roll back: remove the node we just added.
try:
with graph._lock:
graph._drop_node_from_indexes(node_id)
except Exception:
pass
return {
"error": (
"Persistence blocked: the configured SEMANTICA_KG_PATH "
"could not be loaded at startup. Restart the server with "
"a readable graph file to re-enable persistence."
)
}
try:
graph.save_to_file(kg_path)
except Exception as save_exc:
# Roll back: remove the node so in-memory and persisted state agree.
try:
with graph._lock:
graph._drop_node_from_indexes(node_id)
except Exception:
pass
log.exception("save_to_file failed after add_entity; mutation rolled back")
return {"error": f"Mutation rolled back: could not persist graph: {save_exc}"}
return {"status": "added", "id": node_id, "type": args.get("type", "Entity")}
except Exception as exc:
log.exception("add_entity failed")
@@ -76,45 +46,6 @@ def handle_add_relationship(args: dict) -> dict:
edge_type=rel_type,
metadata=args.get("metadata", {}),
)
# Persist back to disk so the relationship survives server restarts.
# Skip when the initial load failed to avoid overwriting original data.
kg_path = os.environ.get("SEMANTICA_KG_PATH", "").strip()
if kg_path:
if not is_persistence_safe():
# Roll back: remove the edge we just added (last matching edge).
try:
with graph._lock:
for edge in reversed(list(graph.edges)):
if (edge.source_id == source
and edge.target_id == target
and edge.edge_type == rel_type):
graph._drop_edge_from_indexes(edge)
break
except Exception:
pass
return {
"error": (
"Persistence blocked: the configured SEMANTICA_KG_PATH "
"could not be loaded at startup. Restart the server with "
"a readable graph file to re-enable persistence."
)
}
try:
graph.save_to_file(kg_path)
except Exception as save_exc:
# Roll back: remove the edge so in-memory and persisted state agree.
try:
with graph._lock:
for edge in reversed(list(graph.edges)):
if (edge.source_id == source
and edge.target_id == target
and edge.edge_type == rel_type):
graph._drop_edge_from_indexes(edge)
break
except Exception:
pass
log.exception("save_to_file failed after add_relationship; mutation rolled back")
return {"error": f"Mutation rolled back: could not persist graph: {save_exc}"}
return {"status": "added", "source": source, "target": target, "type": rel_type}
except Exception as exc:
log.exception("add_relationship failed")
+2 -24
View File
@@ -1203,30 +1203,8 @@ class ContextGraph:
"links": links_data,
}
# Write atomically: serialize to a sibling temp file then replace the
# destination in one OS-level rename. This guarantees the destination
# is either the old contents or the new contents — never a partial write
# — so a crash or disk-full error during json.dump cannot corrupt the
# sole persisted copy of the graph.
dest = Path(path)
dest.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
dir=dest.parent, prefix=".kg_tmp_", suffix=".json"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, dest)
except Exception:
# Clean up the temp file on any failure so we don't litter the
# directory with partial writes.
try:
os.unlink(tmp_path)
except OSError:
pass
raise
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
self.logger.info(f"Saved context graph to {path}")
+6 -110
View File
@@ -72,34 +72,19 @@ os.environ["SEMANTICA_DISABLE_PROGRESS"] = "1"
# ── lazy graph session ──────────────────────────────────────────────────────
_graph: Any = None
# Tracks whether the last _get_graph() call successfully loaded the configured
# SEMANTICA_KG_PATH file. When False (load failed) mutation handlers skip
# save_to_file to avoid overwriting the original file with an empty graph.
_kg_load_ok: bool = True
def _get_graph():
global _graph, _kg_load_ok
global _graph
if _graph is None:
from semantica.context import ContextGraph
_graph = ContextGraph(advanced_analytics=True)
_kg_load_ok = True # default: safe to persist
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path and os.path.exists(kg_path):
# Only attempt to load if the file has content. An empty file
# means the path was just created (fresh destination) and should
# be treated as "start with empty graph" not a corrupt-file failure.
if os.path.getsize(kg_path) > 0:
try:
_graph.load_from_file(kg_path)
log.info("Loaded graph from %s", kg_path)
except Exception as exc:
log.warning(
"Could not load graph from %s: %s — persistence disabled "
"to protect existing data; restart the server to retry.",
kg_path, exc,
)
_kg_load_ok = False # do not overwrite the original file
try:
_graph.load_from_file(kg_path)
log.info("Loaded graph from %s", kg_path)
except Exception as exc:
log.warning("Could not load graph from %s: %s", kg_path, exc)
return _graph
@@ -194,35 +179,6 @@ def _tool_record_decision(args: dict) -> dict:
valid_from=args.get("valid_from"),
valid_until=args.get("valid_until"),
)
# Persist back to disk so the decision survives server restarts.
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path:
if not _kg_load_ok:
# Roll back to keep in-memory state consistent with persisted state.
if hasattr(graph, "_decisions") and decision_id in graph._decisions:
del graph._decisions[decision_id]
if hasattr(graph, "_decision_index"):
cat = args.get("category", "")
if cat in graph._decision_index:
graph._decision_index[cat].discard(decision_id)
return {
"error": (
"Persistence blocked: the configured SEMANTICA_KG_PATH "
"could not be loaded at startup. Restart the server to retry."
)
}
try:
graph.save_to_file(kg_path)
except Exception as save_exc:
# Atomic write failed. Roll back to keep states consistent.
if hasattr(graph, "_decisions") and decision_id in graph._decisions:
del graph._decisions[decision_id]
if hasattr(graph, "_decision_index"):
cat = args.get("category", "")
if cat in graph._decision_index:
graph._decision_index[cat].discard(decision_id)
log.exception("save_to_file failed after record_decision; mutation rolled back")
return {"error": f"Mutation rolled back: could not persist graph: {save_exc}"}
return {"decision_id": decision_id, "status": "recorded"}
@@ -290,31 +246,6 @@ def _tool_add_entity(args: dict) -> dict:
graph = _get_graph()
graph.add_node(node_id=node_id, label=label, node_type=node_type,
metadata=args.get("metadata", {}))
# Persist back to disk so the entity survives server restarts.
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path:
if not _kg_load_ok:
try:
with graph._lock:
graph._drop_node_from_indexes(node_id)
except Exception:
pass
return {
"error": (
"Persistence blocked: the configured SEMANTICA_KG_PATH "
"could not be loaded at startup. Restart the server to retry."
)
}
try:
graph.save_to_file(kg_path)
except Exception as save_exc:
try:
with graph._lock:
graph._drop_node_from_indexes(node_id)
except Exception:
pass
log.exception("save_to_file failed after add_entity; mutation rolled back")
return {"error": f"Mutation rolled back: could not persist graph: {save_exc}"}
return {"status": "added", "id": node_id}
@@ -328,41 +259,6 @@ def _tool_add_relationship(args: dict) -> dict:
graph = _get_graph()
graph.add_edge(source_id=source, target_id=target, edge_type=rel_type,
metadata=args.get("metadata", {}))
# Persist back to disk so the relationship survives server restarts.
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path:
if not _kg_load_ok:
try:
with graph._lock:
for edge in reversed(list(graph.edges)):
if (edge.source_id == source
and edge.target_id == target
and edge.edge_type == rel_type):
graph._drop_edge_from_indexes(edge)
break
except Exception:
pass
return {
"error": (
"Persistence blocked: the configured SEMANTICA_KG_PATH "
"could not be loaded at startup. Restart the server to retry."
)
}
try:
graph.save_to_file(kg_path)
except Exception as save_exc:
try:
with graph._lock:
for edge in reversed(list(graph.edges)):
if (edge.source_id == source
and edge.target_id == target
and edge.edge_type == rel_type):
graph._drop_edge_from_indexes(edge)
break
except Exception:
pass
log.exception("save_to_file failed after add_relationship; mutation rolled back")
return {"error": f"Mutation rolled back: could not persist graph: {save_exc}"}
return {"status": "added", "source": source, "target": target, "type": rel_type}
@@ -1037,195 +1037,5 @@ class TestClearResetsDecisionIndexes(unittest.TestCase):
self.assertEqual(g._decisions[did]["category"], "new")
# ---------------------------------------------------------------------------
# Part 15: semantica.mcp_server mutation persistence (#1134)
# ---------------------------------------------------------------------------
class TestMCPServerMutationPersistence(unittest.TestCase):
"""_tool_record_decision, _tool_add_entity, and _tool_add_relationship must
each call save_to_file when SEMANTICA_KG_PATH is configured so mutations
survive server restarts.
Mirrors update_node / delete_node which already had this behaviour from
PR #967. These tests extend coverage to the three previously missing tools.
"""
# ---- helpers --------------------------------------------------------
def _isolated_mcp_graph(self):
"""Return a fresh ContextGraph injected as the mcp_server singleton."""
import semantica.mcp_server as mcp_mod
g = ContextGraph(advanced_analytics=False)
self._original_graph = mcp_mod._graph
mcp_mod._graph = g
return g
def _restore_mcp_graph(self):
import semantica.mcp_server as mcp_mod
mcp_mod._graph = self._original_graph
# ---- record_decision ------------------------------------------------
def test_record_decision_persists_to_kg_path(self):
"""_tool_record_decision must write the graph to SEMANTICA_KG_PATH."""
from semantica.mcp_server import _tool_record_decision
g = self._isolated_mcp_graph()
try:
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
path = f.name
try:
with patch.dict(os.environ, {"SEMANTICA_KG_PATH": path}):
result = _tool_record_decision({
"category": "mcp_server_persist",
"scenario": "Testing packaged server persistence",
"reasoning": "save_to_file must be called on mutation",
"outcome": "verified",
"confidence": 0.99,
})
self.assertNotIn("error", result, result)
self.assertIn("decision_id", result)
# File must have been written.
self.assertGreater(os.path.getsize(path), 0,
"save_to_file must have written to the KG_PATH file")
# Simulate restart: reload into a fresh graph.
g2 = ContextGraph(advanced_analytics=False)
g2.load_from_file(path)
decisions = list(g2.find_nodes(node_type="decision"))
self.assertGreater(len(decisions), 0,
"Decision must be present after save → load")
cats = [d.get("category") or (d.get("metadata") or {}).get("category")
for d in decisions]
self.assertIn("mcp_server_persist", cats)
finally:
os.unlink(path)
finally:
self._restore_mcp_graph()
def test_record_decision_works_without_kg_path(self):
"""_tool_record_decision must succeed when SEMANTICA_KG_PATH is unset."""
from semantica.mcp_server import _tool_record_decision
g = self._isolated_mcp_graph()
try:
env = {k: v for k, v in os.environ.items() if k != "SEMANTICA_KG_PATH"}
with patch.dict(os.environ, env, clear=True):
result = _tool_record_decision({
"category": "no_path",
"scenario": "no kg path",
"reasoning": "in-memory only",
"outcome": "ok",
"confidence": 0.5,
})
self.assertNotIn("error", result, result)
self.assertIn("decision_id", result)
finally:
self._restore_mcp_graph()
# ---- add_entity -----------------------------------------------------
def test_add_entity_persists_to_kg_path(self):
"""_tool_add_entity must write the graph to SEMANTICA_KG_PATH."""
from semantica.mcp_server import _tool_add_entity
g = self._isolated_mcp_graph()
try:
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
path = f.name
try:
with patch.dict(os.environ, {"SEMANTICA_KG_PATH": path}):
result = _tool_add_entity({
"id": "mcp_server_entity_test",
"label": "Persistence Entity",
"type": "TestEntity",
})
self.assertNotIn("error", result, result)
self.assertEqual(result.get("status"), "added")
self.assertGreater(os.path.getsize(path), 0)
g2 = ContextGraph(advanced_analytics=False)
g2.load_from_file(path)
self.assertTrue(g2.has_node("mcp_server_entity_test"),
"Entity must be present after save → load")
finally:
os.unlink(path)
finally:
self._restore_mcp_graph()
def test_add_entity_works_without_kg_path(self):
"""_tool_add_entity must succeed when SEMANTICA_KG_PATH is unset."""
from semantica.mcp_server import _tool_add_entity
g = self._isolated_mcp_graph()
try:
env = {k: v for k, v in os.environ.items() if k != "SEMANTICA_KG_PATH"}
with patch.dict(os.environ, env, clear=True):
result = _tool_add_entity({"id": "ephemeral_ent", "label": "E"})
self.assertNotIn("error", result, result)
self.assertEqual(result.get("status"), "added")
finally:
self._restore_mcp_graph()
# ---- add_relationship -----------------------------------------------
def test_add_relationship_persists_to_kg_path(self):
"""_tool_add_relationship must write the graph to SEMANTICA_KG_PATH."""
from semantica.mcp_server import _tool_add_entity, _tool_add_relationship
g = self._isolated_mcp_graph()
try:
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
path = f.name
try:
with patch.dict(os.environ, {"SEMANTICA_KG_PATH": path}):
_tool_add_entity({"id": "rel_src_mcp", "label": "Source"})
_tool_add_entity({"id": "rel_tgt_mcp", "label": "Target"})
result = _tool_add_relationship({
"source": "rel_src_mcp",
"target": "rel_tgt_mcp",
"type": "PROVEN_BY",
})
self.assertNotIn("error", result, result)
self.assertEqual(result.get("status"), "added")
self.assertGreater(os.path.getsize(path), 0)
g2 = ContextGraph(advanced_analytics=False)
g2.load_from_file(path)
edges = list(g2.find_edges())
self.assertTrue(any(e.get("type") == "PROVEN_BY" for e in edges),
"PROVEN_BY edge must be present after save → load")
finally:
os.unlink(path)
finally:
self._restore_mcp_graph()
def test_add_relationship_works_without_kg_path(self):
"""_tool_add_relationship must succeed when SEMANTICA_KG_PATH is unset."""
from semantica.mcp_server import _tool_add_entity, _tool_add_relationship
g = self._isolated_mcp_graph()
try:
env = {k: v for k, v in os.environ.items() if k != "SEMANTICA_KG_PATH"}
with patch.dict(os.environ, env, clear=True):
_tool_add_entity({"id": "src_no_p", "label": "S"})
_tool_add_entity({"id": "tgt_no_p", "label": "T"})
result = _tool_add_relationship({
"source": "src_no_p",
"target": "tgt_no_p",
"type": "RELATED_TO",
})
self.assertNotIn("error", result, result)
self.assertEqual(result.get("status"), "added")
finally:
self._restore_mcp_graph()
if __name__ == "__main__":
unittest.main()
-294
View File
@@ -1,294 +0,0 @@
"""Regression tests for root mcp/ graph persistence (issue #1134).
Covers:
1. get_graph() loads an existing JSON file via load_from_file(), not the
nonexistent .load() method (the original bug).
2. get_graph() with a nonexistent / unset SEMANTICA_KG_PATH starts cleanly.
3. handle_record_decision persists to SEMANTICA_KG_PATH and the mutation
survives a fresh load_from_file() call.
4. handle_add_entity persists to SEMANTICA_KG_PATH and survives reload.
5. handle_add_relationship persists to SEMANTICA_KG_PATH and survives reload.
6. All three mutation tools work correctly when SEMANTICA_KG_PATH is unset
(no errors, no persistence attempt).
"""
from __future__ import annotations
import os
import tempfile
import unittest
from unittest.mock import patch
from semantica.context.context_graph import ContextGraph
import mcp.session as _session
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fresh_graph() -> ContextGraph:
"""Return a minimal ContextGraph ready for use in tests."""
g = ContextGraph(advanced_analytics=False)
g.add_node("seed_node", node_type="entity", label="Seed")
return g
class _IsolatedSession:
"""Context manager that resets the mcp.session singleton before and after
each test so tests are independent of process-level state."""
def __enter__(self):
_session.reset_graph()
return self
def __exit__(self, *_):
_session.reset_graph()
# ---------------------------------------------------------------------------
# 1. get_graph() loading — regression against _graph.load()
# ---------------------------------------------------------------------------
class TestMCPSessionLoad(unittest.TestCase):
"""get_graph() must load an existing file using load_from_file(), not .load()."""
def test_get_graph_loads_existing_kg_path(self):
"""When SEMANTICA_KG_PATH points to a valid JSON file the graph must
contain the persisted nodes after get_graph() returns."""
g = _fresh_graph()
g.add_node("persistent_node", node_type="entity", label="Should survive")
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
path = f.name
try:
g.save_to_file(path)
with _IsolatedSession():
with patch.dict(os.environ, {"SEMANTICA_KG_PATH": path}):
loaded = _session.get_graph()
self.assertTrue(
loaded.has_node("persistent_node"),
"Node saved before server start must be present after load",
)
self.assertTrue(
loaded.has_node("seed_node"),
"seed_node from the persisted graph must also be present",
)
finally:
os.unlink(path)
def test_get_graph_with_nonexistent_kg_path_starts_empty(self):
"""When SEMANTICA_KG_PATH does not exist the graph initialises empty
(no error) matching pre-existing behaviour."""
with _IsolatedSession():
with patch.dict(os.environ, {"SEMANTICA_KG_PATH": "/nonexistent/path.json"}):
loaded = _session.get_graph()
# An empty graph has no nodes; at minimum it must be a ContextGraph.
self.assertIsNotNone(loaded)
nodes = list(loaded.find_nodes())
self.assertEqual(nodes, [], "Graph must be empty when KG_PATH does not exist")
def test_get_graph_without_kg_path_starts_empty(self):
"""When SEMANTICA_KG_PATH is absent the graph initialises empty."""
with _IsolatedSession():
env = {k: v for k, v in os.environ.items() if k != "SEMANTICA_KG_PATH"}
with patch.dict(os.environ, env, clear=True):
loaded = _session.get_graph()
self.assertIsNotNone(loaded)
def test_get_graph_uses_load_from_file_not_load(self):
"""Regression: ContextGraph has no .load() method; get_graph() must
call load_from_file() or the AttributeError is silently swallowed and
the graph silently stays empty. This test verifies the fix directly."""
g = _fresh_graph()
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
path = f.name
try:
g.save_to_file(path)
with _IsolatedSession():
with patch.dict(os.environ, {"SEMANTICA_KG_PATH": path}):
# If the old _graph.load(path) bug were present the graph
# would be empty (exception swallowed). With the fix the
# node must be present.
loaded = _session.get_graph()
self.assertTrue(
loaded.has_node("seed_node"),
"load_from_file must have been called; if .load() was used "
"the AttributeError is swallowed and the graph stays empty",
)
finally:
os.unlink(path)
# ---------------------------------------------------------------------------
# 25. Mutation persistence
# ---------------------------------------------------------------------------
class TestMCPPackageMutationPersistence(unittest.TestCase):
"""Mutations via the root mcp/ tool handlers must persist to SEMANTICA_KG_PATH
so the data survives a server restart (simulated by a fresh load_from_file)."""
# ---- record_decision ------------------------------------------------
def test_record_decision_persists_when_kg_path_set(self):
"""handle_record_decision must write to disk when SEMANTICA_KG_PATH is set."""
from mcp.tools.decisions import handle_record_decision
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
path = f.name
try:
with _IsolatedSession():
with patch.dict(os.environ, {"SEMANTICA_KG_PATH": path}):
result = handle_record_decision({
"category": "test_persistence",
"scenario": "Verifying mcp/ decision persistence",
"reasoning": "KG_PATH must be written on mutation",
"outcome": "verified",
"confidence": 0.99,
})
self.assertNotIn("error", result, result)
self.assertIn("decision_id", result)
# The file must have been written (or overwritten from empty).
self.assertTrue(os.path.exists(path), "save_to_file must create the file")
self.assertGreater(os.path.getsize(path), 0, "Persisted file must not be empty")
# Simulate server restart: load into a fresh graph.
g2 = ContextGraph(advanced_analytics=False)
g2.load_from_file(path)
decisions = list(g2.find_nodes(node_type="decision"))
self.assertGreater(len(decisions), 0, "Decision must survive reload")
cats = [d.get("category") or (d.get("metadata") or {}).get("category")
for d in decisions]
self.assertIn("test_persistence", cats,
"Decision category must be present after reload")
finally:
os.unlink(path)
def test_record_decision_works_without_kg_path(self):
"""handle_record_decision must succeed even when SEMANTICA_KG_PATH is unset."""
from mcp.tools.decisions import handle_record_decision
with _IsolatedSession():
env = {k: v for k, v in os.environ.items() if k != "SEMANTICA_KG_PATH"}
with patch.dict(os.environ, env, clear=True):
result = handle_record_decision({
"category": "no_path",
"scenario": "No persistence path configured",
"reasoning": "Should still work in-memory",
"outcome": "ok",
"confidence": 0.5,
})
self.assertNotIn("error", result, result)
self.assertIn("decision_id", result)
# ---- add_entity -----------------------------------------------------
def test_add_entity_persists_when_kg_path_set(self):
"""handle_add_entity must write to disk when SEMANTICA_KG_PATH is set."""
from mcp.tools.graph import handle_add_entity
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
path = f.name
try:
with _IsolatedSession():
with patch.dict(os.environ, {"SEMANTICA_KG_PATH": path}):
result = handle_add_entity({
"id": "entity_persist_test",
"label": "Persistence Test Entity",
"type": "TestType",
})
self.assertNotIn("error", result, result)
self.assertEqual(result.get("status"), "added")
self.assertTrue(os.path.exists(path))
self.assertGreater(os.path.getsize(path), 0)
g2 = ContextGraph(advanced_analytics=False)
g2.load_from_file(path)
self.assertTrue(
g2.has_node("entity_persist_test"),
"Entity must be present in the graph after reload",
)
finally:
os.unlink(path)
def test_add_entity_works_without_kg_path(self):
"""handle_add_entity must succeed when SEMANTICA_KG_PATH is unset."""
from mcp.tools.graph import handle_add_entity
with _IsolatedSession():
env = {k: v for k, v in os.environ.items() if k != "SEMANTICA_KG_PATH"}
with patch.dict(os.environ, env, clear=True):
result = handle_add_entity({"id": "no_path_entity", "label": "ephemeral"})
self.assertNotIn("error", result, result)
self.assertEqual(result.get("status"), "added")
# ---- add_relationship -----------------------------------------------
def test_add_relationship_persists_when_kg_path_set(self):
"""handle_add_relationship must write to disk when SEMANTICA_KG_PATH is set."""
from mcp.tools.graph import handle_add_relationship
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
path = f.name
try:
with _IsolatedSession():
with patch.dict(os.environ, {"SEMANTICA_KG_PATH": path}):
# Nodes must exist before an edge can be added.
from mcp.tools.graph import handle_add_entity
handle_add_entity({"id": "rel_src", "label": "Source"})
handle_add_entity({"id": "rel_tgt", "label": "Target"})
result = handle_add_relationship({
"source": "rel_src",
"target": "rel_tgt",
"type": "TESTED_BY",
})
self.assertNotIn("error", result, result)
self.assertEqual(result.get("status"), "added")
self.assertTrue(os.path.exists(path))
self.assertGreater(os.path.getsize(path), 0)
g2 = ContextGraph(advanced_analytics=False)
g2.load_from_file(path)
edges = list(g2.find_edges())
edge_types = [e.get("type") for e in edges]
self.assertIn("TESTED_BY", edge_types,
"Relationship must be present after reload")
finally:
os.unlink(path)
def test_add_relationship_works_without_kg_path(self):
"""handle_add_relationship must succeed when SEMANTICA_KG_PATH is unset."""
from mcp.tools.graph import handle_add_entity, handle_add_relationship
with _IsolatedSession():
env = {k: v for k, v in os.environ.items() if k != "SEMANTICA_KG_PATH"}
with patch.dict(os.environ, env, clear=True):
handle_add_entity({"id": "src_no_path", "label": "S"})
handle_add_entity({"id": "tgt_no_path", "label": "T"})
result = handle_add_relationship({
"source": "src_no_path",
"target": "tgt_no_path",
"type": "RELATED_TO",
})
self.assertNotIn("error", result, result)
self.assertEqual(result.get("status"), "added")
if __name__ == "__main__":
unittest.main()
-180
View File
@@ -1,180 +0,0 @@
"""MCP stdio JSON-RPC framing regression test (#1134, point 1).
The original bug: progress output from the Semantica progress tracker was
written to sys.stdout, which is also the MCP JSON-RPC transport channel.
Interleaving progress text with JSON-RPC responses made every response
unparseable and hung the client.
These tests exercise the *actual* root mcp/ server stdio framing loop
(SemanticaMCPServer.run()) over a real subprocess pipe, not just the handler
layer. They prove that:
1. Every non-empty stdout line produced by the running server is valid JSON.
2. A valid JSON-RPC response is received for each request sent.
3. No progress / non-JSON bytes appear on stdout even when a tool triggers
the progress-producing code path (constructing a ContextGraph, which
calls get_progress_tracker() and attempts to enable the tracker).
Tests that are already covered elsewhere are not duplicated here:
- ConsoleProgressDisplay writing to stderr (test_progress_stream.py)
- SEMANTICA_DISABLE_PROGRESS blocking re-enable (test_progress_tracker_regressions.py)
- mcp import sets SEMANTICA_DISABLE_PROGRESS (test_mcp_package_export_graph.py)
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import unittest
# ---------------------------------------------------------------------------
# Module-level helpers
# ---------------------------------------------------------------------------
def _repo_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
def _subprocess_env() -> dict[str, str]:
"""Clean env with the repo on PYTHONPATH and no pre-set progress flag."""
env = os.environ.copy()
env["PYTHONPATH"] = _repo_root()
env.pop("SEMANTICA_DISABLE_PROGRESS", None)
return env
def _jsonrpc(method: str, req_id: int | None, params: dict | None = None) -> bytes:
msg: dict = {"jsonrpc": "2.0", "method": method}
if req_id is not None:
msg["id"] = req_id
if params is not None:
msg["params"] = params
return (json.dumps(msg) + "\n").encode()
def _assert_stdout_is_clean_json(test: unittest.TestCase,
stdout: str,
stderr: str = "") -> list[dict]:
"""Assert every non-empty stdout line is valid JSON; return parsed objects.
Fails immediately with a useful diagnostic if any line is not JSON.
"""
lines = [ln for ln in stdout.splitlines() if ln.strip()]
test.assertGreater(
len(lines), 0,
f"Expected at least one stdout line but got none.\nstderr={stderr!r}",
)
parsed = []
for i, line in enumerate(lines):
try:
parsed.append(json.loads(line))
except json.JSONDecodeError as exc:
test.fail(
f"stdout line {i} is not valid JSON (regression: progress leaked "
f"to stdout?)\n line: {line!r}\n error: {exc}\n stderr={stderr!r}"
)
return parsed
_INIT_REQUEST = _jsonrpc("initialize", 1, {
"protocolVersion": "2024-11-05",
"clientInfo": {"name": "test", "version": "0"},
"capabilities": {},
})
# ---------------------------------------------------------------------------
# Main regression suite
# ---------------------------------------------------------------------------
class TestMCPStdioFramingContract(unittest.TestCase):
"""Run 'python -m mcp' exactly as an MCP client would, over a real pipe.
Each test sends a complete JSON-RPC session through stdin and asserts that
every byte on stdout is valid JSON catching the exact failure mode from
#1134 where progress output corrupted the transport stream.
"""
TIMEOUT = 30
def _run(self, *requests: bytes) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-m", "mcp"],
input=b"".join(requests),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=self.TIMEOUT,
cwd=_repo_root(),
env=_subprocess_env(),
check=False,
)
# ------------------------------------------------------------------
def test_initialize_stdout_is_valid_json_rpc(self):
"""An initialize request must produce a single valid JSON-RPC response."""
proc = self._run(_INIT_REQUEST)
self.assertEqual(proc.returncode, 0,
f"server crashed:\n{proc.stderr.decode()}")
responses = _assert_stdout_is_clean_json(
self, proc.stdout.decode(), proc.stderr.decode()
)
init_resp = next((r for r in responses if r.get("id") == 1), None)
self.assertIsNotNone(init_resp, f"No id=1 response in: {responses}")
self.assertIn("serverInfo", init_resp.get("result", {}))
def test_tools_call_stdout_is_clean_json_rpc(self):
"""A tools/call round-trip through the full stdio framing loop must keep
stdout free of any non-JSON bytes.
run_reasoning is used because Reasoner.infer_with_results() explicitly
calls self.progress_tracker.start_tracking(), making it the minimal
deterministic tool path that exercises the progress-rendering code.
Before the #1134 fix, that start_tracking call wrote a progress bar
directly to stdout, corrupting the JSON-RPC framing. Every byte on
stdout must still be valid JSON-RPC after the fix.
"""
proc = self._run(
_INIT_REQUEST,
_jsonrpc("notifications/initialized", None),
_jsonrpc("tools/call", 2, {
"name": "run_reasoning",
"arguments": {
"facts": ["Person(Alice)", "Employee(Alice)"],
"rules": ["IF Employee(?x) THEN Worker(?x)"],
},
}),
)
self.assertEqual(proc.returncode, 0,
f"server crashed:\n{proc.stderr.decode()}")
stdout = proc.stdout.decode()
stderr = proc.stderr.decode()
responses = _assert_stdout_is_clean_json(self, stdout, stderr)
tool_resp = next((r for r in responses if r.get("id") == 2), None)
self.assertIsNotNone(
tool_resp,
f"No id=2 response in stdout.\nstdout={stdout!r}\nstderr={stderr!r}",
)
# The framing must be a valid JSON-RPC result object regardless of
# whether the reasoner dependency is available in this environment.
self.assertIn("jsonrpc", tool_resp)
self.assertEqual(tool_resp["jsonrpc"], "2.0")
self.assertIn("id", tool_resp)
# If the tool succeeded the response must carry MCP content.
if "result" in tool_resp:
content = tool_resp["result"].get("content", [])
self.assertGreater(len(content), 0,
"Expected non-empty content list in result")
# The embedded tool payload must itself be valid JSON.
inner = json.loads(content[0]["text"])
self.assertIn("derived_facts", inner)
if __name__ == "__main__":
unittest.main()
+56 -40
View File
@@ -10,7 +10,7 @@ To run these tests locally with Docker:
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=test \
-p 5432:5432 \
ankane/pgvector:latest
pgvector/pgvector:pg16
pytest tests/vector_store/test_pgvector_store.py -v
@@ -63,29 +63,37 @@ TEST_CONNECTION_STRING = os.getenv(
@pytest.fixture(scope="module")
def pg_available() -> bool:
"""Check if PostgreSQL with pgvector is available."""
"""Check if PostgreSQL with pgvector is available.
A connection failure only means "skip" when TEST_PGVECTOR_URL wasn't set
explicitly, i.e. this is a local run falling back to the documented
default. CI sets it on purpose, so a failure there means the service is
genuinely broken and the suite should fail loudly instead of skipping.
"""
if not psycopg_available:
return False
explicit_url = "TEST_PGVECTOR_URL" in os.environ
try:
if psycopg_available:
try:
import psycopg
try:
import psycopg
conn = psycopg.connect(TEST_CONNECTION_STRING, connect_timeout=5)
except ImportError:
import psycopg2
conn = psycopg.connect(TEST_CONNECTION_STRING, connect_timeout=5)
except ImportError:
import psycopg2
conn = psycopg2.connect(TEST_CONNECTION_STRING, connect_timeout=5)
conn = psycopg2.connect(TEST_CONNECTION_STRING, connect_timeout=5)
cur = conn.cursor()
cur.execute("SELECT 1")
cur.close()
conn.close()
return True
cur = conn.cursor()
cur.execute("SELECT 1")
cur.close()
conn.close()
return True
except Exception:
if explicit_url:
raise
return False
return False
@pytest.fixture
@@ -191,7 +199,13 @@ class TestPgVectorStoreAdd:
ids = store.add(vectors, metadata)
assert len(ids) == 5
assert all(id.startswith("vec_") for id in ids)
assert len(set(ids)) == 5
# add() assigns uuid4 identifiers, not a "vec_" prefix
for vector_id in ids:
try:
uuid.UUID(vector_id)
except ValueError:
pytest.fail(f"{vector_id!r} is not a valid uuid4 id")
def test_add_auto_generate_ids(self, store):
"""Test that IDs are auto-generated if not provided."""
@@ -290,38 +304,40 @@ class TestPgVectorStoreSearch:
if not pg_available:
pytest.skip("PostgreSQL not available")
from semantica.vector_store.pgvector_store import PgVectorStore
from semantica.vector_store.pgvector_store import PgVectorStore, psycopg_sql
# setup_vectors is autouse and seeds unique_table_name, and fixtures are
# cached per test, so this needs a table of its own to be empty at all.
empty_table = f"{unique_table_name}_empty"
empty_store = PgVectorStore(
connection_string=TEST_CONNECTION_STRING,
table_name=unique_table_name,
table_name=empty_table,
dimension=128,
distance_metric="cosine",
)
query = np.random.rand(128).astype(np.float32)
results = empty_store.search(query, top_k=5)
assert len(results) == 0
# Cleanup: Drop test table after test completes
# Uses best-effort cleanup - failures are silently ignored since
# this is teardown of optional test resources
try:
with empty_store._get_connection() as conn:
cur = conn.cursor()
from semantica.vector_store.pgvector_store import psycopg_sql
drop_sql = psycopg_sql.SQL("DROP TABLE IF EXISTS {}").format(
psycopg_sql.Identifier(unique_table_name)
)
cur.execute(drop_sql)
conn.commit()
cur.close()
empty_store.close()
except Exception:
# Best-effort cleanup: PostgreSQL may be unavailable during teardown
# This is expected when tests are skipped or connection is lost
pass
query = np.random.rand(128).astype(np.float32)
results = empty_store.search(query, top_k=5)
assert len(results) == 0
finally:
try:
with empty_store._get_connection() as conn:
cur = conn.cursor()
cur.execute(
psycopg_sql.SQL("DROP TABLE IF EXISTS {}").format(
psycopg_sql.Identifier(empty_table)
)
)
conn.commit()
cur.close()
empty_store.close()
except Exception:
# Best-effort cleanup: PostgreSQL may be unavailable during
# teardown. This is expected when tests are skipped or the
# connection is lost.
pass
class TestPgVectorStoreGet: