Compare commits

..
Author SHA1 Message Date
Zohaib Hassnain 932e5ce1f4 docs(learning-more): describe the embeddings notebooks 2026-09-03 04:20:54 +05:00
Zohaib Hassnain 75fd56a4e8 Merge branch 'main' into docs-fix-dead-cookbook-links 2026-09-03 04:18:36 +05:00
Zohaib Hassnain 279fdbf15b docs(quickstart): qodo findings addressed (#1402) 2026-09-03 04:18:17 +05:00
Zohaib Hassnain 8823315dcd docs: fix two dead notebook links 2026-09-03 04:14:52 +05:00
Harsh Arora 45915e50a3 fix(context): vector_store=False must suppress AgentMemory's internal vector cascade in ErasureCoordinator (#1395)
* fix(erasure): ensure vector_store=False disables internal vector cascade in AgentMemory

* fix(erasure): ensure skip_vector=True does not orphan local vector ID tracking
2026-09-03 04:05:53 +05:00
Zohaib Hassnain b7b60d4a17 docs(quickstart): fix broken code against real APIs (#1401) 2026-09-03 04:05:19 +05:00
Zohaib Hassnain 25d2ea5fe9 docs: update stale latest version claims 2026-09-03 03:50:50 +05:00
Zohaib Hassnain 3c68cd12ad docs(mcp): correct tool count (#1399) 2026-09-03 03:40:52 +05:00
Sameer Kadam 798a7455e4 fix(mcp): complete persistence and setup fixes (#1394)
MCP's stdio transport uses stdout for JSON-RPC framing, so anything else written there corrupts every response after it. The original #1134 bug was progress-tracker output landing on stdout during tool calls that construct a `ContextGraph`, which is exactly what happens on any request that triggers reasoning or extraction. This PR closes out the remaining pieces of that fix: loading now goes through `load_from_file()` instead of the older `load()` path on the root graph, and mutations, `record_decision`, `add_entity`, `add_relationship`, now persist back to `SEMANTICA_KG_PATH` when it's configured, in both MCP server implementations (the root `mcp/` package and the packaged `semantica.mcp_server`), not just one.

Four things came out of review on top of that.

The stdio regression test originally exercised `get_graph_summary`, which doesn't touch the progress tracker at all, so it couldn't have caught the original bug. Swapped it for `run_reasoning`: `Reasoner.infer_with_results()` calls `progress_tracker.start_tracking()` directly, the exact call site that corrupted stdout before, so this is the minimal path that actually proves the fix. The test now spawns a real `python -m mcp` subprocess, sends it a `tools/call` for `run_reasoning`, and asserts every single line on stdout parses as JSON.

Loading a corrupt or unreadable `SEMANTICA_KG_PATH` used to fail silently and fall through to an empty graph, which meant the next mutation would happily save that empty graph over the original file. Both implementations now track whether the initial load actually succeeded. If it didn't, every mutation handler refuses to save and returns an error instead, so a broken file on disk stays broken rather than getting silently replaced with nothing. An empty file is treated differently: that's a fresh destination, not a corrupt one, and starts a normal empty graph without tripping the guard.

`save_to_file` used to `open(path, 'w')` and `json.dump` directly into the destination, so a crash or disk-full error mid-write could leave a truncated file as the only copy of the graph. It now writes to a temp file in the same directory, flushes, fsyncs, and only then `os.replace`s the destination, so the destination is always either the old contents or the new contents, never a partial write. The temp file gets cleaned up if anything fails before the replace.

And since a mutation is applied to the in-memory graph before the save happens, a save failure used to leave the in-memory graph ahead of what's on disk, an entity or decision the client thinks succeeded but that never made it to the file. `record_decision`, `add_entity`, and `add_relationship` all roll back the in-memory mutation now if `save_to_file` raises, so the client-visible state and the persisted state never diverge: either both hold the change or neither does.

104 tests passing across the MCP, persistence, and progress-tracking suites.
2026-09-03 03:35:20 +05:00
24 changed files with 1333 additions and 325 deletions
+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. 12 tools available instantly.
Use Semantica from Claude Desktop, Cursor, VS Code, or any MCP-aware tool — no Python code required after setup. 15 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 12 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 15 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 12 tools and 3 resources exposed over the MCP protocol.
- [MCP Server](reference/mcp_server) — All 15 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.
+6 -6
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, v0.5.0 ships with 12 security fixes |
| Production-ready? | Yes: 1,000+ tests, security fixes shipped in every release (see [CHANGELOG](https://github.com/semantica-agi/semantica/blob/main/CHANGELOG.md)) |
| 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.5.0**: released May 2026.
**v0.6.7**: released August 2026.
Highlights: Ontology Hub, Distance Intelligence, Parquet/XML ingestion, 12 security fixes, Graph Explorer redesign, NER gateway fix.
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.
```bash
pip install --upgrade semantica
@@ -173,7 +173,7 @@ This includes PyTorch with CUDA, FAISS GPU, and CuPy.
<Accordion title="How does Semantica handle large datasets?" icon="layer-group">
- **Batching**: process documents in configurable chunks to control memory usage
- **Parallel processing**: `Pipeline(workers=N)` runs extraction steps concurrently
- **Parallel processing**: the `semantica.pipeline` module can run independent, parallel-safe steps in the same dependency layer concurrently (see the [Pipeline guide](guides/pipeline))
- **Delta processing**: update graphs incrementally without full recompute on new data
- **Persistent backends**: swap in-memory NetworkX for Neo4j, FalkorDB, or Apache AGE for large-scale production graphs
@@ -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. v0.5.0 ships with:
Yes. Every release 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
- 12 security vulnerability fixes: eval injection, pickle deserialization, SQL injection, XXE, SSRF, ReDoS, path traversal, and more
- 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)
</Accordion>
+1 -1
View File
@@ -183,7 +183,7 @@ icon: "rocket"
}
```
12 tools available instantly: extract entities, query graph, record decisions, run reasoning, export results.
15 tools available instantly: extract entities, query graph, record decisions, run reasoning, export results.
**Next:** [MCP Server reference →](reference/mcp_server)
</Tab>
+4 -2
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 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.
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.
</Info>
## Architecture & Communication
@@ -132,7 +132,7 @@ docker run --rm -i \
ghcr.io/semantica-agi/semantica-mcp:latest
```
## What the Agent Can Do: The 12 Tools
## What the Agent Can Do: The 15 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,6 +140,8 @@ 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: 12 tools for Claude Desktop, VS Code, Cursor, Windsurf, Cline |
| `semantica.mcp_server` | MCP stdio server: 15 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
- 12 security vulnerabilities fixed in v0.5.0
- Ongoing security hardening: fixes shipped in every release ([CHANGELOG](https://github.com/semantica-agi/semantica/blob/main/CHANGELOG.md))
**Modular by Design** — Import only what you need.
- Use `NERExtractor` without a graph store
+1 -1
View File
@@ -46,7 +46,7 @@ Whether you're running your first pipeline or deploying Semantica in production,
[Building Knowledge Graphs notebook](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/07_Building_Knowledge_Graphs.ipynb): multi-source, deduplication, conflict resolution.
</Step>
<Step title="Add semantic search">
[Embeddings notebook](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/09_Embeddings.ipynb): providers, pooling strategies, vector stores.
[Embedding Generation notebook](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/12_Embedding_Generation.ipynb): generating embeddings, provider and model switching, dimensions. Then [Vector Store notebook](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/13_Vector_Store.ipynb): storing and searching vectors for retrieval.
</Step>
<Step title="Multi-source integration">
[Multi-Source Data Integration notebook](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/06_Multi_Source_Data_Integration.ipynb) for multi-source patterns.
+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: 12 MCP tools exposed
**Integrations:** Claude Desktop, VS Code, Cursor, Windsurf, Cline: 15 MCP tools exposed
### Seed
+85 -62
View File
@@ -5,7 +5,7 @@ icon: "rocket"
---
<Info>
**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>
**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>
</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.5.0
# 0.6.7
```
@@ -47,36 +47,24 @@ python -c "import semantica; print(semantica.__version__)"
<Step title="Ingest">
Load a document from a file, directory, URL, or database.
Load a document from a file or directory. The rest of this walkthrough follows
the file path; other sources are shown afterwards.
<CodeGroup>
```python File
```python
from semantica.ingest import FileIngestor
ingestor = FileIngestor()
sources = ingestor.ingest("data/report.pdf")
# Also accepts: .docx, .html, .json, .csv, .xlsx, .pptx, .parquet, .xml
# Also accepts a directory, .docx, .html, .json, .csv, .xlsx, .pptx, .parquet, .xml
```
```python Web
from semantica.ingest import WebIngestor
ingestor = WebIngestor(max_depth=2)
sources = ingestor.ingest("https://example.com/article")
```
```python Parquet / XML (v0.5.0)
from semantica.ingest import ParquetIngestor, XMLIngestor
# Single file or Hive-partitioned directory
sources = ParquetIngestor().ingest("data/events.parquet")
# XML with XSD schema validation
sources = XMLIngestor(validate_xsd="schema.xsd").ingest("data/records/")
```
</CodeGroup>
<Tip>
**Other sources.** `WebIngestor().ingest_url(url)` returns a `WebContent` whose
`.text` you can feed straight into the Extract step (no parsing needed).
`ParquetIngestor().ingest(path)` and `XMLIngestor().ingest(path, schema_path=...)`
return structured records rather than documents; build a graph from those with
`GraphBuilder().build({"entities": [...], "relationships": [...]})` directly.
</Tip>
</Step>
@@ -88,22 +76,24 @@ Extract structured text and layout from raw documents.
from semantica.parse import DocumentParser
parser = DocumentParser()
parsed = parser.parse(sources[0])
parsed = parser.parse(sources[0].path) # parse() takes a path string
print(parsed.text[:200]) # extracted text
print(parsed.metadata) # title, author, date, source
print(parsed["text"][:200]) # extracted text
print(parsed["metadata"]) # file_path, encoding, size, and format-specific keys
```
`parse()` returns a `dict` with `text`, `full_text`, and `metadata` keys.
<Tip>
For PDFs with tables, charts, or multi-column layouts, use `DoclingParser`: it applies advanced layout analysis and returns structured table data alongside text.
For PDFs with tables, charts, or multi-column layouts, use `DoclingParser` (`pip install semantica[parse-docling]`): it applies advanced layout analysis and returns structured table data alongside text.
</Tip>
```python
from semantica.parse import DoclingParser
parser = DoclingParser()
parsed = parser.parse(sources[0])
print(parsed.tables) # structured table objects
parsed = parser.parse(sources[0].path)
print(parsed["tables"]) # structured table data
```
</Step>
@@ -117,26 +107,28 @@ Identify named entities and extract typed relationships between them.
```python Pattern-based (fast, no API key)
from semantica.semantic_extract import NERExtractor, RelationExtractor
ner = NERExtractor(method="pattern")
entities = ner.extract(parsed)
# Returns: [{"text": "Apple Inc.", "type": "ORGANIZATION", "confidence": 0.98}, ...]
text = parsed["text"]
rel = RelationExtractor(method="rule")
relationships = rel.extract(parsed, entities=entities)
# Returns: [{"subject": "Steve Jobs", "predicate": "founded", "object": "Apple Inc."}, ...]
ner = NERExtractor(method="pattern")
entities = ner.extract(text)
# Returns: [Entity(text="Apple Inc.", label="ORG", start_char=0, end_char=10, confidence=0.7), ...]
rel = RelationExtractor(method="pattern")
relationships = rel.extract(text, entities=entities)
# Returns: [Relation(subject=Entity(...), predicate="founded_by", object=Entity(...), confidence=0.7), ...]
```
```python LLM-powered (higher accuracy)
from semantica.semantic_extract import NERExtractor, RelationExtractor
from semantica.llms import Groq
llm = Groq(model="llama-3.3-70b-versatile")
# Reads GROQ_API_KEY from the environment; provider/llm_model select the backend
text = parsed["text"]
ner = NERExtractor(method="llm", llm_provider=llm)
entities = ner.extract(parsed)
ner = NERExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
entities = ner.extract(text)
rel = RelationExtractor(method="llm", llm_provider=llm)
relationships = rel.extract(parsed, entities=entities)
rel = RelationExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
relationships = rel.extract(text, entities=entities)
```
</CodeGroup>
@@ -198,16 +190,17 @@ exporter.export(graph, file_path="graph.nt", format="nt")
from semantica.export import ParquetExporter
exporter = ParquetExporter()
exporter.export(graph, file_path="output/graph.parquet")
# Writes nodes.parquet + edges.parquet: ready for Spark, BigQuery, Databricks
exporter.export(graph, file_path="output/graph")
# Dict input writes one file per key: output/graph_entities.parquet and
# output/graph_relationships.parquet: ready for Spark, BigQuery, Databricks
```
```python ArangoDB
from semantica.export import ArangoAQLExporter
exporter = ArangoAQLExporter()
aql = exporter.export(graph)
# Returns ready-to-run AQL INSERT statements
exporter.export(graph, file_path="graph.aql")
# Writes ready-to-run AQL INSERT statements to graph.aql
```
</CodeGroup>
@@ -272,14 +265,21 @@ relationships = rel.extract(text, entities=entities)
<Accordion title="Multi-source incremental graph build" icon="layer-group">
```python
from semantica.ingest import FileIngestor
from semantica.parse import DocumentParser
from semantica.semantic_extract import NERExtractor, RelationExtractor
from semantica.kg import GraphBuilder
builder = GraphBuilder(merge_entities=True)
all_entities, all_rels = [], []
parser = DocumentParser()
ner = NERExtractor(method="pattern")
rel = RelationExtractor(method="pattern")
builder = GraphBuilder(merge_entities=True)
for doc in parsed_docs:
entities = ner.extract(doc)
rels = rel.extract(doc, entities=entities)
all_entities, all_rels = [], []
for source in FileIngestor().ingest("data/reports/"):
text = parser.parse(source.path)["text"]
entities = ner.extract(text)
rels = rel.extract(text, entities=entities)
all_entities.extend(entities)
all_rels.extend(rels)
@@ -359,7 +359,8 @@ graph = builder.build({"entities": entities, "relationships": relationships})
# Retrieve full lineage for any entity
sources = prov.get_all_sources("Apple Inc.")
print(sources[0])
# {"source": "data/report.pdf", "location": None, "timestamp": "...", "confidence": 0.98}
# {"source": "data/report.pdf", "location": None, "timestamp": "...",
# "confidence": 1.0, "metadata": {"confidence": 0.98}}
```
</Accordion>
@@ -373,32 +374,54 @@ print(sources[0])
<Accordion title="No entities extracted" icon="magnifying-glass">
The document likely contains scanned images rather than machine-readable text. Enable OCR:
The document likely contains scanned images rather than machine-readable text. `DocumentParser` warns when a PDF has no text layer; switch to `DoclingParser` with OCR enabled:
```python
from semantica.parse import DocumentParser
from semantica.parse import DoclingParser # pip install semantica[parse-docling]
parser = DocumentParser(ocr=True) # enables Tesseract OCR
parsed = parser.parse(sources[0])
parser = DoclingParser(enable_ocr=True)
parsed = parser.parse(sources[0].path)
```
</Accordion>
<Accordion title="Slow processing on large corpora" icon="gauge">
Enable parallel processing and GPU acceleration:
Install the GPU extras so embedding and ML inference run on CUDA:
```bash
pip install semantica[gpu]
```
```python
from semantica.pipeline import Pipeline
Scan the directory for paths first (no file contents are read), then handle one
document at a time and write to a persistent graph backend instead of the
in-memory graph:
pipeline = Pipeline(workers=8, batch_size=32)
pipeline.run(sources)
```python
from semantica.ingest import FileIngestor
from semantica.parse import DocumentParser
from semantica.semantic_extract import NERExtractor, RelationExtractor
from semantica.graph_store import GraphStore
from semantica.kg import GraphBuilder
ingestor = FileIngestor()
parser = DocumentParser()
ner = NERExtractor(method="pattern")
rel = RelationExtractor(method="pattern")
store = GraphStore(backend="neo4j", uri="bolt://localhost:7687",
user="neo4j", password="password")
builder = GraphBuilder(merge_entities=True, graph_store=store)
for info in ingestor.scan_directory("data/reports/", recursive=True):
text = parser.parse(info["path"])["text"] # one document loaded at a time
entities = ner.extract(text)
rels = rel.extract(text, entities=entities)
builder.build({"entities": entities, "relationships": rels})
```
For multi-step orchestration with configurable parallelism, see the
[Pipeline guide](guides/pipeline).
</Accordion>
<Accordion title="Memory errors on large graphs" icon="memory">
-2
View File
@@ -611,5 +611,3 @@ The Knowledge Explorer embeds Distance Intelligence directly in the browser dash
- [Knowledge Graph Module](kg) — `NodeEmbedder`, `SimilarityCalculator`, and graph analytics.
- [Visualization](visualization) — Programmatic distance heatmaps and ego-mode graph renders.
- [Explorer](explorer) — Knowledge Explorer with built-in Distance Intelligence dashboard.
- [Distance Intelligence](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/12_Distance_Intelligence.ipynb) — Semantic neighborhoods and distance matrices · Advanced
+46 -203
View File
@@ -1,221 +1,64 @@
---
title: "Evals Module"
description: "Score decision records, audit trails, and reasoning output with deterministic and model-backed evaluators plus a small run harness."
description: "Evaluation framework for measuring Knowledge Graph quality, extraction accuracy, and pipeline performance: coming soon."
icon: "chart-line"
---
`semantica.evals` measures the quality of decision intelligence outputs. It takes
the decisions, audit trails, and reasoning text your pipeline produces and scores
them against expectations you define, returning a structured summary you can log,
assert on in tests, or track across runs.
**`semantica.evals`** is planned as a comprehensive evaluation framework for measuring **extraction accuracy, graph quality, and pipeline performance**.
- A registry of named evaluators, from exact string matching to ROUGE overlap and
LLM-as-judge
- `decision_scores`, a composite evaluator for `Decision` objects that checks
outcome, confidence bounds, required fields, provenance, and (optionally)
policy compliance
- A `evaluate()` runner that applies several evaluators to a list of cases and
aggregates pass / fail / error counts
- Per-evaluator **objectives** that let you override an evaluator's built-in
verdict at the run level
<Warning>
**`semantica.evals` is not yet implemented.** The module is a placeholder with `__all__ = []`. No classes or functions are available for import. This page describes the planned API only.
</Warning>
<Note>
The module is versioned separately from the package: `semantica.evals.__version__`
is `"0.1.0"`. The public surface described here is stable, but expect additive
changes (new evaluators, new objective options) before it reaches 1.0.
</Note>
## Planned Features
## Public API
When released, `semantica.evals` will provide:
| Name | Kind | Role |
| :--- | :--- | :--- |
| `evaluate(cases, evaluators, config=None, target_fn=None)` | function | Run named evaluators over each case, return an `EvalSummary` |
| `list_evaluators()` | function | Sorted names of every registered evaluator |
| `get_evaluator(name)` | function | Look up a single evaluator function by name |
| `EvalMetric` | dataclass (frozen) | One evaluator's result: `score`, `passed`, `meta` |
| `CaseResult` | namedtuple | One case's result: `case_id`, `status`, `metrics`, `details` |
| `EvalSummary` | dataclass | Aggregate across cases: `total`, `passed`, `failed`, `errors`, `pass_rate`, `cases` |
```python
import semantica.evals as evals
from semantica.evals import evaluate, list_evaluators, get_evaluator
```
## Built-in evaluators
Every evaluator is a plain function `fn(actual, expected, config=None) -> EvalMetric`
registered under a stable name. `list_evaluators()` returns the current set:
```python
>>> list_evaluators()
['decision_scores', 'exact_match', 'keyword_check', 'length_range',
'levenshtein', 'llm_as_judge', 'numeric_range', 'regex_match', 'rouge',
'temporal_range']
```
| Name | Passes when | Relevant `config` keys |
| :--- | :--- | :--- |
| `exact_match` | `actual == expected` | none |
| `regex_match` | `re.search(expected, actual)` matches | none |
| `keyword_check` | every required term appears in `actual` (word-boundary) | `required` (falls back to `expected`) |
| `numeric_range` | `min <= actual <= max` | `min`, `max` (both required) |
| `temporal_range` | ISO datetime `actual` falls in `[min, max]` | `min`, `max` as ISO strings (both required) |
| `length_range` | `min <= len(actual) <= max` | `min` (default 0), `max` (required) |
| `levenshtein` | normalized similarity `>= threshold` | `threshold` (default 0.8) |
| `rouge` | ROUGE-1 F1 `> 0` and `>= threshold` | `threshold` (default 0.0) |
| `llm_as_judge` | caller-supplied `judge_fn(actual, expected)` returns truthy | `judge_fn` (required callable) |
| `decision_scores` | all configured sub-checks on a `Decision` pass | see below |
An evaluator that cannot run (bad regex, missing bound, no `judge_fn`) returns an
`EvalMetric` with an `"error"` key in `meta` rather than raising.
### `decision_scores`
`decision_scores` accepts a `Decision` (from `semantica.context.decision_models`)
or its dict form and runs a set of field-level and governance checks. The score is
the fraction of checks that passed; `passed` is `True` only when all of them did.
| Sub-check | Controlled by |
| Planned Class | Role |
| :--- | :--- |
| Outcome matches | `expected_outcome` in config, or the case's `expected` |
| Confidence in range | `min_confidence` (default 0.0), `max_confidence` (default 1.0) |
| `decision_maker`, `reasoning`, `scenario` non-empty | always run |
| Provenance present in metadata | `provenance_key` (default `"provenance"`) |
| Policy compliance | `policy_engine` and `policy_id` both set; skipped otherwise |
| `KGEvaluator` | Completeness, consistency, schema compliance, coverage, and orphan node detection |
| `ExtractionEvaluator` | NER precision / recall / F1 and relation extraction metrics against gold datasets |
| `PipelineBenchmark` | Throughput (docs/sec), per-step latency, peak memory, and error rate |
| `RegressionTracker` | Record runs and compare metrics across commits or config changes |
| `EvalReport` | Structured report: `{scores, regressions, recommendations}` |
| `DeduplicationEvaluator` | Merge precision, false positive / false negative rates |
| `ReasoningEvaluator` | Inference accuracy, rule coverage, and derivation depth |
Passing `causal_chain_exists` in config raises `NotImplementedError`. That key is a
reserved slot for a future release.
## Current Workaround
## Running an evaluation
`evaluate()` takes a list of cases and a list of evaluator names. A case is either
a `(expected, actual)` tuple or a dict:
Until `semantica.evals` ships, use `semantica.ontology.OntologyEvaluator` for ontology quality metrics:
```python
{
"id": "loan-001", # optional, generated if absent
"expected": ..., # optional; some evaluators read it, some don't
"actual": ..., # the value under test
"config": {...}, # optional, per-evaluator settings for this case
"target_fn": callable, # optional, called with the case to produce `actual`
}
from semantica.ontology import OntologyEvaluator
evaluator = OntologyEvaluator()
# evaluate_ontology takes the ontology dict only
result = evaluator.evaluate_ontology(ontology)
print("Coverage: ", result.coverage_score)
print("Completeness:", result.completeness_score)
print("Gaps: ", result.gaps)
print("Suggestions: ", result.suggestions)
# Full report with class granularity and relation completeness
report = evaluator.generate_report(ontology)
print("Coverage score: ", report["evaluation"]["coverage_score"])
print("Completeness score:", report["evaluation"]["completeness_score"])
print("Relation coverage: ", report["relation_completeness"]["relation_coverage"])
```
If `actual` is missing, the runner calls the case's `target_fn` (or the
`target_fn` passed to `evaluate()`) to produce it. Per-case `config` is deep-merged
over the top-level `config`, so a case can override one evaluator's settings
without discarding the rest.
`EvaluationResult` fields returned by `evaluate_ontology()`:
```python
from datetime import datetime
| Field | Type | Description |
| :----- | :---- | :----------- |
| `coverage_score` | `float` | Fraction of competency questions answerable by the ontology |
| `completeness_score` | `float` | Average of class and property completeness scores |
| `gaps` | `List[str]` | Identified gaps in coverage |
| `suggestions` | `List[str]` | Improvement suggestions |
| `metrics` | `dict` | Detailed sub-metrics |
from semantica.context.decision_models import Decision
from semantica.evals import evaluate
decision = Decision(
decision_id="d-1",
category="loan",
scenario="loan-request",
reasoning="vetted against lending policy v3",
outcome="approve",
confidence=0.87,
timestamp=datetime.now(),
decision_maker="approver-a",
metadata={"provenance": "workflow:loan/v3"},
)
cases = [
{
"id": "loan-001",
"actual": decision,
"config": {
"decision_scores": {
"expected_outcome": "approve",
"min_confidence": 0.7,
}
},
},
]
summary = evaluate(cases, ["decision_scores"])
print(summary.pass_rate) # 1.0
```
Evaluators run independently per case. If one raises, that case's `status` becomes
`"error"` and the exception text is captured in the metric's `meta`; the rest of
the run continues.
## Objectives
By default each evaluator decides its own pass / fail. An **objective** overrides
that verdict at the run level, keyed by evaluator name under `config`:
```python
# Raise levenshtein's bar from its default 0.8 to 0.9
evaluate(
[("apple", "aple")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": 0.9}}},
)
# Lower is better
evaluate(
[("night", "nacht")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.5}}},
)
# Expect the metric NOT to match
evaluate(
[("ok", "ok")],
evaluators=["exact_match"],
config={"exact_match": {"objective": {"expect": False}}},
)
```
Rules:
- `maximize` with `threshold`: pass iff `score >= threshold`. `maximize` with no
threshold is a no-op and the evaluator's own verdict stands.
- `minimize` with `threshold`: pass iff `score <= threshold`. `minimize`
**requires** a threshold; omitting it raises `ValueError`.
- `expect` (`True` / `False`): pass iff `bool(score)` equals it. Cannot be combined
with `direction` or `threshold`, and must be a real boolean.
- A metric that already carries an `"error"` in its `meta` is unaffected by any
objective.
- Invalid objective config is validated for every case before any evaluator runs,
so a bad objective fails the whole run up front rather than partway through.
## Reading the summary
```python
summary = evaluate(cases, ["decision_scores"])
summary.total, summary.passed, summary.failed, summary.errors
summary.pass_rate # passed / total, or 1.0 for an empty case list
for case in summary.cases:
print(case.case_id, case.status) # status: "pass" | "fail" | "error"
for name, metric in case.metrics.items():
print(name, metric.score, metric.passed)
print(metric.meta.get("reasons", {})) # per-sub-check failure reasons
```
`EvalMetric` is frozen (`score: float`, `passed: bool`, `meta: dict`). `CaseResult`
is a namedtuple, and `EvalSummary` is a plain dataclass, so all three are
straightforward to serialize for logging or regression tracking.
## Notes
- `llm_as_judge` needs `config["judge_fn"]`, a callable
`judge_fn(actual, expected) -> bool` you supply. No LLM backend is imported
unless you pass one in.
- `decision_scores` governance checks are opt-in: policy compliance is only
evaluated when both `policy_engine` and `policy_id` are present.
## See also
- [Decision Intelligence](../guides/decision-intelligence) — producing the `Decision` records this module scores
- [Reasoning](reasoning) — inference output that reasoning-text evaluators can measure
- [Policy Engine](../guides/policy-engine) — the `policy_engine` used by `decision_scores`
- [Ontology Evaluator](ontology) — separate tooling for ontology quality metrics
- [Semantic Extract](semantic_extract) — Extraction module.
- [Knowledge Graph](kg) — Graph quality assessment.
- [Pipeline](pipeline) — Pipeline performance metrics.
- [Ontology Evaluator](ontology) — Available now for ontology quality metrics.
+51 -3
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**:
- 12 MCP tools exposed: extract entities, query graph, record decisions, run reasoning, export results
- 15 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
- **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.
- **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.
- **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 12 tools that any connected AI assistant can call:
The MCP server exposes 15 tools that any connected AI assistant can call:
| Tool | Category | Description |
| :---- | :-------- | :----------- |
@@ -173,6 +173,9 @@ The MCP server exposes 12 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`) |
@@ -386,6 +389,51 @@ 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
+33 -7
View File
@@ -8,8 +8,9 @@ Connects Claude Code, Cursor, Windsurf, Cline, Continue, VS Code (GitHub Copilot
## Quick start
```bash
# From the repo root
pip install -e ".[mcp]"
# 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 .
# Test the server (type a JSON-RPC request, press Enter)
python -m mcp
@@ -89,7 +90,14 @@ python -m mcp [--debug]
## Per-tool configuration
### Claude Code (`~/.claude/settings.json`)
### 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:
```json
{
@@ -97,15 +105,33 @@ python -m mcp [--debug]
"semantica": {
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
"env": {
"PYTHONPATH": "/path/to/semantica"
}
}
}
}
```
Or use the plugin bundle:
> **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):
```bash
claude mcp add semantica python -m mcp --cwd /path/to/semantica
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
```
---
@@ -216,7 +242,7 @@ Add to your Q Developer MCP config:
| Variable | Default | Description |
|---|---|---|
| `SEMANTICA_KG_PATH` | *(in-memory)* | Path to persist/load the graph (JSON file) |
| `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. |
---
+35 -7
View File
@@ -16,6 +16,13 @@ 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:
"""
@@ -24,24 +31,45 @@ def get_graph() -> Any:
The graph is created with advanced_analytics=True so all centrality,
community-detection, and embedding features are available.
"""
global _graph
global _graph, _load_ok
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):
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)
# 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
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
global _graph, _load_ok
_graph = None
_load_ok = True
+35 -1
View File
@@ -5,6 +5,7 @@ Decision intelligence tools — record, query, precedents, causal chain, impact.
from __future__ import annotations
import logging
import os
from mcp.schemas import (
ANALYZE_DECISION_IMPACT,
@@ -13,7 +14,7 @@ from mcp.schemas import (
QUERY_DECISIONS,
RECORD_DECISION,
)
from mcp.session import get_graph
from mcp.session import get_graph, is_persistence_safe
log = logging.getLogger("semantica.mcp.tools.decisions")
@@ -37,6 +38,39 @@ 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",
+70 -1
View File
@@ -5,9 +5,10 @@ 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
from mcp.session import get_graph, is_persistence_safe
log = logging.getLogger("semantica.mcp.tools.graph")
@@ -25,6 +26,35 @@ 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")
@@ -46,6 +76,45 @@ 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")
+15 -12
View File
@@ -588,16 +588,15 @@ class AgentMemory:
return False
# Remove from vector store unless a caller is staging an atomic local update.
if not skip_vector:
if self.vector_store:
try:
vector_ids = list(self._vector_ids.get(memory_id, [])) or [
memory_id
]
self._delete_vector_ids(vector_ids)
except Exception as e:
self.logger.warning(f"Failed to delete from vector store: {e}")
self._vector_ids.pop(memory_id, None)
if not skip_vector and self.vector_store:
try:
vector_ids = list(self._vector_ids.get(memory_id, [])) or [memory_id]
self._delete_vector_ids(vector_ids)
except Exception as e:
self.logger.warning(f"Failed to delete from vector store: {e}")
# Bookkeeping runs unconditionally: a skip_vector delete still removes the
# item, so leaving its tracked ids behind would orphan them permanently.
self._vector_ids.pop(memory_id, None)
memory_item = self.memory_items[memory_id]
@@ -1588,12 +1587,16 @@ class AgentMemory:
memory_ids.append(memory_id)
return memory_ids
def batch_delete(self, memory_ids: List[str]) -> int:
def batch_delete(self, memory_ids: List[str], *, skip_vector: bool = False) -> int:
"""
Batch delete.
Args:
memory_ids: List of memory IDs to delete
skip_vector: If True, skip each item's own vector-store cascade
(see ``delete_memory``). A caller that is already erasing these
ids' vectors itself passes this to avoid a redundant,
best-effort delete against the vector store.
Returns:
Number of memories deleted
@@ -1603,7 +1606,7 @@ class AgentMemory:
"""
deleted = 0
for memory_id in memory_ids:
if self.delete_memory(memory_id):
if self.delete_memory(memory_id, skip_vector=skip_vector):
deleted += 1
return deleted
+24 -2
View File
@@ -1203,8 +1203,30 @@ class ContextGraph:
"links": links_data,
}
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# 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
self.logger.info(f"Saved context graph to {path}")
+62 -1
View File
@@ -34,6 +34,7 @@ Example:
'unsupported'
"""
import inspect
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
@@ -150,6 +151,24 @@ class ErasureCoordinator:
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.
Note:
An explicit ``vector_store=False`` also suppresses ``AgentMemory``'s
own internal vector cascade, not just the coordinator's leg (#1378).
``AgentMemory.delete_memory()`` deletes an item's vectors best-effort:
it catches a vector-store failure, logs it, and still returns ``True``,
so without this a caller who opted out of the vector leg could still
have ``memory.vector_store`` mutated underneath them while the receipt
read ``vectors: not_configured``. ``vector_store=False`` is taken to
mean "no vector activity at all", so the coordinator passes
``skip_vector=True`` through to ``memory.batch_delete()`` in that case,
and ``receipt.stores["vectors"]["status"]`` stays ``"not_configured"``
honestly -- the caller opted the vector store out entirely, rather than
the coordinator having erased it. This only applies when
``vector_store=False`` was passed explicitly; when no vector store
exists anywhere (no ``memory`` was supplied, or ``memory`` has no
``vector_store`` attribute), there is nothing to suppress and
``memory.batch_delete()`` is called as before.
"""
def __init__(
@@ -170,6 +189,12 @@ class ErasureCoordinator:
self.graph = graph
self.memory = memory
# Distinct from `self.vector_store is None`: that's also true when no
# vector store exists anywhere (no memory, or memory with no
# vector_store attribute), where there is nothing to suppress and
# forcing skip_vector onto a duck-typed memory would break callers
# whose batch_delete() doesn't accept that kwarg.
self._vector_leg_disabled = vector_store is False
if vector_store is False:
self.vector_store: Optional[Any] = None
elif vector_store is not None:
@@ -424,6 +449,20 @@ class ErasureCoordinator:
return {"status": STATUS_NOT_CONFIGURED}
deleted = 0
skip_vector = self._vector_leg_disabled and _accepts_skip_vector(
self.memory.batch_delete
)
if self._vector_leg_disabled and not skip_vector:
# The class docstring only requires find_by_entity/batch_delete; a
# duck-typed adapter is not required to support skip_vector. Falling
# back to the plain call keeps the memory leg working -- the
# adapter's own cascade (if it has one) just can't be suppressed.
self.logger.warning(
"Memory adapter %r has no skip_vector support; its own vector "
"cascade (if any) could not be suppressed for %r",
type(self.memory).__name__,
entity_id,
)
try:
# Sweep in pages until dry rather than passing one large limit:
# ``find_by_entity`` has historically defaulted to ``limit=10`` and
@@ -454,7 +493,10 @@ class ErasureCoordinator:
"detail": "memory items carry no 'memory_id'",
}
removed = self.memory.batch_delete(memory_ids)
if skip_vector:
removed = self.memory.batch_delete(memory_ids, skip_vector=True)
else:
removed = self.memory.batch_delete(memory_ids)
deleted += removed
if removed == 0:
# No progress: another page would return the same items.
@@ -564,6 +606,25 @@ def _memory_item_id(item: Any) -> Optional[str]:
return str(memory_id) if memory_id else None
def _accepts_skip_vector(batch_delete: Any) -> bool:
"""True when ``batch_delete`` takes a ``skip_vector`` keyword.
``skip_vector`` is an ``AgentMemory``-specific extension, not part of the
duck-typed contract the class docstring promises (``find_by_entity`` and
``batch_delete`` only). Passing it to an adapter that doesn't accept it
would raise ``TypeError`` and fail the whole memory leg, so this is
checked before ever passing the kwarg.
"""
try:
signature = inspect.signature(batch_delete)
except (TypeError, ValueError):
return False
for parameter in signature.parameters.values():
if parameter.name == "skip_vector" or parameter.kind == inspect.Parameter.VAR_KEYWORD:
return True
return False
#: 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
+110 -6
View File
@@ -72,19 +72,34 @@ 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
global _graph, _kg_load_ok
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):
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)
# 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
return _graph
@@ -179,6 +194,35 @@ 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"}
@@ -246,6 +290,31 @@ 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}
@@ -259,6 +328,41 @@ 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,5 +1037,195 @@ 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()
+85 -2
View File
@@ -678,7 +678,7 @@ class TestSeparateVectorStoreHandling(unittest.TestCase):
"""
def test_vector_store_false_disables_vector_leg_entirely(self):
"""vector_store=False must disable the vector leg, not try memory.vector_store."""
"""vector_store=False must disable the vector leg AND memory's own cascade (#1378)."""
memory_store = _SelectiveDeleteStore()
memory = _memory_with_embedding("customer-4471", memory_store)
@@ -689,8 +689,91 @@ class TestSeparateVectorStoreHandling(unittest.TestCase):
# 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)
# Memory's own internal vector cascade must be suppressed too, not just
# unreported: the embedding memory owns is left untouched, and the
# backend's delete method is never even called.
self.assertEqual(memory_store.attempts, [])
self.assertTrue(memory_store.live)
def test_vector_store_false_regression_refusing_backend_never_called(self):
"""Regression for #1378: a refusing backend must not be called at all.
Reproduces the exact bug report -- a vector store whose delete_vectors()
always returns False (refuses) bound as memory.vector_store, with the
coordinator's own vector leg disabled via vector_store=False. Before the
fix, delete_memory()'s internal cascade would still call the refusing
store, catch the failure, log a warning, and return True regardless --
so receipt.complete read True while the embedding stayed live and the
backend had in fact been asked to delete it. Pinned here so the delete
method call count can't silently regress back to nonzero.
"""
refusing_store = _SelectiveDeleteStore(refuse={"vec-0"})
memory = _memory_with_embedding("customer-4471", refusing_store)
receipt = ErasureCoordinator(
memory=memory, vector_store=False
).erase_entity("customer-4471")
self.assertTrue(receipt.complete)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_NOT_CONFIGURED)
self.assertEqual(len(refusing_store.attempts), 0) # delete_calls == 0
def test_skip_vector_deletion_does_not_orphan_local_vector_id_tracking(self):
"""skip_vector=True must still pop the item's own _vector_ids entry.
Regression: delete_memory(skip_vector=True) used to leave the item's
entry in AgentMemory._vector_ids behind since the pop() lived inside
the `if not skip_vector` block alongside the actual vector-store
delete. That orphaned entry never got cleaned up and leaked into
to_dict()/from_dict() snapshots.
"""
memory = _memory_with_embedding("customer-4471", _SelectiveDeleteStore())
memory_id = next(iter(memory.memory_items))
self.assertIn(memory_id, memory._vector_ids)
ErasureCoordinator(memory=memory, vector_store=False).erase_entity(
"customer-4471"
)
self.assertNotIn(memory_id, memory.memory_items)
self.assertNotIn(memory_id, memory._vector_ids)
def test_memory_adapter_without_skip_vector_support_is_not_broken(self):
"""A duck-typed memory whose batch_delete() lacks skip_vector must still work.
The class docstring only requires find_by_entity and batch_delete; an
adapter is not obligated to support skip_vector. The coordinator must
detect that and fall back to the plain call rather than raising
TypeError and failing the whole memory leg.
"""
class _PlainAdapter:
def __init__(self):
self.items = {"m1": {"memory_id": "m1", "entities": [{"id": "customer-4471"}]}}
def find_by_entity(self, entity_id, limit=None):
return [
item
for item in self.items.values()
if any(e.get("id") == entity_id for e in item.get("entities", []))
]
def batch_delete(self, memory_ids):
removed = 0
for memory_id in memory_ids:
if self.items.pop(memory_id, None) is not None:
removed += 1
return removed
adapter = _PlainAdapter()
receipt = ErasureCoordinator(
memory=adapter, vector_store=False
).erase_entity("customer-4471")
self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED)
self.assertEqual(adapter.items, {})
def test_separate_vector_store_only_handles_coordinator_store(self):
"""When coordinator has a different vector_store, it only handles that one.
+294
View File
@@ -0,0 +1,294 @@
"""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
@@ -0,0 +1,180 @@
"""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()