mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-04 04:01:07 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92fed2d0a | ||
|
|
8910d2c949 | ||
|
|
4c6eab632f | ||
|
|
103ab04970 | ||
|
|
88a57b39f9 | ||
|
|
f45499b5a7 | ||
|
|
c04adcd1a9 | ||
|
|
a85cf913a5 | ||
|
|
6ba433fea0 | ||
|
|
75bcb64681 | ||
|
|
f6a0e4a32e | ||
|
|
111bcf997e | ||
|
|
6a07ad29be | ||
|
|
064f0eccad | ||
|
|
afec253451 | ||
|
|
dd1e654047 | ||
|
|
6b8437781e | ||
|
|
ba85215aea | ||
|
|
8778e6a837 | ||
|
|
5809418421 | ||
|
|
01352d5fd5 | ||
|
|
6a55b8c3ee | ||
|
|
471cbe7711 |
@@ -28,7 +28,13 @@
|
||||
|
||||
[](https://github.com/semantica-agi/semantica) [](https://github.com/semantica-agi/semantica/network/members) [](https://github.com/semantica-agi/semantica/graphs/contributors) [](https://pypi.org/project/semantica/) [](https://pepy.tech/project/semantica) [](https://www.python.org/) [](https://opensource.org/licenses/MIT) [](https://github.com/semantica-agi/semantica/actions) [](https://github.com/semantica-agi/semantica/actions/workflows/install-matrix.yml) [](https://scorecard.dev/viewer/?uri=github.com/semantica-agi/semantica) [](https://deepwiki.com/semantica-agi/semantica)
|
||||
|
||||
[](https://getsemantica.ai/) [](https://docs.getsemantica.ai/) [](https://discord.gg/sV34vps5hH) [](https://x.com/BuildSemantica) [](https://www.youtube.com/watch?v=QfnNZg4-dZA) [](CHANGELOG.md)
|
||||
[](https://getsemantica.ai/)
|
||||
[](https://docs.getsemantica.ai/)
|
||||
[](https://discord.gg/sV34vps5hH)
|
||||
[](https://x.com/BuildSemantica)
|
||||
|
||||
[](https://www.youtube.com/watch?v=QfnNZg4-dZA)
|
||||
|
||||
|
||||
```bash
|
||||
pip install semantica
|
||||
@@ -1455,6 +1461,22 @@ semantica-explorer --graph my_graph.json
|
||||
|
||||
For contributor / dev-server setup: **[explorer/README.md: Local Setup Guide](explorer/README.md)**
|
||||
|
||||
The CLI exposes the loaded `ContextGraph`. To also browse and edit an existing
|
||||
`AgentMemory`, create the ASGI app programmatically with both live objects:
|
||||
|
||||
```python
|
||||
from semantica.context import AgentMemory, ContextGraph
|
||||
from semantica.explorer.app import create_app
|
||||
from semantica.explorer.session import GraphSession
|
||||
|
||||
graph = ContextGraph()
|
||||
memory = AgentMemory()
|
||||
app = create_app(session=GraphSession(graph), agent_memory=memory)
|
||||
```
|
||||
|
||||
The Memories workspace is shown only when `agent_memory` is provided. Apply
|
||||
updates the supplied runtime object; it does not add disk persistence.
|
||||
|
||||
---
|
||||
|
||||
## What's New in v0.6.7
|
||||
@@ -1463,7 +1485,7 @@ For contributor / dev-server setup: **[explorer/README.md: Local Setup Guide](ex
|
||||
|
||||
- **First-class LangChain integration** (`semantica[langchain]`): a `BaseRetriever` and `VectorStore` over `HybridSearch`, plus graph/decision-query tools
|
||||
- **SAP OData ingestor** (`semantica[ingest-sap]`): OAuth2/Basic-auth, SSRF-guarded ingestion for Business Partners and Sales Orders, following the existing Snowflake/Databricks connector pattern
|
||||
- **`ContextGraph` gains deterministic, human-editable Markdown round-trip persistence** alongside the existing JSON API, and the Explorer graph inspector gains a read-only Markdown content viewer
|
||||
- **`ContextGraph` gains deterministic, human-editable Markdown round-trip persistence** alongside the existing JSON API, and Explorer can validate and apply Markdown edits to individual graph nodes and AgentMemory items supplied by the hosting application
|
||||
- **`reasoning` gains a structured Action layer**: rule-driven `Assert`/`Retract`/`Call`/`EmitEvent` actions with optional provenance, turning the reasoner into a production-rule system
|
||||
- **`run_shacl_validation` is now a public, documented API**, and a dozen ontology/RDF export correctness fixes land: OWL property/class export, SHACL target-namespace resolution, one canonical confidence datatype across all four RDF formats, reachable OWL-Time reification, JSON-LD default-graph and content-derived document identity, and full metadata passthrough on every RDF serializer
|
||||
- **Security**: Agno's `AgnoKnowledgeGraph.load_urls()` and OpenClaw's MCP tool now route outbound requests through the shared SSRF guard
|
||||
|
||||
+32
-19
@@ -132,16 +132,17 @@ stats = context.store(
|
||||
print("Graph built: {} nodes, {} edges".format(
|
||||
stats["graph_nodes"], stats["graph_edges"]
|
||||
))
|
||||
# Graph built: 18 nodes, 14 edges
|
||||
# Nodes: APT29, HAMMERTOSS, NATO, LifeCare, AS59796, CISA Sector 6, ...
|
||||
# Edges: deployed, observed_on, classified_as, targets, operates_in, ...
|
||||
```
|
||||
|
||||
`store()` returns a dict with `stored_count`, `memory_ids`, `graph_nodes`, and
|
||||
`graph_edges`. The extracted nodes (APT29, HAMMERTOSS, LifeCare, AS59796, …) and
|
||||
edges (`deployed`, `observed_on`, `classified_as`, …) now span all four documents.
|
||||
|
||||
The graph now contains a connected subgraph linking APT29 to healthcare infrastructure across four document boundaries, something that would be invisible to a pure vector search.
|
||||
|
||||
## Retrieving the relevant subgraph
|
||||
|
||||
With the graph populated, a plain `retrieve()` call already does more than vector search. When `use_graph=True`, the retriever seeds the graph traversal from the top-k vector matches and expands outward by following edges, collecting connected facts within `max_hops`:
|
||||
With the graph populated, a plain `retrieve()` call already does more than vector search. When `use_graph=True`, the retriever seeds the graph traversal from the top-k vector matches and expands outward by following edges. Expansion depth is set once, by `max_expansion_hops` on the `AgentContext` constructor:
|
||||
|
||||
```python
|
||||
results = context.retrieve(
|
||||
@@ -149,7 +150,6 @@ results = context.retrieve(
|
||||
use_graph=True,
|
||||
max_results=10,
|
||||
expand_graph=True,
|
||||
max_hops=3,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
@@ -175,11 +175,19 @@ apt29_intel = context.retrieve(
|
||||
use_graph=True,
|
||||
anchor_node="APT29",
|
||||
proximity_weight=0.7, # strongly favour nodes close to APT29
|
||||
max_hops=3,
|
||||
max_hops=3, # with an anchor, this bounds the proximity radius
|
||||
max_results=8,
|
||||
)
|
||||
```
|
||||
|
||||
<Note>
|
||||
`max_hops` on `retrieve()` only takes effect when `anchor_node` is set: it
|
||||
bounds the proximity radius used for scoring and drops results farther than
|
||||
`max_hops` from the anchor. Without an `anchor_node` it is ignored. It does
|
||||
**not** change how far graph expansion reaches: that is fixed by
|
||||
`max_expansion_hops` on the constructor.
|
||||
</Note>
|
||||
|
||||
## Getting a grounded LLM answer with a reasoning path
|
||||
|
||||
`retrieve()` gives you the grounded context. `query_with_reasoning()` goes one step further: it passes that subgraph context to an LLM and returns the answer together with the multi-hop path the retrieval system traced through the graph. That path is your audit trail.
|
||||
@@ -187,7 +195,7 @@ apt29_intel = context.retrieve(
|
||||
```python
|
||||
from semantica.llms import LiteLLM
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
|
||||
result = context.query_with_reasoning(
|
||||
"What are APT29's known TTPs against healthcare infrastructure, "
|
||||
@@ -273,7 +281,7 @@ context.store(
|
||||
link_entities=True,
|
||||
)
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
result = context.query_with_reasoning(
|
||||
"Trace the C2 infrastructure chain for APT29 operations targeting "
|
||||
"ITAR-controlled contractors in 2025. Include IP ranges, ASNs, and TTPs.",
|
||||
@@ -343,7 +351,7 @@ Parent: wmiprvse.exe
|
||||
Sigma match: T1053.005 Scheduled Task/Job
|
||||
"""
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
triage = soc_context.query_with_reasoning(
|
||||
"Triage this SIEM alert and identify the correct response runbook:\n{}".format(alert_text),
|
||||
llm_provider=llm,
|
||||
@@ -417,7 +425,7 @@ Patient: 68F, AF, CKD stage 3b (eGFR 32). On warfarin (INR target 2.0–3.0).
|
||||
Presenting for elective hip replacement. Concurrent: amiodarone 200mg, atorvastatin 40mg.
|
||||
"""
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
answer = clinical_context.query_with_reasoning(
|
||||
"What is the evidence-based warfarin bridging protocol for this patient "
|
||||
"given CKD and amiodarone interaction risk?\n\n{}".format(patient_context),
|
||||
@@ -466,12 +474,17 @@ compliance_context = AgentContext(
|
||||
retention_days=2555, # 7-year regulatory retention
|
||||
)
|
||||
|
||||
# In production these come from ingest_file(); shown as strings here for brevity
|
||||
basel_cre20_text = "CRE20.32: For income-producing real estate where repayment depends on "
|
||||
"property cash flows, RWA = exposure × risk weight, where risk weight "
|
||||
"is determined by LTV bucket per Table CRE20.3..."
|
||||
bcbs239_text = "Principle 3: Risk data should be accurate and have a single authoritative source. "
|
||||
"Where data is aggregated across systems, reconciliation must be documented..."
|
||||
# In production the text comes from a parsed file, e.g. FileIngestor().ingest_file(path).text;
|
||||
# inline strings here for brevity
|
||||
basel_cre20_text = (
|
||||
"CRE20.32: For income-producing real estate where repayment depends on "
|
||||
"property cash flows, RWA = exposure × risk weight, where risk weight "
|
||||
"is determined by LTV bucket per Table CRE20.3..."
|
||||
)
|
||||
bcbs239_text = (
|
||||
"Principle 3: Risk data should be accurate and have a single authoritative source. "
|
||||
"Where data is aggregated across systems, reconciliation must be documented..."
|
||||
)
|
||||
|
||||
compliance_context.store(
|
||||
[
|
||||
@@ -482,7 +495,7 @@ compliance_context.store(
|
||||
extract_relationships=True,
|
||||
)
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
answer = compliance_context.query_with_reasoning(
|
||||
"Under Basel III CRE20, what are the RWA calculation requirements for "
|
||||
"commercial real estate exposures with LTV > 80%? "
|
||||
@@ -535,7 +548,7 @@ results = context.retrieve(
|
||||
)
|
||||
```
|
||||
|
||||
Each additional hop in `max_hops` exponentially increases the subgraph size. Practical defaults by domain:
|
||||
Each additional expansion hop exponentially increases the subgraph size. Practical defaults by domain:
|
||||
|
||||
```text
|
||||
General Q&A max_expansion_hops=2 (95% of useful facts within 2 hops)
|
||||
@@ -544,7 +557,7 @@ Drug interactions max_expansion_hops=3 (drug → enzyme → metabolite
|
||||
Regulatory cross-ref max_expansion_hops=2 (rule → article → article)
|
||||
```
|
||||
|
||||
Set globally in the constructor; override per call with the `max_hops` argument to `retrieve()`.
|
||||
Expansion depth is a constructor setting only (`max_expansion_hops`); there is no per-call override on `retrieve()`. `query_with_reasoning()` does take a per-call `max_hops` argument.
|
||||
|
||||
## How GraphRAG works internally
|
||||
|
||||
|
||||
@@ -275,20 +275,20 @@ print(data)
|
||||
|
||||
**LiteLLM** is a universal adapter that provides a single interface to over 100 different LLM providers, including Anthropic Claude, Azure OpenAI, AWS Bedrock, Google Vertex AI, and local Ollama instances. It acts as a translation layer, converting your unified API calls into provider-specific requests, enabling easy switching between providers without code changes.
|
||||
|
||||
`LiteLLM` is the Swiss Army knife. It wraps the `litellm` library, which speaks to every major provider using a unified completion API. The model string encodes both provider and model name: `"anthropic/claude-sonnet-4-20250514"`, `"azure/gpt-4o"`, `"bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0"`, `"ollama/llama3.2"`. Change the string, change the provider — no other code changes needed.
|
||||
`LiteLLM` is the Swiss Army knife. It wraps the `litellm` library, which speaks to every major provider using a unified completion API. The model string encodes both provider and model name: `"anthropic/claude-sonnet-5"`, `"azure/gpt-4o"`, `"bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0"`, `"ollama/llama3.2"`. Change the string, change the provider — no other code changes needed.
|
||||
|
||||
```python
|
||||
from semantica.llms import LiteLLM
|
||||
|
||||
# Anthropic Claude — highest accuracy for complex reasoning
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
# Reads ANTHROPIC_API_KEY from environment
|
||||
|
||||
# Azure OpenAI — compliance and data-residency requirements
|
||||
llm = LiteLLM(model="azure/gpt-4o", api_key="YOUR_AZURE_KEY")
|
||||
|
||||
# AWS Bedrock — existing cloud agreement, no new vendor
|
||||
llm = LiteLLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
llm = LiteLLM(model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
|
||||
# Google Vertex AI
|
||||
llm = LiteLLM(model="vertex_ai/gemini-1.5-pro")
|
||||
@@ -306,7 +306,7 @@ The environment-variable convention for each provider: `ANTHROPIC_API_KEY`, `AZU
|
||||
import os
|
||||
|
||||
PROVIDER_MAP = {
|
||||
"prod": "anthropic/claude-sonnet-4-20250514",
|
||||
"prod": "anthropic/claude-sonnet-5",
|
||||
"staging": "openai/gpt-4o-mini",
|
||||
"local": "ollama/llama3.2",
|
||||
"azure": "azure/gpt-4o",
|
||||
@@ -378,7 +378,7 @@ print("FAST: {} (conf={:.0%})".format(fast_result["response"], fast_result["con
|
||||
|
||||
# Tier 2: deep answer with Claude if confidence is below threshold
|
||||
if fast_result["confidence"] < 0.85:
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
deep_result = context.query_with_reasoning(
|
||||
query, llm_provider=deep_llm, max_results=15, max_hops=3
|
||||
)
|
||||
@@ -574,7 +574,7 @@ print("TRIAGE: {} (conf={:.0%})".format(triage["response"], triage["confidence"]
|
||||
|
||||
# Tier 2: escalate to Claude for deep analysis if Tier 1 is uncertain
|
||||
if triage["confidence"] < 0.88:
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
deep = context.query_with_reasoning(
|
||||
"Full MITRE ATT&CK analysis of this alert: identify the attack chain, "
|
||||
"blast radius, affected systems, and recommended containment steps.",
|
||||
@@ -630,7 +630,7 @@ for d in drugs:
|
||||
# trastuzumab (conf=0.98), pertuzumab (conf=0.97), docetaxel (conf=0.96)
|
||||
|
||||
# Report synthesis with Claude — switch to azure/gpt-4o for HIPAA by changing one string
|
||||
report_llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
report_llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
# For HIPAA-constrained Azure deployment:
|
||||
# report_llm = LiteLLM(model="azure/gpt-4o", api_key="YOUR_AZURE_KEY")
|
||||
|
||||
@@ -682,7 +682,7 @@ question = (
|
||||
|
||||
# Two-provider consensus — same query, same graph, different LLMs
|
||||
gpt4o = OpenAI(model="gpt-4o", api_key="YOUR_OAI_KEY")
|
||||
claude = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
claude = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
|
||||
answer_a = context.query_with_reasoning(question, llm_provider=gpt4o, max_results=10)
|
||||
answer_b = context.query_with_reasoning(question, llm_provider=claude, max_results=10)
|
||||
|
||||
@@ -197,7 +197,7 @@ reasoning_agent.load("./pipeline/enriched_intel/")
|
||||
# All memories, graph nodes, and vector embeddings from both ingestion agents are now available.
|
||||
|
||||
# Use a high-capability model for the synthesis step
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
|
||||
synthesis = reasoning_agent.query_with_reasoning(
|
||||
"Summarize the APT29 exploitation of CVE-2024-3400: affected products, "
|
||||
@@ -428,7 +428,7 @@ tier1.store(
|
||||
|
||||
# --- Tier 2: deep investigation when Tier 1 confidence is low ---
|
||||
if triage["confidence"] < 0.90:
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
|
||||
investigation = tier2.query_with_reasoning(
|
||||
"Full MITRE ATT&CK analysis of incident {}. "
|
||||
@@ -533,7 +533,7 @@ t1.start(); t2.start()
|
||||
t1.join(); t2.join()
|
||||
|
||||
# Chief agent synthesizes across literature and experimental data
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
|
||||
synthesis = chief.query_with_reasoning(
|
||||
"Identify the top two candidate compounds for KRAS G12C NSCLC that show "
|
||||
@@ -576,7 +576,7 @@ credit_officer = make_desk_agent()
|
||||
committee_chair = make_desk_agent()
|
||||
|
||||
app_id = "LOAN-2025-88421"
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
|
||||
# --- Risk Desk: PD/LGD/EL analysis ---
|
||||
risk_desk.store(
|
||||
|
||||
@@ -477,7 +477,7 @@ regs = [
|
||||
]
|
||||
|
||||
# Use an LLM to extract the conceptual model from regulatory prose
|
||||
llm_gen = LLMOntologyGenerator(provider="anthropic", model="claude-sonnet-4-20250514")
|
||||
llm_gen = LLMOntologyGenerator(provider="anthropic", model="claude-sonnet-5")
|
||||
ontology = llm_gen.generate_ontology_from_text(
|
||||
"\n\n".join(r.text[:8000] for r in regs) # token-safe excerpt per document
|
||||
)
|
||||
|
||||
@@ -127,7 +127,7 @@ engine = ExecutionEngine(max_workers=4, retry_on_failure=True)
|
||||
result = engine.execute_pipeline(pipeline)
|
||||
|
||||
print(f"Success: {result.success}")
|
||||
print(f"Output: {result.output}") # {"node_count": 312, "edge_count": 847}
|
||||
print(f"Output: {result.output}") # the final step's return value, e.g. {"node_count": ..., "edge_count": ...}
|
||||
print(f"Duration: {result.metrics['execution_time']:.2f}s")
|
||||
print(f"Steps completed: {result.metrics['steps_executed']}")
|
||||
```
|
||||
@@ -197,7 +197,9 @@ engine = ExecutionEngine(
|
||||
max_workers = 4,
|
||||
retry_on_failure = True,
|
||||
)
|
||||
# The engine uses handler.get_retry_policy(step.step_type) when a step fails
|
||||
# ExecutionEngine builds its own FailureHandler; replace it with the configured one
|
||||
engine.failure_handler = handler
|
||||
# The engine now calls engine.failure_handler.get_retry_policy(step.step_type) on failure
|
||||
```
|
||||
|
||||
`handler.classify_error()` distinguishes `ValidationError` (low severity, usually don't retry), `ProcessingError` (high severity), and timeout/connection errors (medium severity, always retry). You can inspect the classification:
|
||||
|
||||
@@ -100,14 +100,15 @@ ner = NamedEntityRecognizer(
|
||||
methods=["llm", "ml", "pattern"],
|
||||
confidence_threshold=0.75,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
)
|
||||
entities = ner.extract_entities(report)
|
||||
|
||||
for e in entities:
|
||||
print("[{:>5.2f}] {:15s} {}".format(e.confidence, e.label, e.text))
|
||||
|
||||
# Expected output (abbreviated):
|
||||
# Illustrative output — exact labels and scores depend on the method and model.
|
||||
# Abbreviated:
|
||||
# [ 0.94] THREAT_ACTOR GAMMA-7
|
||||
# [ 0.91] THREAT_ACTOR DELTA-3
|
||||
# [ 0.97] MALWARE HAMMERTOSS
|
||||
@@ -262,16 +263,18 @@ from semantica.semantic_extract import TripletExtractor
|
||||
tri = TripletExtractor(
|
||||
method="llm",
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
include_temporal=True, # attach time context to triplets when available
|
||||
include_provenance=True, # embed source document reference in each triplet
|
||||
validate=False, # return raw triplets; validate explicitly below
|
||||
)
|
||||
|
||||
# Feed in the entities and relations you already extracted — the extractor
|
||||
# uses them to constrain and validate what it produces
|
||||
# uses them to constrain what it produces
|
||||
triplets = tri.extract_triplets(report, entities, relations)
|
||||
|
||||
# Filter malformed triplets before serialisation
|
||||
# (extract_triplets validates automatically unless validate=False, as above)
|
||||
valid = tri.validate_triplets(triplets)
|
||||
print("Valid: {}/{}".format(len(valid), len(triplets)))
|
||||
|
||||
@@ -320,7 +323,7 @@ def ingest_intel_report(
|
||||
methods=[method, "pattern"],
|
||||
confidence_threshold=0.70,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
)
|
||||
entities = ner.extract_entities(text)
|
||||
classified = ner.classify_entities(entities)
|
||||
@@ -335,7 +338,7 @@ def ingest_intel_report(
|
||||
relation_types=["deployed", "targets", "exploits", "operates_from", "provided_to"],
|
||||
confidence_threshold=0.65,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
)
|
||||
relations = rel.extract_relations(text, entities)
|
||||
|
||||
@@ -347,9 +350,10 @@ def ingest_intel_report(
|
||||
tri = TripletExtractor(
|
||||
method=method,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
include_temporal=True,
|
||||
include_provenance=True,
|
||||
validate=False, # keep raw triplets so the summary can report rejections
|
||||
)
|
||||
triplets = tri.extract_triplets(text, entities, relations)
|
||||
valid = tri.validate_triplets(triplets)
|
||||
@@ -377,6 +381,7 @@ def ingest_intel_report(
|
||||
"coref_chains": len(chains),
|
||||
"relations": len(relations),
|
||||
"events": len(events),
|
||||
"triplets_total": len(triplets),
|
||||
"triplets_valid": len(valid),
|
||||
"graph_nodes": graph_stats.get("graph_nodes", 0),
|
||||
"graph_edges": graph_stats.get("graph_edges", 0),
|
||||
@@ -402,7 +407,7 @@ for text, doc_id in reports:
|
||||
summary["relations"],
|
||||
summary["events"],
|
||||
summary["triplets_valid"],
|
||||
len(summary["rdf_turtle"]),
|
||||
summary["triplets_total"],
|
||||
))
|
||||
```
|
||||
|
||||
@@ -421,7 +426,7 @@ ner = NamedEntityRecognizer(
|
||||
methods=["llm", "pattern"],
|
||||
confidence_threshold=0.75,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
)
|
||||
entities = ner.extract_entities(fintel_text)
|
||||
grouped = ner.classify_entities(entities)
|
||||
@@ -438,14 +443,14 @@ rel = RelationExtractor(
|
||||
relation_types=["operates_from", "deployed", "targets", "exploits"],
|
||||
confidence_threshold=0.70,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
)
|
||||
relations = rel.extract_relations(fintel_text, entities)
|
||||
|
||||
tri = TripletExtractor(
|
||||
method="llm",
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
include_temporal=True,
|
||||
include_provenance=True,
|
||||
)
|
||||
@@ -544,14 +549,14 @@ rel = RelationExtractor(
|
||||
relation_types=["treats", "causes_adverse_event", "has_efficacy", "evaluated_in"],
|
||||
confidence_threshold=0.65,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
)
|
||||
relations = rel.extract_relations(paper, entities)
|
||||
|
||||
tri = TripletExtractor(
|
||||
method="llm",
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
triplet_types=["treats", "has_efficacy", "causes_adverse_event"],
|
||||
include_temporal=True,
|
||||
include_provenance=True,
|
||||
@@ -595,7 +600,7 @@ ner = NamedEntityRecognizer(
|
||||
methods=["llm", "ml", "pattern"],
|
||||
confidence_threshold=0.70,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
)
|
||||
entities = ner.extract_entities(credit_memo)
|
||||
grouped = ner.classify_entities(entities)
|
||||
@@ -612,14 +617,14 @@ rel = RelationExtractor(
|
||||
relation_types=["guaranteed_by", "secured_by", "classified_as", "exposed_to"],
|
||||
confidence_threshold=0.65,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
)
|
||||
relations = rel.extract_relations(credit_memo, entities)
|
||||
|
||||
tri = TripletExtractor(
|
||||
method="llm",
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
llm_model="claude-sonnet-5",
|
||||
include_temporal=True,
|
||||
include_provenance=True,
|
||||
)
|
||||
|
||||
+24
-293
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Semantica"
|
||||
title: "Welcome to Semantica"
|
||||
description: "The Context and Semantic Layer for AI in High-Stakes Domains: Context Graphs · Decision Intelligence · Full Provenance"
|
||||
---
|
||||
|
||||
@@ -7,93 +7,25 @@ description: "The Context and Semantic Layer for AI in High-Stakes Domains: Cont
|
||||
pip install semantica
|
||||
```
|
||||
|
||||
Most AI agents store embeddings, not meaning. They can't say why a fact was recalled, where it came from, or what led to a decision. In healthcare, finance, legal, and government, that lack of a traceable record blocks production deployment.
|
||||
Most AI agents run on embeddings, not meaning. A similarity score has no structure, no relationships, and no way to explain why a result came back.
|
||||
|
||||
Semantica is the context and semantic layer for AI in high-stakes domains, sitting beneath your existing agent framework. It doesn't replace LangChain or LlamaIndex; it makes their outputs traceable.
|
||||
Semantica is the semantic and context layer underneath your LLM, vector store, and agent framework: deterministic infrastructure, not a model. Graph construction, reasoning, and provenance all run without an LLM in the loop. It turns fragmented enterprise data into a structured, queryable context graph and knowledge graph, governed by ontologies, taxonomies, and controlled vocabularies (OWL, SHACL, SKOS), so your data's meaning is explicit rather than approximated by an embedding.
|
||||
|
||||
Provenance and audit trails aren't a bolt-on. They fall out naturally once your data has that structure, so the same graph that powers retrieval and reasoning also gives you a straight answer when a regulator asks why.
|
||||
|
||||
## What Most AI Stacks Are Missing
|
||||
## What you get
|
||||
|
||||
**No memory structure.** Agents store embeddings, not meaning.
|
||||
- No way to ask *why* a fact was recalled
|
||||
- No link from a recalled fact back to its source document
|
||||
- Context is a black box that resets on every run
|
||||
|
||||
**No decision trail.** Agents act continuously but record nothing.
|
||||
- No history to hand to a regulator or auditor
|
||||
- No way to replay or reproduce a past decision
|
||||
- Debugging means re-running, not reviewing
|
||||
|
||||
**No provenance.** Outputs can't be traced to source facts.
|
||||
- A hard compliance blocker in healthcare, finance, and legal
|
||||
- No lineage from inference back to the original document
|
||||
- No way to demonstrate what the agent actually relied on
|
||||
|
||||
**No reasoning transparency.** Black-box answers with no explanation.
|
||||
- No way to validate the reasoning path
|
||||
- No way to contest a specific conclusion
|
||||
- No basis for improving or correcting future behavior
|
||||
|
||||
**No conflict detection.** Contradictory facts silently coexist in vector stores.
|
||||
- No detection when two sources disagree
|
||||
- Outputs become inconsistent and unpredictable over time
|
||||
- Silent failures compound as the knowledge base grows
|
||||
|
||||
|
||||
## What Semantica Adds to Your Stack
|
||||
|
||||
Semantica gives every agent the infrastructure it needs to be accountable, and it drops into an existing setup in minutes.
|
||||
|
||||
**Context Graphs.** A structured, queryable graph of everything your agent knows, decides, and reasons about.
|
||||
- Persistent across agent runs, with no context loss between sessions
|
||||
- Queryable with SPARQL and full graph algorithms
|
||||
- Temporal model with `valid_from` / `valid_until` on nodes and edges
|
||||
- Point-in-time snapshots of the full knowledge state
|
||||
|
||||
**Decision Intelligence.** Every decision is a first-class object in your system.
|
||||
- `record_decision()` captures full lifecycle and causal chain
|
||||
- Hybrid precedent search over past decisions for consistency
|
||||
- `analyze_decision_impact()` shows downstream consequences
|
||||
- Causal chain visualization from trigger to outcome
|
||||
|
||||
**Full Provenance.** Every fact links to its source document and ingestion event.
|
||||
- W3C PROV-O compliant lineage across all modules
|
||||
- Full traceability from raw input to final inference
|
||||
- `recorded_at` stamping with OWL-Time export
|
||||
- Audit-ready for HIPAA, SOX, GDPR, FDA 21 CFR Part 11
|
||||
|
||||
**Reasoning Engines.** Explainable reasoning paths, not black boxes.
|
||||
- Forward chaining, Rete, deductive, abductive
|
||||
- SPARQL query-based inference over RDF graphs
|
||||
- Datalog with recursive Horn clause rules
|
||||
- Every conclusion backed by a traceable derivation path
|
||||
|
||||
**Temporal Intelligence.** Your graph knows not just *what*, but *when*.
|
||||
- Allen interval algebra covering all 13 temporal relations
|
||||
- Point-in-time queries over historical graph states
|
||||
- Temporal provenance stamping on every fact
|
||||
- OWL-Time export for standards-compliant archiving
|
||||
|
||||
**Ontology Hub.** Full ontology lifecycle in the browser.
|
||||
- Visual editor for schema design and editing
|
||||
- SHACL Studio for constraint authoring and validation
|
||||
- Alignment authoring across multiple ontologies
|
||||
- Health dashboard and version control built in
|
||||
- **[Context graphs](/guides/context-graphs)**: a persistent, queryable graph of everything your agent knows, decides, and reasons about
|
||||
- **Decision intelligence**: `record_decision()` captures the full lifecycle and causal chain of every decision
|
||||
- **[Full provenance](/guides/provenance)**: every fact links back to its source, W3C PROV-O compliant and audit-ready for HIPAA, SOX, and GDPR
|
||||
- **[Explainable reasoning](/guides/reasoning)**: forward chaining, Datalog, and SPARQL, each with a derivation path you can inspect
|
||||
- **Temporal intelligence**: Allen interval algebra and point-in-time snapshots, so the graph knows not just *what* but *when*
|
||||
|
||||
<Tip>
|
||||
Works alongside any LLM provider and any agent framework. Add it to an existing stack without changing your architecture.
|
||||
Works alongside any LLM provider and any agent framework, and ingests directly from enterprise data platforms like Databricks, SAP, Salesforce, and Snowflake. Add it to an existing stack without changing your architecture.
|
||||
</Tip>
|
||||
|
||||
<img src="/assets/img/diagrams/architecture-overview.svg" alt="Semantica four-layer architecture: Ingestion → Processing → Intelligence → Application" style={{ width: '100%', borderRadius: '12px', margin: '24px 0' }} />
|
||||
|
||||
|
||||
## See It In Action
|
||||
|
||||
One pip install. A few lines to connect your agent. Everything else becomes traceable.
|
||||
|
||||
```bash
|
||||
pip install semantica
|
||||
```
|
||||
## Try it
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
@@ -175,229 +107,28 @@ decision_id = context.record_decision(
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
- [Full Quickstart](/quickstart): step-by-step pipeline walkthrough
|
||||
- [Cookbook](/cookbook): 40+ real-world Jupyter notebooks
|
||||
- [Join Discord](https://discord.gg/sV34vps5hH): community chat and support
|
||||
|
||||
|
||||
## Industry Use Cases
|
||||
|
||||
Semantica is used in domains where every decision must be explainable and every fact must be traceable.
|
||||
|
||||
<Warning>
|
||||
**This is system-level explainability, not foundation-model explainability.** Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model. Its internal reasoning or chain-of-thought stays opaque, as it does for any external system. What Semantica explains is *outside* the model: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. See [Core Concepts](/concepts) for the full scope note.
|
||||
</Warning>
|
||||
|
||||
**Healthcare & Life Sciences**
|
||||
- Clinical decision support with full audit trails
|
||||
- Drug interaction and contraindication graphs
|
||||
- Patient safety event tracking and root-cause analysis
|
||||
- HIPAA-compliant provenance chains out of the box
|
||||
|
||||
**Finance & Risk**
|
||||
- Fraud detection knowledge graphs
|
||||
- Risk assessment trails built to survive an audit
|
||||
- SOX, GDPR, and MiFID II compliance infrastructure
|
||||
- Model decision lineage for regulatory reporting
|
||||
|
||||
**Legal & Compliance**
|
||||
- Evidence-backed research with every cited fact provenance-linked
|
||||
- Contract analysis with traceable clause extraction
|
||||
- Regulatory change tracking across jurisdictions
|
||||
- Full reasoning paths ready for court-admissible documentation
|
||||
|
||||
**Cybersecurity**
|
||||
- Threat attribution graphs linking actors, TTPs, and indicators
|
||||
- Incident response timelines with full event provenance
|
||||
- Security audit trails across the complete kill chain
|
||||
- MITRE ATT&CK-aligned knowledge graph integration
|
||||
|
||||
**Government & Defense**
|
||||
- Policy decision trails from brief to outcome
|
||||
- Classified information handling with provenance chains
|
||||
- Chain-of-custody scrutiny for intelligence reporting
|
||||
- Air-gapped deployment with local LLM support
|
||||
|
||||
**Critical Infrastructure**
|
||||
- Power grid state tracking with temporal intelligence
|
||||
- Transportation safety event graphs
|
||||
- Emergency response coordination with decision audit trails
|
||||
- Consequence modeling for high-stakes operational decisions
|
||||
|
||||
|
||||
## Start Here
|
||||
## Start here
|
||||
|
||||
<Steps>
|
||||
<Step title="Install Semantica">
|
||||
<Step title="Install">
|
||||
```bash
|
||||
pip install semantica
|
||||
```
|
||||
See [Installation](/installation) for optional extras (`[all]`, `[neo4j]`, `[pinecone]`) and environment setup.
|
||||
Optional extras: `[all]`, `[neo4j]`, `[pinecone]`. See [Installation](/installation).
|
||||
</Step>
|
||||
<Step title="Run the Quickstart">
|
||||
Build a complete knowledge graph pipeline in [5 minutes](/quickstart):
|
||||
- Ingest documents from any source
|
||||
- Extract entities and relationships
|
||||
- Build and query the graph
|
||||
- Record and trace a decision
|
||||
<Step title="Build a pipeline">
|
||||
Follow the [Quickstart](/quickstart) to ingest documents, extract entities, build a graph, and record a decision in 5 minutes.
|
||||
</Step>
|
||||
<Step title="Learn the mental model">
|
||||
[Core Concepts](/concepts) covers:
|
||||
- Knowledge graphs vs. vector stores: when to use each
|
||||
- What GraphRAG is and how Semantica implements it
|
||||
- How provenance and decision tracking work together
|
||||
- The context and semantic layer architecture
|
||||
<Step title="Learn the model">
|
||||
[Core Concepts](/concepts) covers knowledge graphs vs. vector stores, GraphRAG, and how provenance and decisions fit together.
|
||||
</Step>
|
||||
<Step title="Go deep on any module">
|
||||
Every module has a dedicated [reference page](/reference/context) with:
|
||||
- Full class and method documentation
|
||||
- Parameter tables with types and defaults
|
||||
- Runnable code examples for each feature
|
||||
<Step title="Go deep">
|
||||
Every module has a [reference page](/reference/context) with full API docs and runnable examples.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
- [Installation](/installation): get Semantica installed in under a minute
|
||||
- [Quickstart](/quickstart): build a complete knowledge graph pipeline in 5 minutes
|
||||
- [Core Concepts](/concepts): the mental model behind the API
|
||||
- [API Reference](/reference/context): exact module, class, and method details
|
||||
- [Cookbook](/cookbook): domain notebooks for real-world use cases
|
||||
- [Changelog](https://github.com/semantica-agi/semantica/releases): release history
|
||||
|
||||
|
||||
## Full Capabilities
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="Context & Decision Intelligence" icon="brain">
|
||||
|
||||
### Context Graphs
|
||||
|
||||
- Structured, persistent graph of entities, relationships, and decisions
|
||||
- Temporal model with `valid_from` / `valid_until` on every node and edge
|
||||
- Point-in-time queries across historical graph states
|
||||
- Distance Intelligence: semantic neighborhoods and N×N distance matrices
|
||||
|
||||
### Decision Tracking
|
||||
|
||||
- `record_decision()` with full lifecycle management and causal chains
|
||||
- Hybrid similarity search over past decisions for consistency enforcement
|
||||
- `analyze_decision_impact()` and `analyze_decision_influence()` for consequence modeling
|
||||
- Ego-mode exploration for targeted neighborhood investigation
|
||||
More: the [Cookbook](/cookbook) for real-world notebooks, [Discord](https://discord.gg/sV34vps5hH) for help.
|
||||
|
||||
<Accordion title="Full module list">
|
||||
`semantica.ingest`, `semantica.parse`, `semantica.split`, `semantica.normalize`, `semantica.semantic_extract`, `semantica.kg`, `semantica.ontology`, `semantica.reasoning`, `semantica.embeddings`, `semantica.vector_store`, `semantica.graph_store`, `semantica.triplet_store`, `semantica.context`, `semantica.provenance`, `semantica.change_management`, `semantica.deduplication`, `semantica.conflicts`, `semantica.export`, `semantica.visualization`, `semantica.pipeline`, `semantica.seed`, `semantica.llms`, `semantica.mcp_server`, `semantica.explorer`, `semantica.evals`, `semantica.utils`, `semantica.core`. See the [API Reference](/reference/context) for full docs on each.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Knowledge Engineering" icon="diagram-project">
|
||||
|
||||
### Entity & Relation Extraction
|
||||
|
||||
- Named entity recognition: pattern, ML, or LLM methods
|
||||
- Typed triplet extraction via LLM or rule-based pipelines
|
||||
- Event extraction with temporal and causal linking
|
||||
|
||||
### Ontology & Schema
|
||||
|
||||
- Ontology Hub: visual editor, SHACL Studio, alignments, health dashboard
|
||||
- Deduplication v2: `blocking_v2`, `hybrid_v2`, `semantic_v2`: up to 7x faster
|
||||
- Datalog reasoning: recursive Horn clause rules with fixpoint semantics
|
||||
- SPARQL reasoning: query-based inference over RDF graphs
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Provenance & Auditability" icon="shield-check">
|
||||
|
||||
### Lineage Tracking
|
||||
|
||||
- W3C PROV-O lineage across all modules: every fact has a source
|
||||
- `recorded_at` stamping with full OWL-Time export
|
||||
- Change management with SHA-256 checksums and version control
|
||||
- Full audit trails from ingestion event to final inference
|
||||
|
||||
### Compliance Infrastructure
|
||||
|
||||
- HIPAA: patient data handling with audit-ready provenance chains
|
||||
- SOX / MiFID II: financial decision records with full traceability
|
||||
- GDPR: data lineage for subject access and right-to-erasure workflows
|
||||
- FDA 21 CFR Part 11: electronic records and signature compliance
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Data Ingestion & Export" icon="database">
|
||||
|
||||
### Ingestion Formats
|
||||
|
||||
- Documents: PDF, DOCX, HTML, PPTX, Docling layout analysis
|
||||
- Structured data: JSON, CSV, Excel, Parquet, XML
|
||||
- Sources: web crawl, SQL, Snowflake, feeds, email, code repositories, MCP
|
||||
|
||||
### Vector Stores
|
||||
|
||||
- FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector, in-memory
|
||||
|
||||
### Graph Stores
|
||||
|
||||
- Neo4j, FalkorDB, Apache AGE, Amazon Neptune
|
||||
|
||||
### Export Formats
|
||||
|
||||
- RDF: Turtle, JSON-LD, N-Triples, RDF/XML
|
||||
- Tabular: Parquet, CSV, Arrow
|
||||
- Graph: GraphML, GEXF, DOT, ArangoDB AQL
|
||||
- Ontology: OWL, SKOS, SHACL
|
||||
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
## Module Reference
|
||||
|
||||
| Module | What it provides |
|
||||
| :-------- | :----------------- |
|
||||
| `semantica.context` | Context graphs, agent memory, decision tracking, causal analysis, precedent search |
|
||||
| `semantica.kg` | KG construction, graph algorithms, temporal model, Allen interval algebra |
|
||||
| `semantica.semantic_extract` | NER, relation extraction, event extraction, triplet generation |
|
||||
| `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.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 |
|
||||
| `semantica.ingest` | Files, web, feeds, databases, Snowflake, Parquet, XML, MCP |
|
||||
| `semantica.parse` | Document parsing: PDF, DOCX, HTML, PPTX, Docling layout analysis |
|
||||
| `semantica.split` | Text chunking: sentence, paragraph, token, semantic boundary strategies |
|
||||
| `semantica.normalize` | Text normalization, entity canonicalization, whitespace and encoding cleanup |
|
||||
| `semantica.embeddings` | Sentence-Transformers, FastEmbed, OpenAI, BGE, Ollama local embeddings |
|
||||
| `semantica.pipeline` | Pipeline DSL, parallel workers, retry policies, failure handling |
|
||||
| `semantica.export` | RDF, Parquet, ArangoDB AQL, CSV, OWL, Arrow, GraphML, GEXF, DOT |
|
||||
| `semantica.visualization` | Programmatic graph rendering: force, hierarchical, circular, spring layouts |
|
||||
| `semantica.deduplication` | Entity deduplication v1/v2, similarity scoring, blocking, merging |
|
||||
| `semantica.conflicts` | Conflict detection and resolution across overlapping knowledge sources |
|
||||
| `semantica.provenance` | W3C PROV-O lineage tracking, source attribution, audit trails |
|
||||
| `semantica.change_management` | Version control with SHA-256 checksums, diff, rollback |
|
||||
| `semantica.llms` | Groq, OpenAI, Anthropic, Gemini, Ollama, DeepSeek, Novita AI, LiteLLM, HuggingFace |
|
||||
| `semantica.seed` | Foundation graph seeding from CSV, JSON, SQL, API, and RDF sources |
|
||||
| `semantica.evals` | Evaluation harness: KG quality, extraction F1, pipeline benchmarking, regression tracking |
|
||||
| `semantica.core` | Orchestration, ConfigManager, LifecycleManager, PluginRegistry, MethodRegistry |
|
||||
| `semantica.utils` | Logging, validation, progress tracking, hash utilities, nested dict helpers |
|
||||
|
||||
|
||||
## Why Semantica?
|
||||
|
||||
**Open Source, MIT.** No vendor lock-in, no paywalled features.
|
||||
- Full source available on GitHub
|
||||
- Every line auditable by your security team
|
||||
- Fork, extend, and self-host with no restrictions
|
||||
- No telemetry, no usage reporting
|
||||
|
||||
**Production Ready.** Built for teams that can't afford surprises.
|
||||
- 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, with 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
|
||||
- Use `ContextGraph` without vector storage
|
||||
- Every component independently swappable and testable
|
||||
- No framework lock-in, and works with any agent stack
|
||||
|
||||
@@ -12,13 +12,13 @@ icon: "link"
|
||||
pip install "semantica[langchain]"
|
||||
```
|
||||
|
||||
Requires `langchain-core >= 0.3`. If langchain-core is not installed, the integration still imports — every class carries the full Semantica API and degrades gracefully (`build()` returns `None`; branch on `LANGCHAIN_AVAILABLE`).
|
||||
Requires `langchain-core >= 0.3`. If langchain-core is not installed, the integration still imports. Every class carries the full Semantica API and degrades gracefully (`build()` returns `None`; branch on `LANGCHAIN_AVAILABLE`).
|
||||
|
||||
## Components at a Glance
|
||||
|
||||
- **SemanticaRetriever** — `BaseRetriever`: hybrid-search seeds retrieval, then graph edges are walked `hops` steps (default 2) for GraphRAG-style results.
|
||||
- **SemanticaVectorStore** — `VectorStore`: `add_texts` / `similarity_search` / `similarity_search_with_score` / `from_texts` over `HybridSearch`.
|
||||
- **SemanticaKGTool** / **SemanticaDecisionTool** — `BaseTool` subclasses: `semantica_query_graph` and `semantica_query_decisions` for LangGraph / tool-calling agents.
|
||||
- **SemanticaRetriever** (`BaseRetriever`): hybrid-search seeds retrieval, then graph edges are walked `hops` steps (default 2) for GraphRAG-style results.
|
||||
- **SemanticaVectorStore** (`VectorStore`): `add_texts` / `similarity_search` / `similarity_search_with_score` / `from_texts` over `HybridSearch`.
|
||||
- **SemanticaKGTool** / **SemanticaDecisionTool** (`BaseTool` subclasses): `semantica_query_graph` and `semantica_query_decisions` for LangGraph / tool-calling agents.
|
||||
|
||||
## Component Details
|
||||
|
||||
|
||||
+163
-122
@@ -28,7 +28,9 @@ Semantica is organized into **27 modules** across six logical layers. Each modul
|
||||
|
||||
### Ingest
|
||||
|
||||
Loads data from files, web, databases, and streams into a unified `SourceDocument` format.
|
||||
Loads data from files, web, databases, and streams. Each ingestor returns its own
|
||||
result type (`FileIngestor` → `FileObject`, `WebIngestor` → `WebContent`, …);
|
||||
document-oriented ones expose a `.text` payload and `.metadata`.
|
||||
|
||||
```python
|
||||
from semantica.ingest import FileIngestor, WebIngestor, ParquetIngestor, XMLIngestor, DatabricksIngestor
|
||||
@@ -37,7 +39,7 @@ from semantica.ingest import FileIngestor, WebIngestor, ParquetIngestor, XMLInge
|
||||
ingestor = FileIngestor()
|
||||
documents = ingestor.ingest_directory("data/")
|
||||
|
||||
# Web crawl
|
||||
# Web page: returns a WebContent with .text, .title, .links, .metadata
|
||||
web_ingestor = WebIngestor()
|
||||
page = web_ingestor.ingest_url("https://example.com")
|
||||
|
||||
@@ -67,13 +69,13 @@ Extracts structured text and layout metadata from raw documents.
|
||||
```python
|
||||
from semantica.parse import DocumentParser, DoclingParser
|
||||
|
||||
# Standard parser: all common formats
|
||||
# Standard parser: all common formats. parse() takes a path, returns a dict
|
||||
parser = DocumentParser()
|
||||
parsed = parser.parse_document("document.pdf")
|
||||
parsed = parser.parse("document.pdf") # {"full_text": ..., "metadata": ..., ...}
|
||||
|
||||
# Advanced parser: multi-column PDFs, merged-cell tables, OCR
|
||||
parser = DoclingParser(extract_tables=True, extract_images=True, output_format="markdown")
|
||||
parsed = parser.parse("data/annual_report.pdf")
|
||||
# Advanced parser (pip install semantica[parse-docling]): tables, OCR, layout
|
||||
parser = DoclingParser(export_format="markdown", enable_ocr=True)
|
||||
parsed = parser.parse("data/annual_report.pdf") # dict with full_text, tables, pages
|
||||
```
|
||||
|
||||
**Available parsers:** `DocumentParser`, `DoclingParser`, `CodeParser`, `CSVParser`, `DocxParser`, `EmailParser`, `ExcelParser`, `HTMLParser`, `ImageParser`, `JSONParser`, `MCPParser`, `MediaParser`, `PDFParser`, `PPTXParser`, `StructuredDataParser`, `WebParser`, `XMLParser`
|
||||
@@ -85,11 +87,12 @@ Chunks text for embedding and RAG pipelines with awareness of semantic boundarie
|
||||
```python
|
||||
from semantica.split import TextSplitter
|
||||
|
||||
splitter = TextSplitter(method="semantic_transformer")
|
||||
chunks = splitter.split(text, chunk_size=1000, chunk_overlap=200)
|
||||
# chunk_size / chunk_overlap are constructor arguments
|
||||
splitter = TextSplitter(method="semantic_transformer", chunk_size=1000, chunk_overlap=200)
|
||||
chunks = splitter.split(text)
|
||||
```
|
||||
|
||||
**Chunking strategies:** `recursive`, `semantic_transformer`, `entity_aware`, `relation_aware`, `sliding_window`, `structural`
|
||||
**Chunking methods:** `recursive`, `token`, `sentence`, `paragraph`, `semantic_transformer`, `entity_aware`, `relation_aware`, `graph_based`, `ontology_aware`, `hierarchical`, `community_detection`, `centrality_based`, `llm`
|
||||
|
||||
### Normalize
|
||||
|
||||
@@ -115,17 +118,18 @@ Named entity recognition, relation extraction, and triplet generation.
|
||||
```python
|
||||
from semantica.semantic_extract import NERExtractor, RelationExtractor, TripletExtractor
|
||||
|
||||
ner = NERExtractor(method="llm", llm_provider=llm)
|
||||
entities = ner.extract("Apple Inc. was founded by Steve Jobs.")
|
||||
# LLM method: provider + llm_model select the backend; the API key comes from the env
|
||||
ner = NERExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
|
||||
entities = ner.extract("Apple Inc. was founded by Steve Jobs.") # list[Entity]
|
||||
|
||||
rel = RelationExtractor(method="llm", llm_provider=llm)
|
||||
relationships = rel.extract(text, entities=entities)
|
||||
rel = RelationExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
|
||||
relationships = rel.extract(text, entities=entities) # list[Relation]
|
||||
|
||||
trip = TripletExtractor(method="llm", llm_provider=llm)
|
||||
triplets = trip.extract(text)
|
||||
trip = TripletExtractor(method="pattern")
|
||||
triplets = trip.extract(text) # list[Triplet]
|
||||
```
|
||||
|
||||
**Extraction methods:** `"pattern"` (no API key), `"ml"` (local model), `"llm"` (any of the 8 supported providers)
|
||||
**Extraction methods:** `"pattern"` (no API key), `"ml"` (local spaCy model), `"llm"` (any of the 9 supported providers)
|
||||
|
||||
**Additional extractors:** `CoreferenceResolver`, `EventDetector`, `SemanticAnalyzer`, `SemanticNetworkExtractor`
|
||||
|
||||
@@ -137,17 +141,17 @@ Graph construction, graph algorithms, temporal model, and distance intelligence.
|
||||
from semantica.kg import GraphBuilder, GraphAnalyzer, TemporalGraphQuery, SimilarityCalculator
|
||||
from datetime import datetime
|
||||
|
||||
# Build
|
||||
# Build: build() takes a {"entities": ..., "relationships": ...} dict
|
||||
builder = GraphBuilder(merge_entities=True)
|
||||
kg = builder.build(entities=entities, relationships=relationships)
|
||||
kg = builder.build({"entities": entities, "relationships": relationships})
|
||||
|
||||
# Temporal graphs (v0.4.0)
|
||||
query_engine = TemporalGraphQuery(enable_temporal_reasoning=True)
|
||||
snapshot = query_engine.query_at_time(kg, query="", at_time=datetime(2021, 6, 15))
|
||||
|
||||
# Semantic similarity (v0.5.0)
|
||||
calc = SimilarityCalculator()
|
||||
scores = calc.calculate_similarity(entity_a, entity_b)
|
||||
# Semantic similarity (v0.5.0): operates on embedding vectors
|
||||
calc = SimilarityCalculator(method="cosine")
|
||||
score = calc.cosine_similarity(vec_a, vec_b)
|
||||
```
|
||||
|
||||
**Graph algorithms available:** centrality calculation, community detection, connectivity analysis, entity resolution, link prediction, path finding, similarity calculation
|
||||
@@ -175,19 +179,23 @@ Derives new facts from existing knowledge using multiple inference strategies.
|
||||
```python
|
||||
from semantica.reasoning import Reasoner, DatalogReasoner
|
||||
|
||||
# Rule-based reasoning
|
||||
# Forward chaining: facts and rules as predicate(args) / IF-THEN strings
|
||||
engine = Reasoner()
|
||||
engine.apply_transitivity("located_in")
|
||||
engine.apply_symmetry("knows")
|
||||
result = engine.infer()
|
||||
engine.add_fact("Manager(Alice)")
|
||||
engine.add_rule("IF Manager(?x) THEN HasAuthority(?x)")
|
||||
results = engine.forward_chain() # list[InferenceResult] with .conclusion, .rule_used
|
||||
|
||||
# Datalog: recursive Horn clause rules (v0.4.0)
|
||||
datalog = DatalogEngine()
|
||||
datalog = DatalogReasoner()
|
||||
datalog.add_fact("parent(tom, bob)")
|
||||
datalog.add_fact("parent(bob, ann)")
|
||||
datalog.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
||||
datalog.add_rule("ancestor(X, Z) :- parent(X, Y), ancestor(Y, Z).")
|
||||
results = datalog.query("ancestor(alice, ?)")
|
||||
datalog.derive_all()
|
||||
results = datalog.query("ancestor(tom, ?Z)") # [{"Z": "bob"}, {"Z": "ann"}], order not guaranteed
|
||||
```
|
||||
|
||||
**Engines:** forward chaining, Rete network, deductive, abductive, SPARQL, Datalog: all produce explainable inference paths
|
||||
**Engines:** `Reasoner` (forward/backward chaining), `ReteEngine`, `SPARQLReasoner`, `DatalogReasoner`, `TemporalReasoningEngine`, `GraphReasoner` (LLM)
|
||||
|
||||
|
||||
## Storage
|
||||
@@ -199,9 +207,9 @@ Generates and manages vector embeddings for semantic similarity.
|
||||
```python
|
||||
from semantica.embeddings import EmbeddingGenerator
|
||||
|
||||
generator = EmbeddingGenerator(model="sentence-transformers")
|
||||
embeddings = generator.generate(["text1", "text2"])
|
||||
similarity = generator.similarity(embeddings[0], embeddings[1])
|
||||
generator = EmbeddingGenerator()
|
||||
embeddings = generator.generate_embeddings(["text1", "text2"]) # np.ndarray
|
||||
similarity = generator.compare_embeddings(embeddings[0], embeddings[1])
|
||||
```
|
||||
|
||||
**Supported models:** Sentence-Transformers, FastEmbed, OpenAI, BGE
|
||||
@@ -215,12 +223,18 @@ Multi-backend vector database with hybrid search support.
|
||||
```python
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
store = VectorStore(backend="faiss", dimension=768)
|
||||
store.add_vectors(embeddings, ids)
|
||||
results = store.search(query_vector, top_k=10)
|
||||
store = VectorStore(backend="faiss", dimension=768)
|
||||
|
||||
# Raw vectors
|
||||
ids = store.store_vectors(embeddings) # returns generated ids
|
||||
hits = store.search_vectors(query_vector, k=10)
|
||||
|
||||
# Or store text and let the store embed it
|
||||
store.add_documents(["Apple was founded in 1976.", "Google was founded in 1998."])
|
||||
results = store.search("tech company founding dates", limit=10)
|
||||
```
|
||||
|
||||
**Backends:** FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector, in-memory
|
||||
**Backends:** FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector, SQLite, in-memory
|
||||
|
||||
**Search modes:** semantic top-k, hybrid (vector + keyword), metadata-filtered
|
||||
|
||||
@@ -232,8 +246,8 @@ Connects to graph databases for persistent, query-able storage.
|
||||
from semantica.graph_store import GraphStore
|
||||
|
||||
store = GraphStore(backend="neo4j")
|
||||
store.add_nodes(entities)
|
||||
store.add_edges(relationships)
|
||||
store.add_nodes([{"id": "acme", "type": "Organization", "properties": {"name": "Acme"}}])
|
||||
store.add_edges([{"source": "alice", "target": "acme", "type": "works_for"}])
|
||||
results = store.query("MATCH (n)-[r]->(m) RETURN n, r, m")
|
||||
```
|
||||
|
||||
@@ -246,9 +260,9 @@ RDF triple-based storage with SPARQL query support.
|
||||
```python
|
||||
from semantica.triplet_store import TripletStore
|
||||
|
||||
store = TripletStore(backend="blazegraph")
|
||||
store.add_triplets(subject, predicate, obj)
|
||||
results = store.sparql("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
|
||||
store = TripletStore(backend="oxigraph")
|
||||
store.add_triplets(triplets) # list of Triplet objects (or add_triplet for one)
|
||||
results = store.execute_query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
|
||||
```
|
||||
|
||||
**Backends:** Oxigraph (embedded), Blazegraph, Apache Jena, RDF4J
|
||||
@@ -261,15 +275,18 @@ results = store.sparql("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
|
||||
Detects, scores, and merges duplicate entities across sources.
|
||||
|
||||
```python
|
||||
from semantica.deduplication import EntityResolver
|
||||
from semantica.deduplication import DuplicateDetector, EntityMerger
|
||||
|
||||
resolver = EntityResolver()
|
||||
merged = resolver.resolve(entities, strategy="semantic_v2")
|
||||
detector = DuplicateDetector(similarity_threshold=0.85)
|
||||
candidates = detector.detect_duplicates(entities)
|
||||
|
||||
merger = EntityMerger()
|
||||
operations = merger.merge_duplicates(entities, strategy="keep_most_complete")
|
||||
```
|
||||
|
||||
**v2 strategies** (`blocking_v2`, `hybrid_v2`, `semantic_v2`) are up to 7x faster than v1.
|
||||
**v2 candidate-generation modes** (`blocking_v2`, `hybrid_v2`, `semantic_v2`) are up to 7x faster than v1.
|
||||
|
||||
**Components:** `EntityResolver`, `DuplicateDetector`, `EntityMerger`, `SimilarityCalculator`, `ClusterBuilder`
|
||||
**Components:** `DuplicateDetector`, `EntityMerger`, `ClusterBuilder`, `MergeStrategyManager`
|
||||
|
||||
**`DuplicateDetector` options:** `max_results`, `top_k_per_entity`, `min_similarity`, `sort_by`
|
||||
|
||||
@@ -278,14 +295,13 @@ merged = resolver.resolve(entities, strategy="semantic_v2")
|
||||
Detects and resolves fact conflicts across overlapping knowledge sources.
|
||||
|
||||
```python
|
||||
from semantica.conflicts import ConflictDetector
|
||||
from semantica.conflicts import ConflictDetector, ConflictResolver
|
||||
|
||||
detector = ConflictDetector()
|
||||
conflicts = detector.detect_conflicts(kg)
|
||||
resolved = detector.resolve(conflicts, strategy="most_recent")
|
||||
conflicts = ConflictDetector().detect_conflicts(entities) # list of entity dicts
|
||||
resolved = ConflictResolver().resolve_conflicts(conflicts, strategy="most_recent")
|
||||
```
|
||||
|
||||
**Detection types:** value conflicts, type conflicts, temporal conflicts, logical conflicts
|
||||
**Detection types:** value conflicts, type conflicts, relationship conflicts, temporal conflicts, logical conflicts
|
||||
|
||||
**Resolution strategies:** prefer most recent, prefer most reliable source, majority vote, flag for manual review
|
||||
|
||||
@@ -298,6 +314,7 @@ Agent context graphs, decision tracking, causal chains, and precedent search.
|
||||
|
||||
```python
|
||||
from semantica.context import AgentContext, ContextGraph
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
context = AgentContext(
|
||||
vector_store=VectorStore(backend="faiss", dimension=768),
|
||||
@@ -328,7 +345,7 @@ W3C PROV-O compliant lineage tracking across all modules.
|
||||
from semantica.provenance import ProvenanceManager
|
||||
|
||||
manager = ProvenanceManager()
|
||||
manager.track_entity("entity_1", "document.pdf", "person")
|
||||
manager.track_entity("entity_1", source="document.pdf", metadata={"type": "person"})
|
||||
lineage = manager.get_lineage("entity_1")
|
||||
```
|
||||
|
||||
@@ -364,8 +381,8 @@ RDFExporter().export(graph, file_path="graph.ttl", format="turtle")
|
||||
# Analytics
|
||||
ParquetExporter().export(graph, file_path="output/graph.parquet")
|
||||
|
||||
# ArangoDB
|
||||
aql = ArangoAQLExporter().export(graph)
|
||||
# ArangoDB: writes AQL INSERT statements to the given path
|
||||
ArangoAQLExporter().export(graph, file_path="graph.aql")
|
||||
```
|
||||
|
||||
**Export formats:** RDF (Turtle, JSON-LD, N-Triples, XML), Parquet, ArangoDB AQL, CSV, OWL, Arrow, LPG, YAML, distance matrices
|
||||
@@ -390,16 +407,24 @@ viz.visualize_network(graph, output="html", file_path="graph.html")
|
||||
Pipeline DSL with parallel workers, retry policies, and failure handling.
|
||||
|
||||
```python
|
||||
from semantica.pipeline import Pipeline
|
||||
from semantica.pipeline import PipelineBuilder, ExecutionEngine
|
||||
from semantica.ingest import FileIngestor
|
||||
from semantica.semantic_extract import NERExtractor
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_step("ingest", FileIngestor())
|
||||
pipeline.add_step("extract", NERExtractor())
|
||||
pipeline.add_step("build", GraphBuilder())
|
||||
result = pipeline.run("data/")
|
||||
builder = PipelineBuilder()
|
||||
|
||||
# Each step type dispatches to a handler you register (or supply explicitly)
|
||||
builder.register_step_handler("ingest", lambda data, **c: FileIngestor().ingest(c["source"]))
|
||||
builder.register_step_handler("extract", lambda docs, **c: NERExtractor(method="pattern").extract(docs[0].text))
|
||||
|
||||
builder.add_step("ingest", step_type="ingest", source="data/")
|
||||
builder.add_step("extract", step_type="extract")
|
||||
|
||||
pipeline = builder.connect_steps("ingest", "extract").build(name="docs_to_entities")
|
||||
result = ExecutionEngine().execute_pipeline(pipeline)
|
||||
```
|
||||
|
||||
**Components:** `Pipeline`, `PipelineBuilder`, `ExecutionEngine`, `FailureHandler`, `PipelineValidator`, `ParallelismManager`, `ResourceScheduler`
|
||||
**Components:** `PipelineBuilder`, `Pipeline`, `ExecutionEngine`, `FailureHandler`, `PipelineValidator`, `ParallelismManager`, `ResourceScheduler`
|
||||
|
||||
### Explorer
|
||||
|
||||
@@ -428,7 +453,7 @@ llm = OpenAI(model="gpt-4o", api_key=os.getenv("OPENAI_API_KEY"))
|
||||
llm = LiteLLM(model="anthropic/claude-opus-4-7", api_key=os.getenv("ANTHROPIC_API_KEY"))
|
||||
```
|
||||
|
||||
**Supported providers:** OpenAI, Anthropic, Google Gemini, Groq, Ollama, DeepSeek, Novita AI, LiteLLM (20+ models via one interface)
|
||||
**Supported providers:** OpenAI, Anthropic, Google Gemini, Groq, Ollama, DeepSeek, Novita AI, HuggingFace, plus LiteLLM (100+ models via one interface)
|
||||
|
||||
### MCP Server
|
||||
|
||||
@@ -445,44 +470,43 @@ python -m semantica.mcp_server
|
||||
Bootstrap knowledge graphs from verified structured sources: fixed-point reference data, controlled vocabularies, and domain anchors.
|
||||
|
||||
```python
|
||||
from semantica.seed import SeedManager
|
||||
from semantica.seed import SeedDataManager
|
||||
|
||||
seed = SeedManager()
|
||||
seed.populate(kg, dataset="companies", count=100)
|
||||
seed = SeedDataManager()
|
||||
|
||||
# Load domain seeds from file or built-in datasets
|
||||
seed.load_from_file("seed_data/industries.json")
|
||||
seed.inject(kg) # merges seed nodes without duplicating existing entities
|
||||
# Load trusted reference data from CSV / JSON / a database / an API
|
||||
seed_data = seed.load_from_csv("seed_data/industries.csv", entity_type="Industry")
|
||||
|
||||
# Merge seed data with extraction output (seed values win on conflict by default)
|
||||
combined = seed.integrate_with_extracted(
|
||||
{"entities": seed_data, "relationships": []},
|
||||
{"entities": extracted_entities, "relationships": extracted_relationships},
|
||||
merge_strategy="seed_first",
|
||||
)
|
||||
```
|
||||
|
||||
**Use cases:** anchoring extraction with known entities, pre-populating ontology classes, deterministic test graph generation.
|
||||
|
||||
### Evals
|
||||
|
||||
Evaluation framework for measuring KG quality, extraction accuracy, and pipeline performance.
|
||||
Scores decision-intelligence outputs (decision records, audit trails, reasoning
|
||||
text) with a registry of deterministic and model-backed evaluators plus a small
|
||||
run harness.
|
||||
|
||||
```python
|
||||
from semantica.evals import KGEvaluator, ExtractionEvaluator, PipelineEvaluator, RegressionTracker
|
||||
from semantica.evals import evaluate, list_evaluators
|
||||
|
||||
# KG quality
|
||||
report = KGEvaluator().evaluate(kg, ontology=ontology)
|
||||
print(f"Completeness: {report.completeness:.2%} Consistency: {report.consistency:.2%}")
|
||||
list_evaluators()
|
||||
# ['decision_scores', 'exact_match', 'keyword_check', 'length_range',
|
||||
# 'levenshtein', 'llm_as_judge', 'numeric_range', 'regex_match', 'rouge',
|
||||
# 'temporal_range']
|
||||
|
||||
# Extraction accuracy
|
||||
report = ExtractionEvaluator().evaluate_ner(predictions=extracted, gold_standard=annotated)
|
||||
print(f"Precision: {report.precision:.3f} Recall: {report.recall:.3f} F1: {report.f1:.3f}")
|
||||
|
||||
# Pipeline throughput and latency
|
||||
metrics = PipelineEvaluator().benchmark(pipeline, data="data/", bench_runs=5)
|
||||
print(f"Throughput: {metrics.docs_per_second:.1f} docs/sec")
|
||||
|
||||
# Regression tracking across runs
|
||||
tracker = RegressionTracker(db_path="eval_history.db")
|
||||
run_id = tracker.record_run(pipeline_version="v1.2.0", metrics=metrics)
|
||||
diff = tracker.compare(run_id, baseline_run_id="run_abc123")
|
||||
cases = [("apple", "aple"), ("night", "nacht")]
|
||||
summary = evaluate(cases, evaluators=["levenshtein"])
|
||||
print(summary.total, summary.passed, summary.pass_rate)
|
||||
```
|
||||
|
||||
**Components:** `KGEvaluator`, `ExtractionEvaluator`, `PipelineEvaluator`, `RegressionTracker`
|
||||
**Public API:** `evaluate(cases, evaluators, config=None)`, `list_evaluators()`, `get_evaluator(name)`, and the `EvalMetric` / `CaseResult` / `EvalSummary` result types. See the [Evals reference](/reference/evals).
|
||||
|
||||
### Core
|
||||
|
||||
@@ -491,20 +515,20 @@ Base classes, shared data models, and the plugin registry used across all module
|
||||
```python
|
||||
from semantica.core import Semantica, PluginRegistry, ConfigManager
|
||||
|
||||
# Top-level orchestrator
|
||||
sem = Semantica(config_path="config.yaml")
|
||||
# ConfigManager loads a Config; Config.get() does dotted lookups
|
||||
config = ConfigManager().load_from_file("config.yaml")
|
||||
batch = config.get("processing.batch_size", default=32)
|
||||
|
||||
# Top-level orchestrator: pass the Config object (or a dict), not a path
|
||||
sem = Semantica(config=config)
|
||||
sem.initialize()
|
||||
|
||||
# Plugin registry: register custom components
|
||||
# Plugin registry: register custom components under a name
|
||||
registry = PluginRegistry()
|
||||
registry.register("my_ingestor", MyCustomIngestor)
|
||||
|
||||
# Config management
|
||||
config = ConfigManager(config_path="config.yaml")
|
||||
batch = config.get("processing.batch_size", default=32)
|
||||
registry.register_plugin("my_ingestor", MyCustomIngestor, version="1.0.0")
|
||||
```
|
||||
|
||||
**Components:** `Semantica`, `PluginRegistry`, `ConfigManager`, `LifecycleManager`, `HealthMonitor`, `Config`
|
||||
**Components:** `Semantica`, `PluginRegistry`, `ConfigManager`, `Config`, `LifecycleManager`, `HealthStatus`, `MethodRegistry`
|
||||
|
||||
### Utils
|
||||
|
||||
@@ -532,11 +556,13 @@ from semantica.semantic_extract import NERExtractor, RelationExtractor
|
||||
from semantica.kg import GraphBuilder
|
||||
|
||||
sources = FileIngestor().ingest("data/")
|
||||
parsed = DocumentParser().parse(sources[0])
|
||||
entities = NERExtractor(method="llm", llm_provider=llm).extract(parsed)
|
||||
relationships = RelationExtractor(method="llm", llm_provider=llm).extract(parsed, entities=entities)
|
||||
text = DocumentParser().parse(sources[0].path)["full_text"]
|
||||
ner = NERExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
|
||||
rel = RelationExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
|
||||
entities = ner.extract(text)
|
||||
relationships = rel.extract(text, entities=entities)
|
||||
graph = GraphBuilder(merge_entities=True).build(
|
||||
entities=entities, relationships=relationships
|
||||
{"entities": entities, "relationships": relationships}
|
||||
)
|
||||
```
|
||||
|
||||
@@ -555,16 +581,20 @@ from semantica.vector_store import VectorStore
|
||||
context = AgentContext(
|
||||
vector_store=VectorStore(backend="faiss", dimension=768),
|
||||
knowledge_graph=ContextGraph(advanced_analytics=True),
|
||||
graph_expansion=True,
|
||||
)
|
||||
context.load_graph("company_kg.json")
|
||||
|
||||
result = context.query(
|
||||
# store() extracts entities and populates the graph + vector index
|
||||
context.store([{"content": "Steve Wozniak co-founded Apple with Steve Jobs."}])
|
||||
|
||||
# retrieve() blends vector similarity with multi-hop graph traversal
|
||||
results = context.retrieve(
|
||||
"What companies did Apple alumni found?",
|
||||
mode="graphrag",
|
||||
reasoning=True,
|
||||
use_graph=True,
|
||||
expand_graph=True,
|
||||
)
|
||||
for claim in result.claims:
|
||||
print(f"{claim.text} → {claim.source_node}")
|
||||
for r in results:
|
||||
print(f"[{r['score']:.3f}] {r['content']} (source: {r['source']})")
|
||||
```
|
||||
|
||||
**Best for:** question-answering systems, RAG with source attribution, research assistants
|
||||
@@ -606,18 +636,22 @@ precedents = context.find_precedents("model selection", limit=5)
|
||||
|
||||
```python
|
||||
from semantica.ingest import FileIngestor
|
||||
from semantica.parse import DocumentParser
|
||||
from semantica.semantic_extract import NERExtractor
|
||||
from semantica.kg import GraphBuilder
|
||||
from semantica.provenance import ProvenanceManager
|
||||
from semantica.export import RDFExporter
|
||||
|
||||
sources = FileIngestor().ingest("records/")
|
||||
entities = NERExtractor(method="llm", llm_provider=llm).extract(sources)
|
||||
graph = GraphBuilder(merge_entities=True).build(entities=entities, relationships=[])
|
||||
prov = ProvenanceManager()
|
||||
lineage = prov.get_entity_lineage("entity_id")
|
||||
ner = NERExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
|
||||
entities = ner.extract(DocumentParser().parse(sources[0].path)["full_text"])
|
||||
graph = GraphBuilder(merge_entities=True).build({"entities": entities, "relationships": []})
|
||||
|
||||
RDFExporter(include_provenance=True).export(graph, file_path="audit.ttl", format="turtle")
|
||||
prov = ProvenanceManager()
|
||||
prov.track_entity("entity_id", source="records/filing.pdf", metadata={"extractor": "llm"})
|
||||
lineage = prov.get_lineage("entity_id")
|
||||
|
||||
RDFExporter().export(graph, file_path="audit.ttl", format="turtle")
|
||||
```
|
||||
|
||||
**Best for:** HIPAA, SOX, GDPR, FDA 21 CFR Part 11 deployments
|
||||
@@ -632,18 +666,25 @@ RDFExporter(include_provenance=True).export(graph, file_path="audit.ttl", format
|
||||
from semantica.ingest import WebIngestor
|
||||
from semantica.normalize import TextNormalizer
|
||||
from semantica.semantic_extract import NERExtractor, RelationExtractor
|
||||
from semantica.graph_store import Neo4jStore
|
||||
from semantica.graph_store import GraphStore
|
||||
from semantica.kg import GraphBuilder
|
||||
|
||||
pages = WebIngestor(max_depth=2).ingest("https://example.com")
|
||||
ingestor = WebIngestor()
|
||||
normalizer = TextNormalizer()
|
||||
store = Neo4jStore(uri="bolt://localhost:7687", user="neo4j", password="password")
|
||||
ner = NERExtractor(method="pattern")
|
||||
rel = RelationExtractor(method="pattern")
|
||||
|
||||
for page in pages:
|
||||
# The generic GraphStore wrapper exposes the add_nodes/add_edges interface
|
||||
# GraphBuilder persists through; a raw Neo4jStore does not
|
||||
store = GraphStore(backend="neo4j", uri="bolt://localhost:7687", user="neo4j", password="password")
|
||||
builder = GraphBuilder(merge_entities=True, graph_store=store)
|
||||
|
||||
for url in ["https://example.com/a", "https://example.com/b"]:
|
||||
page = ingestor.ingest_url(url) # WebContent, has .text
|
||||
text = normalizer.normalize_text(page.text)
|
||||
entities = NERExtractor().extract(text)
|
||||
relationships = RelationExtractor().extract(text, entities=entities)
|
||||
store.add_nodes(entities)
|
||||
store.add_edges(relationships)
|
||||
entities = ner.extract(text)
|
||||
relationships = rel.extract(text, entities=entities)
|
||||
builder.build({"entities": entities, "relationships": relationships})
|
||||
```
|
||||
|
||||
**Best for:** competitive intelligence, news monitoring, research aggregation
|
||||
@@ -692,8 +733,8 @@ versioner.create_snapshot(kg, "2024-Q1", author="user@example.com", description=
|
||||
| [vector_store](/reference/vector_store) | Vector database | `VectorStore` |
|
||||
| [graph_store](/reference/graph_store) | Graph database | `GraphStore` |
|
||||
| [triplet_store](/reference/triplet_store) | RDF triple store | `TripletStore` |
|
||||
| [deduplication](/reference/deduplication) | Entity resolution | `EntityResolver`, `DuplicateDetector`, `ClusterBuilder`, `MergeStrategyManager` |
|
||||
| [conflicts](/reference/conflicts) | Conflict resolution | `ConflictDetector` |
|
||||
| [deduplication](/reference/deduplication) | Entity resolution | `DuplicateDetector`, `EntityMerger`, `ClusterBuilder`, `MergeStrategyManager` |
|
||||
| [conflicts](/reference/conflicts) | Conflict resolution | `ConflictDetector`, `ConflictResolver`, `SourceTracker` |
|
||||
| [context](/reference/context) | Agent context & decisions | `AgentContext`, `ContextGraph` |
|
||||
| [provenance](/reference/provenance) | W3C PROV-O lineage | `ProvenanceManager` |
|
||||
| [change_management](/reference/change_management) | Version control | `TemporalVersionManager` |
|
||||
@@ -703,8 +744,8 @@ versioner.create_snapshot(kg, "2024-Q1", author="user@example.com", description=
|
||||
| [explorer](/reference/explorer) | Knowledge Explorer UI | `semantica-explorer --graph <file>` |
|
||||
| [llms](/reference/llms) | LLM providers | `Groq`, `OpenAI`, `create_provider` |
|
||||
| [mcp_server](/reference/mcp_server) | MCP stdio server | `python -m semantica.mcp_server` |
|
||||
| [seed](/reference/seed) | KG bootstrapping from structured sources | `SeedManager` |
|
||||
| [evals](/reference/evals) | Quality evaluation | `KGEvaluator`, `ExtractionEvaluator`, `PipelineEvaluator`, `RegressionTracker` |
|
||||
| [seed](/reference/seed) | KG bootstrapping from structured sources | `SeedDataManager` |
|
||||
| [evals](/reference/evals) | Decision-intelligence evaluation | `evaluate`, `list_evaluators`, `EvalSummary` |
|
||||
| [core](/reference/core) | Base classes & registry | `Semantica`, `ConfigManager`, `PluginRegistry`, `LifecycleManager` |
|
||||
| [utils](/reference/utils) | Shared utilities | `helpers`, `validators` |
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ from semantica.llms import Groq, OpenAI, LiteLLM, HuggingFaceLLM
|
||||
from semantica.llms import LiteLLM
|
||||
|
||||
llm = LiteLLM(
|
||||
model="anthropic/claude-sonnet-4-20250514",
|
||||
model="anthropic/claude-sonnet-5",
|
||||
api_key=os.getenv("ANTHROPIC_API_KEY"),
|
||||
temperature=0.0,
|
||||
)
|
||||
@@ -198,7 +198,7 @@ llm = Groq(api_key=os.getenv("GROQ_API_KEY"), model="llama-3.1-8b-instant")
|
||||
# Method 3: Multiple providers via LiteLLM
|
||||
providers = {
|
||||
"fast": LiteLLM(model="groq/llama-3.1-8b-instant", api_key=os.getenv("GROQ_API_KEY")),
|
||||
"smart": LiteLLM(model="anthropic/claude-sonnet-4-20250514", api_key=os.getenv("ANTHROPIC_API_KEY"))
|
||||
"smart": LiteLLM(model="anthropic/claude-sonnet-5", api_key=os.getenv("ANTHROPIC_API_KEY"))
|
||||
}
|
||||
```
|
||||
|
||||
@@ -252,7 +252,7 @@ from semantica.llms import LiteLLM
|
||||
# pip install "semantica[llm-litellm]"
|
||||
|
||||
# Anthropic Claude
|
||||
llm = LiteLLM(model="anthropic/claude-opus-4-5", api_key=os.getenv("ANTHROPIC_API_KEY"))
|
||||
llm = LiteLLM(model="anthropic/claude-opus-4-7", api_key=os.getenv("ANTHROPIC_API_KEY"))
|
||||
|
||||
# Google Gemini
|
||||
llm = LiteLLM(model="gemini/gemini-1.5-pro", api_key=os.getenv("GOOGLE_API_KEY"))
|
||||
@@ -267,7 +267,7 @@ llm = LiteLLM(model="deepseek/deepseek-chat", api_key=os.getenv("DEEP
|
||||
llm = LiteLLM(model="azure/gpt-4o", api_key=os.getenv("AZURE_API_KEY"))
|
||||
|
||||
# AWS Bedrock
|
||||
llm = LiteLLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
llm = LiteLLM(model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
|
||||
# Novita AI
|
||||
llm = LiteLLM(model="novita/deepseek/deepseek-v3.2", api_key=os.getenv("NOVITA_API_KEY"))
|
||||
@@ -297,12 +297,12 @@ from semantica.llms import LiteLLM
|
||||
|
||||
# Pattern: LiteLLM(model="<provider>/<model-name>")
|
||||
providers = {
|
||||
"Anthropic": LiteLLM(model="anthropic/claude-opus-4-5", api_key=os.getenv("ANTHROPIC_API_KEY")),
|
||||
"Anthropic": LiteLLM(model="anthropic/claude-opus-4-7", api_key=os.getenv("ANTHROPIC_API_KEY")),
|
||||
"Gemini": LiteLLM(model="gemini/gemini-1.5-pro", api_key=os.getenv("GOOGLE_API_KEY")),
|
||||
"Ollama": LiteLLM(model="ollama/llama3.2:3b", api_base="http://localhost:11434"),
|
||||
"DeepSeek": LiteLLM(model="deepseek/deepseek-chat", api_key=os.getenv("DEEPSEEK_API_KEY")),
|
||||
"Azure": LiteLLM(model="azure/gpt-4o", api_key=os.getenv("AZURE_API_KEY")),
|
||||
"Bedrock": LiteLLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0"),
|
||||
"Bedrock": LiteLLM(model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0"),
|
||||
"Cohere": LiteLLM(model="cohere/command-r-plus", api_key=os.getenv("COHERE_API_KEY")),
|
||||
"Novita AI": LiteLLM(model="novita/deepseek/deepseek-v3.2", api_key=os.getenv("NOVITA_API_KEY")),
|
||||
}
|
||||
@@ -416,7 +416,7 @@ for text in texts:
|
||||
| :---------- | :--------------------------- | :----------- |
|
||||
| **Entity Extraction** | `Groq("llama-3.3-70b-versatile")` | Fast, good accuracy for structured tasks |
|
||||
| **Relation Extraction** | `OpenAI("gpt-4o")` | Best at complex relationship reasoning |
|
||||
| **Complex Analysis** | `LiteLLM("anthropic/claude-sonnet-4-20250514")` | Highest reasoning capability |
|
||||
| **Complex Analysis** | `LiteLLM("anthropic/claude-sonnet-5")` | Highest reasoning capability |
|
||||
| **High Volume/Cost** | `LiteLLM("deepseek/deepseek-chat")` | Lowest cost per token |
|
||||
|
||||
### Error Handling
|
||||
|
||||
@@ -22,6 +22,7 @@ icon: "sitemap"
|
||||
| `LLMOntologyGenerator` | LLM-powered ontology generation for complex domains |
|
||||
| `SHACLGenerator` | Generate SHACL shapes from an ontology or KG schema |
|
||||
| `OntologyValidator` | Validate any graph against SHACL shapes: returns `SHACLValidationReport` |
|
||||
| `OntologyQualityGate` | Run deterministic ontology/KG quality checks for CI |
|
||||
| `OWLGenerator` | Serialize ontologies to Turtle, RDF/XML, JSON-LD |
|
||||
| `NamespaceManager` | IRI generation, prefix management, and namespace binding |
|
||||
| `OntologyEvaluator` | Coverage, completeness, and granularity quality metrics |
|
||||
@@ -81,9 +82,38 @@ engine.export_owl(ontology, "ontology.ttl", format="turtle")
|
||||
| :------ | :----------- |
|
||||
| `from_data(data)` | Run the 5-stage pipeline on entity/relationship data |
|
||||
| `validate_graph(kg, ontology=...)` | Check a knowledge graph against generated SHACL shapes |
|
||||
| `quality_check(ontology, graph_data=...)` | Return a deterministic quality report and CI-friendly pass/fail result |
|
||||
| `export_owl(ontology, path, format)` | Serialize to `"turtle"`, `"xml"`, or `"json-ld"` |
|
||||
| `evaluate(ontology, kg)` | Compute coverage, completeness, and granularity metrics |
|
||||
|
||||
### Ontology Quality Gate
|
||||
|
||||
Use the quality gate before export or deployment to catch structural issues
|
||||
without adding a runtime dependency:
|
||||
|
||||
```python
|
||||
from semantica.ontology import ontology_quality_check
|
||||
|
||||
report = ontology_quality_check(
|
||||
ontology,
|
||||
graph_data=kg,
|
||||
thresholds={"min_coverage": 0.8},
|
||||
)
|
||||
|
||||
if not report.passed:
|
||||
for issue in report.issues:
|
||||
print(issue.code, issue.message)
|
||||
```
|
||||
|
||||
The report checks class/property coverage, orphan schema elements, domain and
|
||||
range references, and unresolved KG relationship endpoints. It includes
|
||||
machine-readable issue codes, severity, counts, metrics, and threshold
|
||||
failures. The first version reports findings only; it does not auto-fix data.
|
||||
|
||||
### Thresholds
|
||||
|
||||
`min_coverage` (default `0.0`) sets the minimum required `coverage` score, the average of class and property coverage from `0.0` to `1.0`; the gate fails below it. `max_errors` (default `0.0`) caps how many `error`/`critical` issues are allowed before the gate fails. `max_warnings` (default `None`) caps `warning` issues the same way, and `None` means warnings alone never fail the gate. `fail_on_warnings` is a separate parameter, not a `thresholds` key, passed to `OntologyQualityGate(...)` or `.check(...)` directly; when `True`, a single warning fails the gate regardless of `max_warnings`.
|
||||
|
||||
## OntologyGenerator (5-Stage Pipeline)
|
||||
|
||||
**`OntologyGenerator`** auto-generates a formal ontology from your knowledge graph entities and relationships:
|
||||
|
||||
@@ -323,7 +323,7 @@ all_facts = datalog.derive_all()
|
||||
|
||||
# Query with variable pattern: variables start with uppercase or ?
|
||||
results = datalog.query("ancestor(alice, ?Z)")
|
||||
# → [{"Z": "bob"}, {"Z": "charlie"}, {"Z": "dave"}]
|
||||
# → a list of binding dicts: [{"Z": "bob"}, {"Z": "charlie"}, {"Z": "dave"}] (order not guaranteed)
|
||||
|
||||
# Clear and start over
|
||||
datalog.clear()
|
||||
|
||||
Generated
+653
@@ -33,7 +33,9 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.6",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -43,12 +45,34 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^6.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.3",
|
||||
"@csstools/css-color-parser": "^3.0.9",
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3",
|
||||
"lru-cache": "^10.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -340,6 +364,121 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^5.1.0",
|
||||
"@csstools/css-calc": "^2.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@egjs/hammerjs": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
||||
@@ -1473,6 +1612,63 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.3.tgz",
|
||||
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1614,6 +1810,18 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsdom": {
|
||||
"version": "21.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/jsdom/-/jsdom-21.1.7.tgz",
|
||||
"integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/tough-cookie": "*",
|
||||
"parse5": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -1665,6 +1873,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -2010,6 +2225,16 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -2027,6 +2252,42 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
@@ -2259,6 +2520,20 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^3.2.0",
|
||||
"rrweb-cssom": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2370,6 +2645,20 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-5.0.0.tgz",
|
||||
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2387,6 +2676,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
@@ -2449,6 +2745,14 @@
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
@@ -2466,6 +2770,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
@@ -3058,6 +3375,19 @@
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -3068,6 +3398,47 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -3173,6 +3544,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -3186,6 +3564,46 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz",
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
"decimal.js": "^10.5.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.16",
|
||||
"parse5": "^7.2.1",
|
||||
"rrweb-cssom": "^0.8.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.1.1",
|
||||
"ws": "^8.18.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -3321,6 +3739,17 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
@@ -4283,6 +4712,13 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.27",
|
||||
"resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.27.tgz",
|
||||
"integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -4382,6 +4818,19 @@
|
||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4505,6 +4954,30 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -4817,6 +5290,33 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -4924,6 +5424,13 @@
|
||||
"inline-style-parser": "0.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
@@ -4941,6 +5448,52 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -5364,6 +5917,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -5390,6 +6004,45 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xss": {
|
||||
"version": "1.0.15",
|
||||
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
|
||||
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts",
|
||||
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/markdownEditorInteraction.test.tsx tests/markdownEditorState.test.ts tests/nodeMarkdownSync.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/explorerCapabilities.test.tsx tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts",
|
||||
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts tests/ontologyEditorModel.test.ts",
|
||||
"test:deterministic-e2e": "node --import tsx --test tests/deterministicExplorerRendering.e2e.ts",
|
||||
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
|
||||
},
|
||||
@@ -39,7 +40,9 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.6",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -49,6 +52,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
|
||||
+58
-15
@@ -17,10 +17,13 @@ import {
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { ExploreWorkspaceTabs, type ExploreView } from './ExploreWorkspaceTabs';
|
||||
import { fetchAgentMemoryAvailability } from './explorerCapabilities';
|
||||
|
||||
const DecisionWorkspace = lazy(() => import('./workspaces/DecisionWorkspace/DecisionWorkspace').then((module) => ({ default: module.DecisionWorkspace })));
|
||||
const DiffMergeWorkspace = lazy(() => import('./workspaces/DiffMergeWorkspace/DiffMergeWorkspace').then((module) => ({ default: module.DiffMergeWorkspace })));
|
||||
const GraphWorkspace = lazy(() => import('./workspaces/GraphWorkspace/GraphWorkspace').then((module) => ({ default: module.GraphWorkspace })));
|
||||
const MemoryWorkspace = lazy(() => import('./workspaces/MemoryWorkspace').then((module) => ({ default: module.MemoryWorkspace })));
|
||||
const ImportExportWorkspace = lazy(() => import('./workspaces/ImportExportWorkspace/ImportExportWorkspace').then((module) => ({ default: module.ImportExportWorkspace })));
|
||||
const LineageDiagram = lazy(() => import('./workspaces/LineageWorkspace/LineageDiagram').then((module) => ({ default: module.LineageDiagram })));
|
||||
const ReasoningWorkspace = lazy(() => import('./workspaces/ReasoningWorkspace').then((module) => ({ default: module.ReasoningWorkspace })));
|
||||
@@ -33,7 +36,6 @@ const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/Ontol
|
||||
const OntologyWorkspace = lazy(() => import('./workspaces/OntologyWorkspace').then((module) => ({ default: module.OntologyWorkspace })));
|
||||
|
||||
type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage' | 'ontology-hub';
|
||||
type ExploreView = 'graph' | 'vocabulary';
|
||||
type AnalyzeView = 'sparql' | 'reasoning';
|
||||
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
|
||||
type ManageView = 'lineage' | 'kg-overview' | 'ontology';
|
||||
@@ -93,6 +95,18 @@ const navItems: NavItem[] = [
|
||||
{ id: 'ontology-hub', label: 'Ontology Hub', hint: 'Schema governance, registry, and vocabulary management', icon: GitMerge },
|
||||
];
|
||||
|
||||
function readInitialWorkspace(): WorkspaceId {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("ontologyTab") || params.has("ontologyEntity")) {
|
||||
return "ontology-hub";
|
||||
}
|
||||
} catch {
|
||||
// Default to the welcome screen when URL state is unavailable.
|
||||
}
|
||||
return "welcome";
|
||||
}
|
||||
|
||||
const shellStyles = `
|
||||
:root {
|
||||
--app-bg: #07111f;
|
||||
@@ -1773,12 +1787,43 @@ function WelcomeScreen({
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>(readInitialWorkspace);
|
||||
const [exploreView, setExploreView] = useState<ExploreView>('graph');
|
||||
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
|
||||
const [enrichView, setEnrichView] = useState<EnrichView>('import');
|
||||
const [manageView, setManageView] = useState<ManageView>('lineage');
|
||||
const [graphFocusRequest, setGraphFocusRequest] = useState<{ nodeId: string; token: number } | null>(null);
|
||||
const [exploreDraftDirty, setExploreDraftDirty] = useState(false);
|
||||
const [agentMemoryAvailable, setAgentMemoryAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void fetchAgentMemoryAvailability().then((available) => {
|
||||
if (active) setAgentMemoryAvailable(available);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const confirmDiscardExploreDraft = () => (
|
||||
!exploreDraftDirty
|
||||
|| window.confirm("Discard the unapplied Markdown draft and leave this resource?")
|
||||
);
|
||||
|
||||
const switchExploreView = (nextView: ExploreView) => {
|
||||
if (nextView === exploreView) return;
|
||||
if (!confirmDiscardExploreDraft()) return;
|
||||
setExploreDraftDirty(false);
|
||||
setExploreView(nextView);
|
||||
};
|
||||
|
||||
const switchWorkspace = (nextWorkspace: WorkspaceId) => {
|
||||
if (nextWorkspace === activeWorkspace) return;
|
||||
if (activeWorkspace === "explore" && !confirmDiscardExploreDraft()) return;
|
||||
setExploreDraftDirty(false);
|
||||
setActiveWorkspace(nextWorkspace);
|
||||
};
|
||||
|
||||
|
||||
const renderWorkspace = () => {
|
||||
@@ -1811,18 +1856,15 @@ export default function App() {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Explore"
|
||||
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
|
||||
kicker={exploreView === 'graph' ? 'Graph Studio' : 'Vocabulary Browser'}
|
||||
subtitle={exploreView === 'graph' ? undefined : exploreView === 'memories' ? "Browse and edit canonical AgentMemory documents." : "Browse the graph and switch views without leaving the workspace."}
|
||||
kicker={exploreView === 'graph' ? 'Graph Studio' : exploreView === 'memories' ? 'Memory Browser' : 'Vocabulary Browser'}
|
||||
compact
|
||||
tabs={
|
||||
<>
|
||||
<button className="workspace-tab" data-active={exploreView === 'graph'} onClick={() => setExploreView('graph')}>
|
||||
Semantica Explorer
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={exploreView === 'vocabulary'} onClick={() => setExploreView('vocabulary')}>
|
||||
Vocabulary Browser
|
||||
</button>
|
||||
</>
|
||||
<ExploreWorkspaceTabs
|
||||
activeView={exploreView}
|
||||
agentMemoryAvailable={agentMemoryAvailable}
|
||||
onSelect={switchExploreView}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ErrorBoundary key={`explore-${exploreView}`}>
|
||||
@@ -1831,8 +1873,9 @@ export default function App() {
|
||||
<GraphWorkspace
|
||||
externalFocusNodeId={graphFocusRequest?.nodeId}
|
||||
externalFocusToken={graphFocusRequest?.token}
|
||||
onDirtyChange={setExploreDraftDirty}
|
||||
/>
|
||||
) : <VocabularyWorkspace />}
|
||||
) : exploreView === 'memories' ? <MemoryWorkspace onDirtyChange={setExploreDraftDirty} /> : <VocabularyWorkspace />}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</WorkspaceShell>
|
||||
@@ -1977,13 +2020,13 @@ export default function App() {
|
||||
<style>{shellStyles}</style>
|
||||
<div className="app-shell">
|
||||
<aside className="app-rail">
|
||||
<button className="brand-pill" title="Semantica Knowledge Explorer" onClick={() => setActiveWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
|
||||
<button className="brand-pill" title="Semantica Knowledge Explorer" onClick={() => switchWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
|
||||
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
className="nav-button"
|
||||
data-active={activeWorkspace === id}
|
||||
onClick={() => setActiveWorkspace(id)}
|
||||
onClick={() => switchWorkspace(id)}
|
||||
title={hint}
|
||||
>
|
||||
<Icon size={20} />
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export type ExploreView = 'graph' | 'memories' | 'vocabulary';
|
||||
|
||||
type ExploreWorkspaceTabsProps = {
|
||||
activeView: ExploreView;
|
||||
agentMemoryAvailable: boolean;
|
||||
onSelect: (view: ExploreView) => void;
|
||||
};
|
||||
|
||||
export function ExploreWorkspaceTabs({
|
||||
activeView,
|
||||
agentMemoryAvailable,
|
||||
onSelect,
|
||||
}: ExploreWorkspaceTabsProps) {
|
||||
return (
|
||||
<>
|
||||
<button className="workspace-tab" data-active={activeView === 'graph'} onClick={() => onSelect('graph')}>
|
||||
Semantica Explorer
|
||||
</button>
|
||||
{agentMemoryAvailable ? (
|
||||
<button className="workspace-tab" data-active={activeView === 'memories'} onClick={() => onSelect('memories')}>
|
||||
Memories
|
||||
</button>
|
||||
) : null}
|
||||
<button className="workspace-tab" data-active={activeView === 'vocabulary'} onClick={() => onSelect('vocabulary')}>
|
||||
Vocabulary Browser
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
type Fetcher = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
type ExplorerInfo = {
|
||||
capabilities?: {
|
||||
agent_memory?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export async function fetchAgentMemoryAvailability(
|
||||
fetcher: Fetcher = fetch,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetcher('/api/info');
|
||||
if (!response.ok) return false;
|
||||
|
||||
const info = await response.json() as ExplorerInfo;
|
||||
return info.capabilities?.agent_memory === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export type RegistryEntryOp =
|
||||
| "export"
|
||||
| "merge"
|
||||
| "add-node"
|
||||
| "update-node"
|
||||
| "add-edge"
|
||||
| "delete"
|
||||
| "infer"
|
||||
|
||||
@@ -16,6 +16,7 @@ const OP_META: Record<
|
||||
export: { label: "EXPORT", color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" },
|
||||
merge: { label: "MERGE", color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" },
|
||||
"add-node": { label: "ADD NODE", color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" },
|
||||
"update-node": { label: "UPDATE NODE", color: "#79c0ff", bg: "rgba(121,192,255,0.10)", border: "rgba(121,192,255,0.24)" },
|
||||
"add-edge": { label: "ADD EDGE", color: "#4cc38a", bg: "rgba(76,195,138,0.10)", border: "rgba(76,195,138,0.22)" },
|
||||
delete: { label: "DELETE", color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" },
|
||||
infer: { label: "INFER", color: "#d2a8ff", bg: "rgba(210,168,255,0.12)", border: "rgba(210,168,255,0.28)" },
|
||||
@@ -23,7 +24,7 @@ const OP_META: Record<
|
||||
};
|
||||
|
||||
const ALL_OPS: (RegistryEntryOp | "all")[] = [
|
||||
"all", "import", "export", "merge", "add-node", "add-edge", "infer", "delete", "vocab-import",
|
||||
"all", "import", "export", "merge", "add-node", "update-node", "add-edge", "infer", "delete", "vocab-import",
|
||||
];
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import type { GraphSelectedNodeKind } from "./types";
|
||||
import { MarkdownContentViewer } from "./MarkdownContentViewer";
|
||||
import type { MarkdownApplyResult } from "./markdownResourceClient";
|
||||
|
||||
export type LinkPrediction = {
|
||||
target: string;
|
||||
@@ -44,6 +45,8 @@ export interface GraphInspectorPanelProps {
|
||||
pathResult: PathResponse | null;
|
||||
onDownloadProvenance: (format: "json" | "markdown") => void;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
onMarkdownApplied?: (result: MarkdownApplyResult) => void;
|
||||
onMarkdownDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
|
||||
@@ -304,6 +307,8 @@ export function GraphInspectorPanel({
|
||||
pathResult,
|
||||
onDownloadProvenance,
|
||||
onFocusNode,
|
||||
onMarkdownApplied,
|
||||
onMarkdownDirtyChange,
|
||||
}: GraphInspectorPanelProps) {
|
||||
if (!nodeId) {
|
||||
return (
|
||||
@@ -414,19 +419,18 @@ export function GraphInspectorPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Content Section — only rendered when the node carries actual content.
|
||||
This matches the existing inspector convention: sections that have no
|
||||
data for the current node are either hidden (temporal bounds) or closed
|
||||
by default (Source Attribution, Properties). Always showing an open
|
||||
empty panel would add noise for every relationship/predicate node. */}
|
||||
{nodeContent && (
|
||||
<details className="node-panel-collapse" open>
|
||||
<summary className="node-panel-summary">Content</summary>
|
||||
<div className="node-panel-body" style={{ marginTop: 8 }}>
|
||||
<MarkdownContentViewer content={nodeContent} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{/* Canonical nodes remain editable even when their current body is empty. */}
|
||||
<details className="node-panel-collapse" open>
|
||||
<summary className="node-panel-summary">Content</summary>
|
||||
<div className="node-panel-body" style={{ marginTop: 8 }}>
|
||||
<MarkdownContentViewer
|
||||
content={nodeContent}
|
||||
resource={{ kind: "context-node", id: effectiveNodeId }}
|
||||
onApplied={onMarkdownApplied}
|
||||
onDirtyChange={onMarkdownDirtyChange}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Actions */}
|
||||
<section style={sectionStyle}>
|
||||
|
||||
@@ -44,6 +44,12 @@ import { createTemporalSnapshotGuards, type TemporalSnapshotResponse } from "./t
|
||||
import { SMALL_GRAPH_MAX_NODES } from "./smallGraphLayout";
|
||||
import { buildRealtimeEdgeAttributes } from "./realtimeGraphAttributes";
|
||||
import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
|
||||
import type { MarkdownApplyResult } from "./markdownResourceClient";
|
||||
import {
|
||||
NodeMarkdownRefreshGuard,
|
||||
buildNodeMarkdownAttributeUpdate,
|
||||
readNodeMarkdownAttributeUpdate,
|
||||
} from "./nodeMarkdownSync";
|
||||
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
@@ -1241,9 +1247,10 @@ function collectPluginOverlays(
|
||||
interface GraphWorkspaceProps {
|
||||
externalFocusNodeId?: string;
|
||||
externalFocusToken?: number;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: GraphWorkspaceProps = {}) {
|
||||
export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirtyChange }: GraphWorkspaceProps = {}) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||
const [focusedNodeId, setFocusedNodeId] = useState("");
|
||||
const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState("");
|
||||
@@ -1251,6 +1258,12 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
||||
const [graphReady, setGraphReady] = useState(false);
|
||||
const [graphVersion, setGraphVersion] = useState(0);
|
||||
const [markdownDraftDirty, setMarkdownDraftDirty] = useState(false);
|
||||
const markdownRefreshGuard = useMemo(() => new NodeMarkdownRefreshGuard(), []);
|
||||
const handleMarkdownDirtyChange = useCallback((dirty: boolean) => {
|
||||
setMarkdownDraftDirty(dirty);
|
||||
onDirtyChange?.(dirty);
|
||||
}, [onDirtyChange]);
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>("full");
|
||||
const [aggregationEnabled] = useState(true);
|
||||
const [collapsedNeighborhoodNodeIds, setCollapsedNeighborhoodNodeIds] = useState<string[]>([]);
|
||||
@@ -1628,8 +1641,17 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
: null,
|
||||
[viewMode, aggregationEnabled, collapsedNeighborhoodNodeIds, graphVersion],
|
||||
);
|
||||
const confirmDiscardMarkdownDraft = useCallback(() => {
|
||||
if (!markdownDraftDirty) return true;
|
||||
const discard = window.confirm(
|
||||
"Discard the unapplied Markdown draft and leave this node?",
|
||||
);
|
||||
return discard;
|
||||
}, [markdownDraftDirty]);
|
||||
|
||||
|
||||
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
|
||||
if (nextViewMode !== viewMode && !confirmDiscardMarkdownDraft()) return;
|
||||
if (nextViewMode === "focused") {
|
||||
const resolution = resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph);
|
||||
if (!resolution.resolvedNodeId) {
|
||||
@@ -1682,6 +1704,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
));
|
||||
setViewMode("full");
|
||||
}, [
|
||||
confirmDiscardMarkdownDraft,
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
focusedNodeId,
|
||||
@@ -1692,9 +1715,11 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
lastGroupedSelectedNodeId,
|
||||
resolveNodeIdForFocusedMode,
|
||||
selectedNodeId,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const focusNode = useCallback((nodeId: string) => {
|
||||
if (nodeId !== selectedNodeId && !confirmDiscardMarkdownDraft()) return;
|
||||
if (!nodeId) {
|
||||
setSelectedNodeId("");
|
||||
setSelectedEdgeId("");
|
||||
@@ -1720,14 +1745,18 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
setFocusedNodeId(nextSelectedNodeId);
|
||||
setIsLayoutRunning(false);
|
||||
}
|
||||
}, [viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes
|
||||
}, [confirmDiscardMarkdownDraft, selectedNodeId, viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes
|
||||
|
||||
useEffect(() => {
|
||||
if (!externalFocusNodeId || externalFocusToken == null) return;
|
||||
if (lastExternalFocusTokenRef.current === externalFocusToken) return;
|
||||
if (!graphReady || !graph.hasNode(externalFocusNodeId)) return;
|
||||
|
||||
if (
|
||||
externalFocusNodeId !== selectedNodeId
|
||||
&& !confirmDiscardMarkdownDraft()
|
||||
) return;
|
||||
lastExternalFocusTokenRef.current = externalFocusToken;
|
||||
|
||||
// Set state directly instead of going through focusNode(), which captures
|
||||
// a stale viewMode in its closure. setViewMode is called first so the node
|
||||
// is visible in the full graph before the scene pans to it.
|
||||
@@ -1737,7 +1766,13 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
window.setTimeout(() => {
|
||||
sceneRef.current?.focusNode(externalFocusNodeId);
|
||||
}, 0);
|
||||
}, [externalFocusNodeId, externalFocusToken, graphReady]);
|
||||
}, [
|
||||
confirmDiscardMarkdownDraft,
|
||||
externalFocusNodeId,
|
||||
externalFocusToken,
|
||||
graphReady,
|
||||
selectedNodeId,
|
||||
]);
|
||||
|
||||
const handleEdgeSelect = useCallback((edgeId: string) => {
|
||||
setSelectedEdgeId(edgeId);
|
||||
@@ -1843,6 +1878,37 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
document.body.removeChild(anchor);
|
||||
}, [inspectableNodeId]);
|
||||
|
||||
const handleMarkdownApplied = useCallback((result: MarkdownApplyResult) => {
|
||||
if (result.resource.kind !== "context-node") return;
|
||||
if (!graph.hasNode(result.resource.id)) return;
|
||||
const syncGeneration = markdownRefreshGuard.begin(result.resource.id);
|
||||
const attributes = graph.getNodeAttributes(result.resource.id) as NodeAttributes;
|
||||
graph.mergeNodeAttributes(
|
||||
result.resource.id,
|
||||
buildNodeMarkdownAttributeUpdate(
|
||||
result.resource.id,
|
||||
result.body,
|
||||
attributes.properties ?? {},
|
||||
),
|
||||
);
|
||||
setGraphVersion((current) => current + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
|
||||
void readNodeMarkdownAttributeUpdate(result.resource.id)
|
||||
.then((savedAttributes) => {
|
||||
if (
|
||||
!markdownRefreshGuard.isCurrent(result.resource.id, syncGeneration)
|
||||
|| !graph.hasNode(result.resource.id)
|
||||
) return;
|
||||
graph.mergeNodeAttributes(result.resource.id, savedAttributes);
|
||||
setGraphVersion((current) => current + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
})
|
||||
.catch((syncError) => {
|
||||
console.error("[GraphWorkspace] applied node refresh failed", syncError);
|
||||
});
|
||||
}, [markdownRefreshGuard]);
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/graph-updates`);
|
||||
@@ -1873,6 +1939,25 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
setGraphVersion((current) => current + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
}
|
||||
if (eventType === "UPDATE_NODE" && payload?.id && graph.hasNode(payload.id)) {
|
||||
markdownRefreshGuard.invalidate(payload.id);
|
||||
const properties = payload.properties ?? {};
|
||||
const current = graph.getNodeAttributes(payload.id) as NodeAttributes;
|
||||
const content = typeof properties.content === "string" ? properties.content : "";
|
||||
graph.mergeNodeAttributes(payload.id, {
|
||||
...buildNodeMarkdownAttributeUpdate(payload.id, content, properties),
|
||||
nodeType: payload.type ?? current.nodeType,
|
||||
valid_from: properties.valid_from ?? null,
|
||||
valid_until: properties.valid_until ?? null,
|
||||
});
|
||||
logEvent(
|
||||
"update-node",
|
||||
`Updated node ${payload.id} via realtime ws`,
|
||||
{ nodeId: payload.id, nodeType: payload.type },
|
||||
);
|
||||
setGraphVersion((version) => version + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
}
|
||||
if (eventType === "ADD_EDGE") {
|
||||
const isSmallGraph = smallGraphModeRef.current;
|
||||
batchMergeEdges([
|
||||
@@ -1899,7 +1984,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
}, [markdownRefreshGuard]);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedNeighborhoodNodeIds([]);
|
||||
@@ -2172,28 +2257,29 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
}, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]);
|
||||
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
|
||||
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
|
||||
const displayResult = useMemo(
|
||||
() => (
|
||||
viewMode === "grouped"
|
||||
? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
}))
|
||||
: resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
})
|
||||
),
|
||||
[
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
groupedDisplayCandidate,
|
||||
structuralActivePath,
|
||||
structuralActivePathEdgeIds,
|
||||
structuralSelectedNodeId,
|
||||
viewMode,
|
||||
],
|
||||
);
|
||||
const displayResult = useMemo(() => {
|
||||
// The displayed graph is an aggregated clone. Rebuild it after domain
|
||||
// mutations so applied Markdown labels do not remain stale on the canvas.
|
||||
void graphVersion;
|
||||
return viewMode === "grouped"
|
||||
? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
}))
|
||||
: resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
});
|
||||
}, [
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
graphVersion,
|
||||
groupedDisplayCandidate,
|
||||
structuralActivePath,
|
||||
structuralActivePathEdgeIds,
|
||||
structuralSelectedNodeId,
|
||||
viewMode,
|
||||
]);
|
||||
const displayState = useMemo(
|
||||
() => (
|
||||
viewMode === "grouped"
|
||||
@@ -3295,6 +3381,8 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
pathResult={pathResult}
|
||||
onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
|
||||
onFocusNode={focusNode}
|
||||
onMarkdownApplied={handleMarkdownApplied}
|
||||
onMarkdownDirtyChange={handleMarkdownDirtyChange}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -1,125 +1,243 @@
|
||||
import { useState, useRef, useEffect, useMemo, type CSSProperties } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
Code2,
|
||||
Copy,
|
||||
Eye,
|
||||
ExternalLink,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
import type { MarkdownApplyResult } from "./markdownResourceClient";
|
||||
import type { MarkdownResourceRef } from "./markdownEditorState";
|
||||
import { isSafeUrl } from "./markdownUrlSafety";
|
||||
import { useMarkdownEditor } from "./useMarkdownEditor";
|
||||
|
||||
export interface MarkdownContentViewerProps {
|
||||
content?: string | null;
|
||||
resource?: MarkdownResourceRef;
|
||||
onApplied?: (result: MarkdownApplyResult) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
className?: string;
|
||||
defaultMode?: "preview" | "source";
|
||||
}
|
||||
|
||||
export function MarkdownContentViewer({
|
||||
content,
|
||||
resource,
|
||||
onApplied,
|
||||
onDirtyChange,
|
||||
className,
|
||||
defaultMode = "preview",
|
||||
}: MarkdownContentViewerProps) {
|
||||
const [activeMode, setActiveMode] = useState<"preview" | "source">(defaultMode);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const modeBeforeEditRef = useRef<"preview" | "source">(defaultMode);
|
||||
const resourceKey = resource ? `${resource.kind}:${resource.id}` : "";
|
||||
const [activeResourceKey, setActiveResourceKey] = useState(resourceKey);
|
||||
const editor = useMarkdownEditor({ resource, onApplied, onDirtyChange });
|
||||
const {
|
||||
session,
|
||||
error,
|
||||
dirty,
|
||||
editing,
|
||||
saving,
|
||||
loading,
|
||||
} = editor;
|
||||
|
||||
if (activeResourceKey !== resourceKey) {
|
||||
setActiveResourceKey(resourceKey);
|
||||
setCopied(false);
|
||||
setActiveMode(defaultMode);
|
||||
}
|
||||
|
||||
// Track the content value for which the copied indicator is valid.
|
||||
// When content changes (i.e. the user selects a different node), reset the
|
||||
// copied indicator inline during render rather than in a useEffect — this
|
||||
// avoids a cascading-render lint error and is the React-recommended pattern
|
||||
// for resetting derived visual state on prop changes.
|
||||
const [copiedForContent, setCopiedForContent] = useState<string | null | undefined>(content);
|
||||
if (copiedForContent !== content) {
|
||||
setCopiedForContent(content);
|
||||
if (copied) {
|
||||
// Clear the stale indicator synchronously so the new node's copy button
|
||||
// never shows "Copied" from the previous selection.
|
||||
setCopied(false);
|
||||
}
|
||||
if (copied) setCopied(false);
|
||||
}
|
||||
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Clean up any outstanding timeout on unmount.
|
||||
const copyTimeoutRef = useRef<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimeoutRef.current) {
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rawContent = typeof content === "string" ? content : "";
|
||||
const rawContent = editor.editing
|
||||
? editor.session?.draft ?? ""
|
||||
: (typeof content === "string" ? content : "");
|
||||
const previewContent = useMemo(() => {
|
||||
if (!editor.editing) return rawContent;
|
||||
const lines = rawContent.split(/\r?\n/);
|
||||
if (lines[0] !== "---") return rawContent;
|
||||
const closingIndex = lines.findIndex((line, index) => index > 0 && line === "---");
|
||||
return closingIndex < 0 ? rawContent : lines.slice(closingIndex + 1).join("\n").replace(/^\n/, "");
|
||||
}, [editor.editing, rawContent]);
|
||||
const hasContent = rawContent.trim().length > 0;
|
||||
|
||||
// react-markdown runs the whole remark pipeline synchronously inside its own
|
||||
// render, so without this memo every unrelated re-render of this component --
|
||||
// clicking Copy, toggling Preview/Source -- re-parses the entire document.
|
||||
// Measured at ~364ms per re-render for a 1000-row GFM table (issue #1118).
|
||||
// Keyed on rawContent so a genuine node change still re-parses exactly once.
|
||||
const renderedMarkdown = useMemo(
|
||||
() => (
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{rawContent}
|
||||
{previewContent}
|
||||
</ReactMarkdown>
|
||||
),
|
||||
[rawContent],
|
||||
[previewContent],
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!hasContent) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawContent);
|
||||
if (copyTimeoutRef.current) {
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
setCopied(true);
|
||||
copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard write unavailable
|
||||
// Clipboard write unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
modeBeforeEditRef.current = activeMode;
|
||||
setActiveMode("source");
|
||||
if (!await editor.beginEdit()) {
|
||||
setActiveMode(modeBeforeEditRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
editor.discard();
|
||||
setActiveMode(modeBeforeEditRef.current);
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
if (await editor.save()) {
|
||||
setActiveMode("preview");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} style={viewerContainerStyle}>
|
||||
<div style={viewerHeaderStyle}>
|
||||
<div style={{ display: "flex", gap: 4 }} role="tablist">
|
||||
<div style={{ display: "flex", gap: 4 }} role="tablist" aria-label="Markdown view">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeMode === "preview"}
|
||||
aria-controls="markdown-viewer-panel"
|
||||
onClick={() => setActiveMode("preview")}
|
||||
style={{ ...tabBtnStyle, ...(activeMode === "preview" ? activeTabBtnStyle : {}) }}
|
||||
>
|
||||
<Eye size={12} style={{ marginRight: 5 }} />
|
||||
<Eye size={12} style={{ marginRight: 5 }} aria-hidden="true" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeMode === "source"}
|
||||
aria-controls="markdown-viewer-panel"
|
||||
onClick={() => setActiveMode("source")}
|
||||
style={{ ...tabBtnStyle, ...(activeMode === "source" ? activeTabBtnStyle : {}) }}
|
||||
>
|
||||
<Code2 size={12} style={{ marginRight: 5 }} />
|
||||
<Code2 size={12} style={{ marginRight: 5 }} aria-hidden="true" />
|
||||
Source
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasContent && (
|
||||
<button type="button" onClick={() => void handleCopy()} style={copyBtnStyle} title="Copy raw content">
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={12} color="#3fb950" style={{ marginRight: 4 }} />
|
||||
<span style={{ color: "#3fb950", fontSize: 11 }}>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={12} style={{ marginRight: 4 }} />
|
||||
<span style={{ fontSize: 11 }}>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{hasContent && (
|
||||
<button type="button" onClick={() => void handleCopy()} style={copyBtnStyle} title="Copy raw content">
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={12} color="#3fb950" style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
<span style={{ color: "#3fb950", fontSize: 11 }}>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={12} style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
<span style={{ fontSize: 11 }}>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{resource && !editing && !loading ? (
|
||||
<button type="button" onClick={() => void handleEdit()} style={copyBtnStyle}>
|
||||
<Pencil size={12} style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
Edit
|
||||
</button>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<button type="button" disabled style={{ ...copyBtnStyle, opacity: 0.65 }}>
|
||||
<Loader2 size={12} className="animate-spin" style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
Loading…
|
||||
</button>
|
||||
) : null}
|
||||
{editing ? (
|
||||
<>
|
||||
<button type="button" onClick={handleCancel} disabled={saving} style={copyBtnStyle}>
|
||||
<X size={12} style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleApply()}
|
||||
disabled={saving || !dirty}
|
||||
title={!dirty ? "Make a change before applying" : undefined}
|
||||
style={{ ...saveBtnStyle, opacity: saving || !dirty ? 0.55 : 1 }}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={12} className="animate-spin" style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
) : (
|
||||
<Check size={12} style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
)}
|
||||
{saving ? "Applying…" : "Apply"}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={viewerBodyStyle}>
|
||||
{!hasContent ? (
|
||||
{error ? (
|
||||
<div id="markdown-editor-error" role="alert" style={errorStyle}>
|
||||
<span>{error.message}</span>
|
||||
{error.kind === "conflict" ? (
|
||||
<button type="button" onClick={() => void editor.reloadLatest()} style={errorActionStyle}>
|
||||
<RefreshCw size={12} style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
Reload latest
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
id="markdown-viewer-panel"
|
||||
role="tabpanel"
|
||||
aria-busy={saving || loading}
|
||||
style={viewerBodyStyle}
|
||||
>
|
||||
{activeMode === "source" && editing ? (
|
||||
<textarea
|
||||
aria-label="Markdown source"
|
||||
aria-describedby={error ? "markdown-editor-error" : undefined}
|
||||
aria-invalid={error?.kind === "validation" || undefined}
|
||||
value={session?.draft ?? ""}
|
||||
onChange={(event) => editor.changeDraft(event.target.value)}
|
||||
disabled={saving}
|
||||
spellCheck={false}
|
||||
style={editorStyle}
|
||||
/>
|
||||
) : !hasContent ? (
|
||||
<div style={emptyTextStyle}>No content available for this node.</div>
|
||||
) : activeMode === "source" ? (
|
||||
<pre style={sourcePreStyle}>
|
||||
@@ -238,6 +356,8 @@ const viewerHeaderStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
padding: "6px 10px",
|
||||
background: "rgba(0, 0, 0, 0.2)",
|
||||
borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
@@ -275,12 +395,56 @@ const copyBtnStyle: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const saveBtnStyle: CSSProperties = {
|
||||
...copyBtnStyle,
|
||||
background: GRAPH_THEME.ui.control.primaryBg,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.primaryBorder}`,
|
||||
color: GRAPH_THEME.ui.control.primaryText,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const errorStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
color: "#ffb4ad",
|
||||
background: "rgba(248, 81, 73, 0.1)",
|
||||
borderBottom: "1px solid rgba(248, 81, 73, 0.25)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const errorActionStyle: CSSProperties = {
|
||||
...copyBtnStyle,
|
||||
flexShrink: 0,
|
||||
color: "#ffb4ad",
|
||||
border: "1px solid rgba(248, 81, 73, 0.32)",
|
||||
};
|
||||
|
||||
const viewerBodyStyle: CSSProperties = {
|
||||
padding: 12,
|
||||
maxHeight: 380,
|
||||
overflowY: "auto",
|
||||
};
|
||||
|
||||
const editorStyle: CSSProperties = {
|
||||
display: "block",
|
||||
boxSizing: "border-box",
|
||||
width: "100%",
|
||||
minHeight: 280,
|
||||
resize: "vertical",
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
background: "rgba(0, 0, 0, 0.3)",
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 12,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
export type MarkdownResourceRef =
|
||||
| { kind: "context-node"; id: string }
|
||||
| { kind: "agent-memory"; id: string };
|
||||
|
||||
export type EditorStatus =
|
||||
| "viewing"
|
||||
| "loading-document"
|
||||
| "editing"
|
||||
| "saving"
|
||||
| "validation-error"
|
||||
| "save-error"
|
||||
| "conflict";
|
||||
|
||||
export interface MarkdownEditorError {
|
||||
kind: "validation" | "conflict" | "save" | "network";
|
||||
message: string;
|
||||
field?: string;
|
||||
currentRevision?: string;
|
||||
}
|
||||
|
||||
export interface MarkdownEditSession {
|
||||
resource: MarkdownResourceRef;
|
||||
baseSource: string;
|
||||
baseRevision: string;
|
||||
draft: string;
|
||||
status: EditorStatus;
|
||||
error: MarkdownEditorError | null;
|
||||
}
|
||||
|
||||
export interface MarkdownSavedDocument {
|
||||
source: string;
|
||||
revision: string;
|
||||
}
|
||||
|
||||
export function createLoadingSession(resource: MarkdownResourceRef): MarkdownEditSession {
|
||||
return {
|
||||
resource,
|
||||
baseSource: "",
|
||||
baseRevision: "",
|
||||
draft: "",
|
||||
status: "loading-document",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEditSession(
|
||||
resource: MarkdownResourceRef,
|
||||
document: MarkdownSavedDocument,
|
||||
): MarkdownEditSession {
|
||||
return {
|
||||
resource,
|
||||
baseSource: document.source,
|
||||
baseRevision: document.revision,
|
||||
draft: document.source,
|
||||
status: "editing",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateDraft(
|
||||
session: MarkdownEditSession,
|
||||
draft: string,
|
||||
): MarkdownEditSession {
|
||||
return {
|
||||
...session,
|
||||
draft,
|
||||
status: "editing",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isDirty(session: MarkdownEditSession | null): boolean {
|
||||
return session !== null && session.draft !== session.baseSource;
|
||||
}
|
||||
|
||||
export function saveStarted(session: MarkdownEditSession): MarkdownEditSession {
|
||||
if (!isDirty(session)) return session;
|
||||
return { ...session, status: "saving", error: null };
|
||||
}
|
||||
|
||||
export function saveSucceeded(
|
||||
session: MarkdownEditSession,
|
||||
document: MarkdownSavedDocument,
|
||||
): MarkdownEditSession {
|
||||
return {
|
||||
...session,
|
||||
baseSource: document.source,
|
||||
baseRevision: document.revision,
|
||||
draft: document.source,
|
||||
status: "viewing",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function saveFailed(
|
||||
session: MarkdownEditSession,
|
||||
error: MarkdownEditorError,
|
||||
): MarkdownEditSession {
|
||||
const status: EditorStatus =
|
||||
error.kind === "validation"
|
||||
? "validation-error"
|
||||
: error.kind === "conflict"
|
||||
? "conflict"
|
||||
: "save-error";
|
||||
return { ...session, status, error };
|
||||
}
|
||||
|
||||
export function cancelEdit(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldConfirmDiscard(session: MarkdownEditSession | null): boolean {
|
||||
return isDirty(session) && session?.status !== "saving";
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type {
|
||||
MarkdownEditorError,
|
||||
MarkdownResourceRef,
|
||||
} from "./markdownEditorState";
|
||||
|
||||
export interface MarkdownDocument {
|
||||
resource: MarkdownResourceRef;
|
||||
source: string;
|
||||
body: string;
|
||||
revision: string;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export interface MarkdownApplyResult extends MarkdownDocument {
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
type ErrorDetail = {
|
||||
code?: string;
|
||||
message?: string;
|
||||
field?: string;
|
||||
current_revision?: string;
|
||||
};
|
||||
|
||||
export class MarkdownClientError extends Error implements MarkdownEditorError {
|
||||
readonly kind: MarkdownEditorError["kind"];
|
||||
readonly field?: string;
|
||||
readonly currentRevision?: string;
|
||||
|
||||
constructor(error: MarkdownEditorError) {
|
||||
super(error.message);
|
||||
this.name = "MarkdownClientError";
|
||||
this.kind = error.kind;
|
||||
this.field = error.field;
|
||||
this.currentRevision = error.currentRevision;
|
||||
}
|
||||
}
|
||||
|
||||
function resourceUrl(ref: MarkdownResourceRef): string {
|
||||
return `/api/markdown/${ref.kind}/${encodeURIComponent(ref.id)}`;
|
||||
}
|
||||
|
||||
async function responseError(response: Response): Promise<MarkdownClientError> {
|
||||
let detail: ErrorDetail = {};
|
||||
try {
|
||||
const payload = (await response.json()) as { detail?: ErrorDetail };
|
||||
if (payload.detail && typeof payload.detail === "object") {
|
||||
detail = payload.detail;
|
||||
}
|
||||
} catch {
|
||||
// A non-JSON response is mapped from its status below.
|
||||
}
|
||||
|
||||
const kind: MarkdownEditorError["kind"] =
|
||||
response.status === 422
|
||||
? "validation"
|
||||
: response.status === 409
|
||||
? "conflict"
|
||||
: "save";
|
||||
return new MarkdownClientError({
|
||||
kind,
|
||||
message: detail.message || `Markdown request failed (${response.status}).`,
|
||||
field: detail.field,
|
||||
currentRevision: detail.current_revision,
|
||||
});
|
||||
}
|
||||
|
||||
async function request<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(input, init);
|
||||
if (!response.ok) {
|
||||
throw await responseError(response);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof MarkdownClientError) throw error;
|
||||
throw new MarkdownClientError({
|
||||
kind: "network",
|
||||
message: "The Markdown service could not be reached. Your draft was kept.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function readMarkdownResource(
|
||||
ref: MarkdownResourceRef,
|
||||
): Promise<MarkdownDocument> {
|
||||
return request<MarkdownDocument>(resourceUrl(ref));
|
||||
}
|
||||
|
||||
export function applyMarkdownResource(
|
||||
ref: MarkdownResourceRef,
|
||||
markdown: string,
|
||||
expectedRevision: string,
|
||||
): Promise<MarkdownApplyResult> {
|
||||
return request<MarkdownApplyResult>(resourceUrl(ref), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
markdown,
|
||||
expected_revision: expectedRevision,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface NodeMarkdownAttributeUpdate {
|
||||
label: string;
|
||||
content: string;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GraphNodeMarkdownSnapshot {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
properties: Record<string, unknown>;
|
||||
valid_from: string | null;
|
||||
valid_until: string | null;
|
||||
}
|
||||
|
||||
export interface SavedNodeMarkdownAttributeUpdate extends NodeMarkdownAttributeUpdate {
|
||||
nodeType: string;
|
||||
valid_from: string | null;
|
||||
valid_until: string | null;
|
||||
}
|
||||
|
||||
export class NodeMarkdownRefreshGuard {
|
||||
private readonly generations = new Map<string, number>();
|
||||
|
||||
begin(nodeId: string): number {
|
||||
const generation = (this.generations.get(nodeId) ?? 0) + 1;
|
||||
this.generations.set(nodeId, generation);
|
||||
return generation;
|
||||
}
|
||||
|
||||
invalidate(nodeId: string): void {
|
||||
this.begin(nodeId);
|
||||
}
|
||||
|
||||
isCurrent(nodeId: string, generation: number): boolean {
|
||||
return this.generations.get(nodeId) === generation;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNodeMarkdownAttributeUpdate(
|
||||
nodeId: string,
|
||||
content: string,
|
||||
properties: Record<string, unknown>,
|
||||
): NodeMarkdownAttributeUpdate {
|
||||
return {
|
||||
label: content || nodeId,
|
||||
content,
|
||||
properties: {
|
||||
...properties,
|
||||
content,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readNodeMarkdownAttributeUpdate(
|
||||
nodeId: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<SavedNodeMarkdownAttributeUpdate> {
|
||||
const response = await fetcher(
|
||||
`/api/graph/node?node_id=${encodeURIComponent(nodeId)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Graph node refresh failed (${response.status}).`);
|
||||
}
|
||||
|
||||
const node = await response.json() as GraphNodeMarkdownSnapshot;
|
||||
if (node.id !== nodeId) {
|
||||
throw new Error("Graph node refresh returned a different resource.");
|
||||
}
|
||||
|
||||
return {
|
||||
...buildNodeMarkdownAttributeUpdate(
|
||||
node.id,
|
||||
node.content,
|
||||
node.properties ?? {},
|
||||
),
|
||||
nodeType: node.type,
|
||||
valid_from: node.valid_from ?? null,
|
||||
valid_until: node.valid_until ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
MarkdownClientError,
|
||||
applyMarkdownResource,
|
||||
readMarkdownResource,
|
||||
type MarkdownApplyResult,
|
||||
} from "./markdownResourceClient";
|
||||
import {
|
||||
cancelEdit,
|
||||
createEditSession,
|
||||
createLoadingSession,
|
||||
isDirty,
|
||||
saveFailed,
|
||||
saveStarted,
|
||||
saveSucceeded,
|
||||
updateDraft,
|
||||
type MarkdownEditorError,
|
||||
type MarkdownEditSession,
|
||||
type MarkdownResourceRef,
|
||||
} from "./markdownEditorState";
|
||||
|
||||
interface MarkdownEditorOptions {
|
||||
resource?: MarkdownResourceRef;
|
||||
onApplied?: (result: MarkdownApplyResult) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
interface KeyedError {
|
||||
resourceKey: string;
|
||||
error: MarkdownEditorError;
|
||||
}
|
||||
|
||||
function keyOf(resource?: MarkdownResourceRef): string {
|
||||
return resource ? `${resource.kind}:${resource.id}` : "";
|
||||
}
|
||||
|
||||
function normalizeError(failure: unknown): MarkdownEditorError {
|
||||
if (failure instanceof MarkdownClientError) return failure;
|
||||
return {
|
||||
kind: "network",
|
||||
message: "The Markdown service could not be reached. Your draft was kept.",
|
||||
};
|
||||
}
|
||||
|
||||
export function useMarkdownEditor({
|
||||
resource,
|
||||
onApplied,
|
||||
onDirtyChange,
|
||||
}: MarkdownEditorOptions) {
|
||||
const resourceKey = keyOf(resource);
|
||||
const [session, setSession] = useState<MarkdownEditSession | null>(null);
|
||||
const [viewError, setViewError] = useState<KeyedError | null>(null);
|
||||
const [renderedResourceKey, setRenderedResourceKey] = useState(resourceKey);
|
||||
const loadGenerationRef = useRef(0);
|
||||
|
||||
if (renderedResourceKey !== resourceKey) {
|
||||
setRenderedResourceKey(resourceKey);
|
||||
setSession(null);
|
||||
setViewError(null);
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
loadGenerationRef.current += 1;
|
||||
}, [resourceKey]);
|
||||
|
||||
const activeSession = session && keyOf(session.resource) === resourceKey
|
||||
? session
|
||||
: null;
|
||||
const dirty = isDirty(activeSession);
|
||||
const editing = activeSession !== null
|
||||
&& activeSession.status !== "viewing"
|
||||
&& activeSession.status !== "loading-document";
|
||||
const saving = activeSession?.status === "saving";
|
||||
const loading = activeSession?.status === "loading-document";
|
||||
const error = activeSession?.error
|
||||
?? (viewError?.resourceKey === resourceKey ? viewError.error : null);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
return () => {
|
||||
if (dirty) onDirtyChange?.(false);
|
||||
};
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const protectDraft = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", protectDraft);
|
||||
return () => window.removeEventListener("beforeunload", protectDraft);
|
||||
}, [dirty]);
|
||||
|
||||
const beginEdit = useCallback(async () => {
|
||||
if (!resource) return false;
|
||||
const loadGeneration = ++loadGenerationRef.current;
|
||||
setViewError(null);
|
||||
setSession(createLoadingSession(resource));
|
||||
try {
|
||||
const document = await readMarkdownResource(resource);
|
||||
if (loadGeneration !== loadGenerationRef.current) return false;
|
||||
setSession(createEditSession(resource, document));
|
||||
return true;
|
||||
} catch (failure) {
|
||||
if (loadGeneration !== loadGenerationRef.current) return false;
|
||||
setSession(null);
|
||||
setViewError({ resourceKey, error: normalizeError(failure) });
|
||||
return false;
|
||||
}
|
||||
}, [resource, resourceKey]);
|
||||
|
||||
const discard = useCallback(() => {
|
||||
if (!activeSession || saving) return;
|
||||
setSession(cancelEdit());
|
||||
setViewError(null);
|
||||
}, [activeSession, saving]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!activeSession || saving || !dirty) return false;
|
||||
const resourceGeneration = loadGenerationRef.current;
|
||||
const pending = saveStarted(activeSession);
|
||||
setSession(pending);
|
||||
try {
|
||||
const result = await applyMarkdownResource(
|
||||
pending.resource,
|
||||
pending.draft,
|
||||
pending.baseRevision,
|
||||
);
|
||||
if (resourceGeneration !== loadGenerationRef.current) return false;
|
||||
setSession(saveSucceeded(pending, result));
|
||||
onApplied?.(result);
|
||||
return true;
|
||||
} catch (failure) {
|
||||
if (resourceGeneration !== loadGenerationRef.current) return false;
|
||||
setSession(saveFailed(pending, normalizeError(failure)));
|
||||
return false;
|
||||
}
|
||||
}, [activeSession, dirty, onApplied, saving]);
|
||||
|
||||
const reloadLatest = useCallback(async () => {
|
||||
if (!resource || saving) return;
|
||||
if (
|
||||
dirty
|
||||
&& !window.confirm("Discard this draft and reload the latest applied version?")
|
||||
) return;
|
||||
await beginEdit();
|
||||
}, [beginEdit, dirty, resource, saving]);
|
||||
|
||||
const changeDraft = useCallback((draft: string) => {
|
||||
setSession((current) => (
|
||||
current && keyOf(current.resource) === resourceKey
|
||||
? updateDraft(current, draft)
|
||||
: current
|
||||
));
|
||||
}, [resourceKey]);
|
||||
|
||||
return {
|
||||
session: activeSession,
|
||||
error,
|
||||
dirty,
|
||||
editing,
|
||||
saving,
|
||||
loading,
|
||||
beginEdit,
|
||||
discard,
|
||||
save,
|
||||
reloadLatest,
|
||||
changeDraft,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { Brain, RefreshCw } from "lucide-react";
|
||||
|
||||
import { MarkdownContentViewer } from "./GraphWorkspace/MarkdownContentViewer";
|
||||
import {
|
||||
readMarkdownResource,
|
||||
type MarkdownApplyResult,
|
||||
} from "./GraphWorkspace/markdownResourceClient";
|
||||
import { GRAPH_THEME } from "./GraphWorkspace/graphTheme";
|
||||
|
||||
interface MemorySummary {
|
||||
id: string;
|
||||
type: string;
|
||||
excerpt: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface MemoryListResponse {
|
||||
items: MemorySummary[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
}
|
||||
interface MemoryWorkspaceProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const MEMORY_PAGE_SIZE = 100;
|
||||
|
||||
|
||||
function responseMessage(payload: unknown, fallback: string): string {
|
||||
if (!payload || typeof payload !== "object" || !("detail" in payload)) {
|
||||
return fallback;
|
||||
}
|
||||
return typeof payload.detail === "string" ? payload.detail : fallback;
|
||||
}
|
||||
|
||||
|
||||
async function fetchMemoryList(skip = 0): Promise<MemoryListResponse> {
|
||||
const response = await fetch(`/api/memories?skip=${skip}&limit=${MEMORY_PAGE_SIZE}`);
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(responseMessage(payload, `Memory list failed (${response.status}).`));
|
||||
}
|
||||
return response.json() as Promise<MemoryListResponse>;
|
||||
}
|
||||
|
||||
async function fetchLoadedMemoryPages(endOffset: number): Promise<{
|
||||
items: MemorySummary[];
|
||||
total: number;
|
||||
nextOffset: number;
|
||||
}> {
|
||||
const items: MemorySummary[] = [];
|
||||
let total = 0;
|
||||
let nextOffset = 0;
|
||||
const targetOffset = Math.max(endOffset, MEMORY_PAGE_SIZE);
|
||||
|
||||
while (nextOffset < targetOffset) {
|
||||
const payload = await fetchMemoryList(nextOffset);
|
||||
items.push(...payload.items);
|
||||
total = payload.total;
|
||||
nextOffset = payload.skip + payload.items.length;
|
||||
if (payload.items.length === 0 || nextOffset >= total) break;
|
||||
}
|
||||
|
||||
return { items, total, nextOffset };
|
||||
}
|
||||
|
||||
export function MemoryWorkspace({ onDirtyChange }: MemoryWorkspaceProps = {}) {
|
||||
const [items, setItems] = useState<MemorySummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [nextOffset, setNextOffset] = useState(0);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [selectedBody, setSelectedBody] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const listGenerationRef = useRef(0);
|
||||
const selectionGenerationRef = useRef(0);
|
||||
const handleDirtyChange = useCallback((nextDirty: boolean) => {
|
||||
setDirty(nextDirty);
|
||||
onDirtyChange?.(nextDirty);
|
||||
}, [onDirtyChange]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const listGeneration = ++listGenerationRef.current;
|
||||
const selectionGeneration = ++selectionGenerationRef.current;
|
||||
const isCurrent = () => (
|
||||
!cancelled
|
||||
&& listGeneration === listGenerationRef.current
|
||||
&& selectionGeneration === selectionGenerationRef.current
|
||||
);
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setLoadingMore(false);
|
||||
setError("");
|
||||
try {
|
||||
const payload = await fetchMemoryList();
|
||||
if (!isCurrent()) return;
|
||||
setItems(payload.items);
|
||||
setTotal(payload.total);
|
||||
setNextOffset(payload.skip + payload.items.length);
|
||||
const first = payload.items[0];
|
||||
if (!first) {
|
||||
setSelectedId("");
|
||||
setSelectedBody("");
|
||||
return;
|
||||
}
|
||||
const document = await readMarkdownResource({
|
||||
kind: "agent-memory",
|
||||
id: first.id,
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
setSelectedId(first.id);
|
||||
setSelectedBody(document.body);
|
||||
} catch (failure) {
|
||||
if (isCurrent()) {
|
||||
setError(failure instanceof Error ? failure.message : "Memories could not be loaded.");
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
listGenerationRef.current += 1;
|
||||
selectionGenerationRef.current += 1;
|
||||
};
|
||||
}, [reloadToken]);
|
||||
|
||||
const selectMemory = async (memoryId: string) => {
|
||||
if (memoryId === selectedId) return;
|
||||
if (dirty && !window.confirm("Discard the unapplied Markdown draft and open another memory?")) return;
|
||||
const selectionGeneration = ++selectionGenerationRef.current;
|
||||
// Do NOT call handleDirtyChange(false) here: the editor's own onDirtyChange
|
||||
// callback fires automatically when MarkdownContentViewer re-renders with
|
||||
// the new resource prop and its session is cleared.
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const document = await readMarkdownResource({
|
||||
kind: "agent-memory",
|
||||
id: memoryId,
|
||||
});
|
||||
if (selectionGeneration !== selectionGenerationRef.current) return;
|
||||
setSelectedId(memoryId);
|
||||
setSelectedBody(document.body);
|
||||
} catch (failure) {
|
||||
if (selectionGeneration === selectionGenerationRef.current) {
|
||||
setError(failure instanceof Error ? failure.message : "The memory could not be loaded.");
|
||||
}
|
||||
} finally {
|
||||
if (selectionGeneration === selectionGenerationRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreMemories = useCallback(async () => {
|
||||
if (loadingMore || nextOffset >= total) return;
|
||||
const listGeneration = ++listGenerationRef.current;
|
||||
setLoadingMore(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = await fetchMemoryList(nextOffset);
|
||||
if (listGeneration !== listGenerationRef.current) return;
|
||||
setItems((current) => {
|
||||
const knownIds = new Set(current.map((item) => item.id));
|
||||
return [
|
||||
...current,
|
||||
...payload.items.filter((item) => !knownIds.has(item.id)),
|
||||
];
|
||||
});
|
||||
setTotal(payload.total);
|
||||
setNextOffset(payload.skip + payload.items.length);
|
||||
} catch (failure) {
|
||||
if (listGeneration === listGenerationRef.current) {
|
||||
setError(failure instanceof Error ? failure.message : "More memories could not be loaded.");
|
||||
}
|
||||
} finally {
|
||||
if (listGeneration === listGenerationRef.current) {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [loadingMore, nextOffset, total]);
|
||||
|
||||
const refreshMemorySummaries = useCallback(async () => {
|
||||
const listGeneration = ++listGenerationRef.current;
|
||||
try {
|
||||
const payload = await fetchLoadedMemoryPages(nextOffset);
|
||||
if (listGeneration !== listGenerationRef.current) return;
|
||||
setItems(payload.items);
|
||||
setTotal(payload.total);
|
||||
setNextOffset(payload.nextOffset);
|
||||
} catch {
|
||||
if (listGeneration === listGenerationRef.current) {
|
||||
setError("Memory was applied, but its summary could not be refreshed.");
|
||||
}
|
||||
} finally {
|
||||
if (listGeneration === listGenerationRef.current) {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [nextOffset]);
|
||||
|
||||
const applyMemory = useCallback((result: MarkdownApplyResult) => {
|
||||
setSelectedBody(result.body);
|
||||
handleDirtyChange(false);
|
||||
setItems((current) => current.map((item) => (
|
||||
item.id === result.resource.id
|
||||
? { ...item, excerpt: result.body.replace(/\s+/g, " ").slice(0, 160) }
|
||||
: item
|
||||
)));
|
||||
void refreshMemorySummaries();
|
||||
}, [handleDirtyChange, refreshMemorySummaries]);
|
||||
|
||||
return (
|
||||
<div style={workspaceStyle}>
|
||||
<aside style={listPanelStyle} aria-label="Agent memories">
|
||||
<div style={listHeaderStyle}>
|
||||
<div>
|
||||
<div style={listTitleStyle}>AgentMemory</div>
|
||||
<div style={listCountStyle}>{items.length} of {total} loaded</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Refresh memories"
|
||||
onClick={() => setReloadToken((value) => value + 1)}
|
||||
disabled={loading || loadingMore || dirty}
|
||||
title={dirty ? "Apply or cancel the current draft before refreshing" : "Refresh memories"}
|
||||
style={{ ...iconButtonStyle, opacity: loading || loadingMore || dirty ? 0.55 : 1 }}
|
||||
>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={memoryListStyle}>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
onClick={() => void selectMemory(item.id)}
|
||||
aria-current={item.id === selectedId ? "true" : undefined}
|
||||
style={{
|
||||
...memoryButtonStyle,
|
||||
...(item.id === selectedId ? selectedMemoryButtonStyle : {}),
|
||||
}}
|
||||
>
|
||||
<span style={memoryTypeStyle}>{item.type}</span>
|
||||
<span style={memoryIdStyle}>{item.id}</span>
|
||||
<span style={memoryExcerptStyle}>{item.excerpt || "Empty memory"}</span>
|
||||
</button>
|
||||
))}
|
||||
{nextOffset < total ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Load more memories"
|
||||
onClick={() => void loadMoreMemories()}
|
||||
disabled={loading || loadingMore}
|
||||
style={{ ...retryButtonStyle, opacity: loading || loadingMore ? 0.55 : 1 }}
|
||||
>
|
||||
{loadingMore ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
) : null}
|
||||
{!loading && items.length === 0 ? (
|
||||
<div style={emptyStyle}>
|
||||
<Brain size={22} aria-hidden="true" />
|
||||
<span>No AgentMemory items are available.</span>
|
||||
<button type="button" onClick={() => setReloadToken((value) => value + 1)} style={retryButtonStyle}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main style={editorPanelStyle}>
|
||||
{error ? <div role="alert" style={alertStyle}>{error}</div> : null}
|
||||
{loading ? (
|
||||
<div role="status" style={emptyStyle}>Loading memories…</div>
|
||||
) : selectedId ? (
|
||||
<>
|
||||
<div style={selectionHeaderStyle}>
|
||||
<span style={selectionLabelStyle}>Selected memory</span>
|
||||
<strong style={selectionIdStyle}>{selectedId}</strong>
|
||||
</div>
|
||||
<MarkdownContentViewer
|
||||
content={selectedBody}
|
||||
resource={{ kind: "agent-memory", id: selectedId }}
|
||||
onApplied={applyMemory}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceStyle: CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(220px, 300px) minmax(0, 1fr)",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
background: GRAPH_THEME.ui.surface.stage,
|
||||
};
|
||||
|
||||
const listPanelStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
background: GRAPH_THEME.ui.surface.panel,
|
||||
};
|
||||
|
||||
const listHeaderStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
};
|
||||
|
||||
const listTitleStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const listCountStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 11,
|
||||
marginTop: 3,
|
||||
};
|
||||
|
||||
const iconButtonStyle: CSSProperties = {
|
||||
display: "inline-grid",
|
||||
placeItems: "center",
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
color: GRAPH_THEME.ui.text.body,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const memoryListStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
minHeight: 0,
|
||||
padding: 10,
|
||||
overflowY: "auto",
|
||||
};
|
||||
|
||||
const memoryButtonStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 5,
|
||||
padding: 10,
|
||||
borderRadius: 9,
|
||||
border: "1px solid transparent",
|
||||
background: "transparent",
|
||||
color: GRAPH_THEME.ui.text.body,
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const selectedMemoryButtonStyle: CSSProperties = {
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
background: GRAPH_THEME.ui.timeline.playheadSoft,
|
||||
};
|
||||
|
||||
const memoryTypeStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.timeline.playhead,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const memoryIdStyle: CSSProperties = {
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 12,
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const memoryExcerptStyle: CSSProperties = {
|
||||
display: "-webkit-box",
|
||||
overflow: "hidden",
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 2,
|
||||
};
|
||||
|
||||
const editorPanelStyle: CSSProperties = {
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
padding: 20,
|
||||
overflowY: "auto",
|
||||
};
|
||||
|
||||
const selectionHeaderStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
marginBottom: 12,
|
||||
};
|
||||
|
||||
const selectionLabelStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
const selectionIdStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 14,
|
||||
wordBreak: "break-all",
|
||||
};
|
||||
|
||||
const emptyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
minHeight: 160,
|
||||
padding: 20,
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 12,
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
const retryButtonStyle: CSSProperties = {
|
||||
padding: "6px 10px",
|
||||
borderRadius: 7,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
background: GRAPH_THEME.ui.timeline.playheadSoft,
|
||||
color: GRAPH_THEME.ui.timeline.playhead,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const alertStyle: CSSProperties = {
|
||||
marginBottom: 12,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(248, 81, 73, 0.28)",
|
||||
background: "rgba(248, 81, 73, 0.1)",
|
||||
color: "#ffb4ad",
|
||||
fontSize: 12,
|
||||
};
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
MarkerType,
|
||||
Handle,
|
||||
Position,
|
||||
} from "@xyflow/react";
|
||||
import type { Connection, Edge, Node } from "@xyflow/react";
|
||||
import type { Connection, Edge, Node, ReactFlowInstance } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
Plus,
|
||||
@@ -22,10 +24,20 @@ import {
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { loadOntologyEntityOwner, loadOntologyGraph } from "./api";
|
||||
import type { OntologyGraphEdge, OntologyGraphNode } from "./api";
|
||||
import {
|
||||
classifyNodeType,
|
||||
inferOntologyUri,
|
||||
isEditableEntityType,
|
||||
ONTOLOGY_MINIMAP_THEME,
|
||||
} from "./ontologyEditorModel";
|
||||
import type { EditorEntityType, RegistryEntry } from "./ontologyEditorModel";
|
||||
|
||||
type OntologyNodeData = {
|
||||
label?: string;
|
||||
type?: string;
|
||||
entityType?: EditorEntityType;
|
||||
};
|
||||
|
||||
type OntologyNode = Node<OntologyNodeData>;
|
||||
@@ -34,12 +46,57 @@ type OntologyEdge = Edge<Record<string, unknown>>;
|
||||
const nodeTypes = {
|
||||
classNode: ({ data }: { data: OntologyNodeData }) => (
|
||||
<div style={classNodeStyle}>
|
||||
<Handle type="target" position={Position.Left} style={handleStyle} />
|
||||
<div style={classNodeHeader}>{data.label}</div>
|
||||
<div style={classNodeSub}>{data.type}</div>
|
||||
<Handle type="source" position={Position.Right} style={handleStyle} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const handleStyle: React.CSSProperties = {
|
||||
width: 8,
|
||||
height: 8,
|
||||
border: "1px solid rgba(235, 243, 255, 0.8)",
|
||||
background: "#4aa3ff",
|
||||
};
|
||||
|
||||
const ontologyFlowThemeCss = `
|
||||
.ontology-editor-flow .react-flow__controls {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(127, 208, 255, 0.2);
|
||||
border-radius: 9px;
|
||||
background: rgba(6, 13, 26, 0.96);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.ontology-editor-flow .react-flow__controls-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: transparent;
|
||||
border-bottom-color: rgba(127, 208, 255, 0.14);
|
||||
color: #8fa8c6;
|
||||
transition: color 140ms ease, background 140ms ease;
|
||||
}
|
||||
|
||||
.ontology-editor-flow .react-flow__controls-button:hover {
|
||||
background: rgba(74, 163, 255, 0.14);
|
||||
color: #ebf3ff;
|
||||
}
|
||||
|
||||
.ontology-editor-flow .react-flow__controls-button:focus-visible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
outline: 2px solid #7fd0ff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.ontology-editor-flow .react-flow__controls-button:disabled {
|
||||
background: rgba(3, 9, 18, 0.32);
|
||||
color: #40566f;
|
||||
}
|
||||
`;
|
||||
|
||||
const classNodeStyle: React.CSSProperties = {
|
||||
padding: "12px 16px",
|
||||
borderRadius: "8px",
|
||||
@@ -79,17 +136,95 @@ interface DraftDiff {
|
||||
annotation_changes: Record<string, Record<string, any>>;
|
||||
}
|
||||
|
||||
interface RegistryEntry {
|
||||
uri: string;
|
||||
name: string;
|
||||
function requestedEntityUri(): string {
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get("ontologyEntity") || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function nodeLabel(node: OntologyGraphNode): string {
|
||||
const explicit = String(node.content || node.properties?.["rdfs:label"] || "").trim();
|
||||
if (explicit && explicit !== node.id) {
|
||||
return explicit;
|
||||
}
|
||||
const trimmed = node.id.replace(/[/#]+$/, "");
|
||||
return trimmed.split("#").pop() || trimmed.split("/").pop() || node.id;
|
||||
}
|
||||
|
||||
function classifyEditorNode(node: OntologyGraphNode): OntologyNodeData["entityType"] {
|
||||
return classifyNodeType(node.type);
|
||||
}
|
||||
|
||||
function layoutEditorNodes(inputNodes: OntologyNode[]): OntologyNode[] {
|
||||
const properties = inputNodes.filter((node) => node.data.entityType === "property");
|
||||
const targets = inputNodes.filter((node) => (
|
||||
node.data.entityType === "class" || node.data.entityType === "external"
|
||||
));
|
||||
const context = inputNodes.filter((node) => (
|
||||
node.data.entityType !== "property"
|
||||
&& node.data.entityType !== "class"
|
||||
&& node.data.entityType !== "external"
|
||||
));
|
||||
const height = Math.max(360, Math.max(properties.length, targets.length) * 180);
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
properties.forEach((node, index) => {
|
||||
positions.set(node.id, { x: 0, y: ((index + 1) * height) / (properties.length + 1) });
|
||||
});
|
||||
targets.forEach((node, index) => {
|
||||
positions.set(node.id, { x: 600, y: ((index + 1) * height) / (targets.length + 1) });
|
||||
});
|
||||
context.forEach((node, index) => {
|
||||
positions.set(node.id, { x: 300 + index * 220, y: height + 120 });
|
||||
});
|
||||
|
||||
return inputNodes.map((node) => ({
|
||||
...node,
|
||||
position: positions.get(node.id) || node.position,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildEditorElements(apiNodes: OntologyGraphNode[], apiEdges: OntologyGraphEdge[]) {
|
||||
const sortedNodes = [...apiNodes].sort((left, right) => {
|
||||
const typeDelta = left.type.localeCompare(right.type);
|
||||
return typeDelta || left.id.localeCompare(right.id);
|
||||
});
|
||||
const nodes = layoutEditorNodes(sortedNodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: "classNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: nodeLabel(node),
|
||||
type: node.type,
|
||||
entityType: classifyEditorNode(node),
|
||||
},
|
||||
})));
|
||||
const edges: OntologyEdge[] = apiEdges.map((edge, index) => ({
|
||||
id: edge.id || `${edge.source}:${edge.type}:${edge.target}:${index}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: edge.type,
|
||||
type: "default",
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
style: { stroke: "rgba(127, 208, 255, 0.72)", strokeWidth: 1.5 },
|
||||
labelStyle: { fill: "#c8dcf5", fontSize: 11, fontWeight: 600 },
|
||||
labelBgStyle: { fill: "#07111f", fillOpacity: 0.9 },
|
||||
}));
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export function OntologyEditor() {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<OntologyNode>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<OntologyEdge>([]);
|
||||
const [selectedElement, setSelectedElement] = useState<OntologyNode | OntologyEdge | null>(null);
|
||||
const hasDetailPanel = selectedElement !== null;
|
||||
const [registry, setRegistry] = useState<RegistryEntry[]>([]);
|
||||
const [ontologyUri, setOntologyUri] = useState<string>("");
|
||||
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<OntologyNode, OntologyEdge> | null>(null);
|
||||
const [isLoadingGraph, setIsLoadingGraph] = useState(false);
|
||||
const [graphError, setGraphError] = useState("");
|
||||
const [draftDiff, setDraftDiff] = useState<DraftDiff>({
|
||||
added_classes: [],
|
||||
removed_classes: [],
|
||||
@@ -108,12 +243,18 @@ export function OntologyEditor() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/ontology/registry")
|
||||
.then((response) => (response.ok ? response.json() : []))
|
||||
.then((entries: RegistryEntry[]) => {
|
||||
const requested = requestedEntityUri();
|
||||
Promise.all([
|
||||
fetch("/api/ontology/registry").then((response) => (response.ok ? response.json() : [])),
|
||||
requested
|
||||
? loadOntologyEntityOwner(requested).catch(() => undefined)
|
||||
: Promise.resolve(undefined),
|
||||
])
|
||||
.then(([entries, explicitOwner]: [RegistryEntry[], string | undefined]) => {
|
||||
if (cancelled) return;
|
||||
setRegistry(entries);
|
||||
setOntologyUri((current) => current || entries[0]?.uri || "");
|
||||
const inferredOntology = inferOntologyUri(entries, requested, explicitOwner);
|
||||
setOntologyUri((current) => current || inferredOntology || entries[0]?.uri || "");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load ontology registry:", error);
|
||||
@@ -123,6 +264,47 @@ export function OntologyEditor() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ontologyUri) {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
setSelectedElement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setIsLoadingGraph(true);
|
||||
setGraphError("");
|
||||
loadOntologyGraph(ontologyUri, controller.signal)
|
||||
.then((payload) => {
|
||||
const elements = buildEditorElements(payload.nodes, payload.edges);
|
||||
setNodes(elements.nodes);
|
||||
setEdges(elements.edges);
|
||||
const requested = requestedEntityUri();
|
||||
setSelectedElement(elements.nodes.find((node) => node.id === requested) || null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
setSelectedElement(null);
|
||||
setGraphError(error instanceof Error ? error.message : "Failed to load ontology graph");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setIsLoadingGraph(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [ontologyUri, setEdges, setNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!flowInstance || nodes.length === 0) return;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
void flowInstance.fitView({ padding: 0.22, duration: 320, maxZoom: 1.25 });
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [flowInstance, hasDetailPanel, nodes.length, ontologyUri]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => setEdges((eds) => addEdge({ ...params, markerEnd: { type: MarkerType.ArrowClosed } }, eds)),
|
||||
[setEdges]
|
||||
@@ -134,7 +316,7 @@ export function OntologyEditor() {
|
||||
id: newId,
|
||||
type: "classNode",
|
||||
position: { x: Math.random() * 400, y: Math.random() * 300 },
|
||||
data: { label: "NewClass", type: "owl:Class" },
|
||||
data: { label: "NewClass", type: "owl:Class", entityType: "class" },
|
||||
};
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
setDraftDiff((prev) => ({
|
||||
@@ -170,7 +352,7 @@ export function OntologyEditor() {
|
||||
id: newId,
|
||||
type: "classNode",
|
||||
position: { x: Math.random() * 400, y: Math.random() * 300 },
|
||||
data: { label: "NewIndividual", type: "owl:NamedIndividual" },
|
||||
data: { label: "NewIndividual", type: "owl:NamedIndividual", entityType: "external" },
|
||||
};
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
}, [setNodes]);
|
||||
@@ -190,13 +372,21 @@ export function OntologyEditor() {
|
||||
}, []);
|
||||
|
||||
const autoLayout = useCallback(() => {
|
||||
const layoutNodes = nodes.map((node, index) => ({
|
||||
...node,
|
||||
position: { x: (index % 4) * 200, y: Math.floor(index / 4) * 150 },
|
||||
}));
|
||||
setNodes(layoutNodes);
|
||||
setNodes(layoutEditorNodes(nodes));
|
||||
}, [nodes, setNodes]);
|
||||
|
||||
const selectNode = useCallback((node: OntologyNode) => {
|
||||
setSelectedElement(node);
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("ontologyTab", "editor");
|
||||
params.set("ontologyEntity", node.id);
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
} catch {
|
||||
// URL state is optional; the editor selection still works without it.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveDraft = useCallback(async () => {
|
||||
if (!ontologyUri) {
|
||||
alert("Please select an ontology first");
|
||||
@@ -247,12 +437,11 @@ export function OntologyEditor() {
|
||||
...prev,
|
||||
removed_properties: [...prev.removed_properties, target.id],
|
||||
}));
|
||||
} else {
|
||||
} else if (isEditableEntityType(target.data.entityType)) {
|
||||
setNodes((nds) => nds.filter((n) => n.id !== target.id));
|
||||
setDraftDiff((prev) => ({
|
||||
...prev,
|
||||
removed_classes: [...prev.removed_classes, target.id],
|
||||
}));
|
||||
setDraftDiff((prev) => target.data.entityType === "property"
|
||||
? { ...prev, removed_properties: [...prev.removed_properties, target.id] }
|
||||
: { ...prev, removed_classes: [...prev.removed_classes, target.id] });
|
||||
}
|
||||
setSelectedElement(null);
|
||||
}
|
||||
@@ -261,16 +450,21 @@ export function OntologyEditor() {
|
||||
|
||||
const renameSelected = useCallback(() => {
|
||||
const target = showContext?.element ?? selectedElement;
|
||||
if (target && !("source" in target)) {
|
||||
if (target && !("source" in target) && isEditableEntityType(target.data.entityType)) {
|
||||
const newLabel = prompt("Enter new name:", String(target.data.label ?? ""));
|
||||
if (newLabel) {
|
||||
setNodes((nds) =>
|
||||
nds.map((n) => (n.id === target.id ? { ...n, data: { ...n.data, label: newLabel } } : n))
|
||||
);
|
||||
setDraftDiff((prev) => ({
|
||||
...prev,
|
||||
modified_classes: { ...prev.modified_classes, [target.id]: { label: newLabel } },
|
||||
}));
|
||||
setDraftDiff((prev) => target.data.entityType === "property"
|
||||
? {
|
||||
...prev,
|
||||
modified_properties: { ...prev.modified_properties, [target.id]: { label: newLabel } },
|
||||
}
|
||||
: {
|
||||
...prev,
|
||||
modified_classes: { ...prev.modified_classes, [target.id]: { label: newLabel } },
|
||||
});
|
||||
}
|
||||
}
|
||||
setShowContext(null);
|
||||
@@ -339,11 +533,10 @@ export function OntologyEditor() {
|
||||
};
|
||||
|
||||
const detailPanelStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
flex: "0 0 320px",
|
||||
width: "320px",
|
||||
minWidth: "320px",
|
||||
boxSizing: "border-box",
|
||||
background: "rgba(9, 19, 34, 0.95)",
|
||||
borderLeft: "1px solid rgba(140, 192, 255, 0.12)",
|
||||
padding: "20px",
|
||||
@@ -353,11 +546,24 @@ export function OntologyEditor() {
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "#07111f" }}>
|
||||
<style>{ontologyFlowThemeCss}</style>
|
||||
<div style={toolbarStyle}>
|
||||
<select
|
||||
aria-label="Active ontology"
|
||||
value={ontologyUri}
|
||||
onChange={(event) => setOntologyUri(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setOntologyUri(event.target.value);
|
||||
setSelectedElement(null);
|
||||
try {
|
||||
// Drop the previous ontology's entity from the URL, or a reload
|
||||
// would resolve the stale ID and jump back to that ontology.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("ontologyEntity");
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
} catch {
|
||||
// URL state is optional; switching ontologies still works.
|
||||
}
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">Select ontology...</option>
|
||||
@@ -398,43 +604,75 @@ export function OntologyEditor() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, position: "relative" }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={(_, node) => setSelectedElement(node)}
|
||||
onEdgeClick={(_, edge) => setSelectedElement(edge)}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onEdgeContextMenu={handleEdgeContextMenu}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
style={{ background: "#07111f" }}
|
||||
>
|
||||
<Background color="#1a2d3d" gap={20} />
|
||||
<Controls />
|
||||
<MiniMap nodeColor="#4aa3ff" maskColor="rgba(0,0,0,0.6)" />
|
||||
</ReactFlow>
|
||||
<div style={{ display: "flex", flex: 1, minHeight: 0, minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, position: "relative" }}>
|
||||
<ReactFlow
|
||||
className="ontology-editor-flow"
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onInit={setFlowInstance}
|
||||
onNodeClick={(_, node) => selectNode(node)}
|
||||
onEdgeClick={(_, edge) => setSelectedElement(edge)}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onEdgeContextMenu={handleEdgeContextMenu}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
style={{ background: "#07111f" }}
|
||||
>
|
||||
<Background color="#1a2d3d" gap={20} />
|
||||
<Controls />
|
||||
<MiniMap {...ONTOLOGY_MINIMAP_THEME} />
|
||||
</ReactFlow>
|
||||
|
||||
{showContext && (
|
||||
<div style={{ ...contextMenuStyle, left: showContext.x, top: showContext.y }}>
|
||||
<div style={contextItemStyle} onClick={renameSelected}>
|
||||
<Pencil size={14} />
|
||||
Rename
|
||||
{isLoadingGraph && (
|
||||
<div style={canvasMessageStyle}>Loading ontology structure…</div>
|
||||
)}
|
||||
{!isLoadingGraph && graphError && (
|
||||
<div style={{ ...canvasMessageStyle, color: "#ff9a8d" }}>{graphError}</div>
|
||||
)}
|
||||
{!isLoadingGraph && !graphError && ontologyUri && nodes.length === 0 && (
|
||||
<div style={canvasMessageStyle}>This ontology has no editable classes or properties.</div>
|
||||
)}
|
||||
|
||||
{showContext && (
|
||||
<div style={{ ...contextMenuStyle, left: showContext.x, top: showContext.y }}>
|
||||
{"source" in showContext.element || isEditableEntityType(showContext.element.data.entityType) ? (
|
||||
<>
|
||||
{!("source" in showContext.element) && (
|
||||
<div style={contextItemStyle} onClick={renameSelected}>
|
||||
<Pencil size={14} />
|
||||
Rename
|
||||
</div>
|
||||
)}
|
||||
<div style={contextItemStyle} onClick={deleteSelected}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ ...contextItemStyle, cursor: "default", color: "#8fa8c6" }}>
|
||||
This term is read-only
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={contextItemStyle} onClick={deleteSelected}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedElement && (
|
||||
<div style={detailPanelStyle}>
|
||||
<h3 style={{ margin: "0 0 16px", color: "#ebf3ff", fontSize: "16px" }}>
|
||||
{"source" in selectedElement ? "Property Details" : "Class Details"}
|
||||
{"source" in selectedElement
|
||||
? "Relationship Details"
|
||||
: selectedElement.data.entityType === "property"
|
||||
? "Property Details"
|
||||
: selectedElement.data.entityType === "ontology"
|
||||
? "Ontology Details"
|
||||
: selectedElement.data.entityType === "external"
|
||||
? "External Term Details"
|
||||
: "Class Details"}
|
||||
</h3>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
|
||||
@@ -453,7 +691,9 @@ export function OntologyEditor() {
|
||||
<input
|
||||
type="text"
|
||||
value={String(selectedElement.data.label ?? "")}
|
||||
readOnly={!isEditableEntityType(selectedElement.data.entityType)}
|
||||
onChange={(e) => {
|
||||
if (!isEditableEntityType(selectedElement.data.entityType)) return;
|
||||
setNodes((nds) =>
|
||||
nds.map((n) =>
|
||||
n.id === selectedElement.id
|
||||
@@ -463,10 +703,19 @@ export function OntologyEditor() {
|
||||
);
|
||||
setDraftDiff((prev) => ({
|
||||
...prev,
|
||||
modified_classes: {
|
||||
...prev.modified_classes,
|
||||
[selectedElement.id]: { label: e.target.value },
|
||||
},
|
||||
...(selectedElement.data.entityType === "property"
|
||||
? {
|
||||
modified_properties: {
|
||||
...prev.modified_properties,
|
||||
[selectedElement.id]: { label: e.target.value },
|
||||
},
|
||||
}
|
||||
: {
|
||||
modified_classes: {
|
||||
...prev.modified_classes,
|
||||
[selectedElement.id]: { label: e.target.value },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
}}
|
||||
style={{
|
||||
@@ -496,3 +745,17 @@ export function OntologyEditor() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canvasMessageStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
padding: "10px 14px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid rgba(127, 208, 255, 0.18)",
|
||||
background: "rgba(3, 9, 18, 0.9)",
|
||||
color: "#8fa8c6",
|
||||
fontSize: "13px",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
@@ -9,6 +9,32 @@ import type {
|
||||
ShaclValidationResponse,
|
||||
} from "./types";
|
||||
|
||||
export type OntologyGraphNode = {
|
||||
id: string;
|
||||
type: string;
|
||||
content?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type OntologyGraphEdge = {
|
||||
id?: string;
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
weight?: number;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type OntologyGraphResponse = {
|
||||
uri: string;
|
||||
nodes: OntologyGraphNode[];
|
||||
edges: OntologyGraphEdge[];
|
||||
};
|
||||
|
||||
export type OntologyEntityOwner = {
|
||||
source_ontology?: string;
|
||||
};
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let detail = `Request failed with status ${response.status}`;
|
||||
@@ -31,6 +57,18 @@ export async function loadOntologyRegistry(): Promise<OntologyEntry[]> {
|
||||
return parseResponse<OntologyEntry[]>(await fetch("/api/ontology/registry"));
|
||||
}
|
||||
|
||||
export async function loadOntologyGraph(uri: string, signal?: AbortSignal): Promise<OntologyGraphResponse> {
|
||||
return parseResponse<OntologyGraphResponse>(
|
||||
await fetch(`/api/ontology/graph?uri=${encodeURIComponent(uri)}`, { signal }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadOntologyEntityOwner(uri: string): Promise<string | undefined> {
|
||||
const response = await fetch(`/api/ontology/entity/${encodeURIComponent(uri)}`);
|
||||
if (!response.ok) return undefined;
|
||||
return (await response.json() as OntologyEntityOwner).source_ontology;
|
||||
}
|
||||
|
||||
export async function loadAlignments(uri?: string): Promise<OntologyAlignment[]> {
|
||||
const query = uri ? `?uri=${encodeURIComponent(uri)}` : "";
|
||||
return parseResponse<OntologyAlignment[]>(await fetch(`/api/ontology/alignments${query}`));
|
||||
|
||||
@@ -38,6 +38,7 @@ function readTabParam(): OntologyHubTab {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(TAB_PARAM);
|
||||
if (raw && TABS.some((t) => t.id === raw)) return raw as OntologyHubTab;
|
||||
if (params.get("ontologyEntity")) return "editor";
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -116,4 +117,3 @@ export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
export type EditorEntityType = "ontology" | "class" | "property" | "external";
|
||||
|
||||
export type RegistryEntry = {
|
||||
uri: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const ONTOLOGY_MINIMAP_THEME = {
|
||||
bgColor: "#0b1625",
|
||||
maskColor: "rgba(7, 17, 31, 0.72)",
|
||||
maskStrokeColor: "#5faeff",
|
||||
maskStrokeWidth: 2,
|
||||
nodeColor: "#2d7fd3",
|
||||
nodeStrokeColor: "#9acbff",
|
||||
nodeStrokeWidth: 1,
|
||||
style: {
|
||||
border: "1px solid #29435c",
|
||||
borderRadius: 6,
|
||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.32)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// The backend emits node types in compact (owl:Class) or full IRI
|
||||
// (http://www.w3.org/2002/07/owl#Class) form; classification must accept both.
|
||||
const FULL_IRI_PREFIXES: Array<[string, string]> = [
|
||||
["http://www.w3.org/2002/07/owl#", "owl:"],
|
||||
["http://www.w3.org/2000/01/rdf-schema#", "rdfs:"],
|
||||
["http://www.w3.org/2004/02/skos/core#", "skos:"],
|
||||
];
|
||||
|
||||
export function compactNodeType(type: string): string {
|
||||
for (const [iri, prefix] of FULL_IRI_PREFIXES) {
|
||||
if (type.startsWith(iri)) {
|
||||
return `${prefix}${type.slice(iri.length)}`;
|
||||
}
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
export function classifyNodeType(rawType: string): EditorEntityType {
|
||||
const type = compactNodeType(rawType);
|
||||
if (type === "owl:Ontology") return "ontology";
|
||||
if (type === "owl:Class" || type === "rdfs:Class") return "class";
|
||||
if (type.includes("Property")) return "property";
|
||||
return "external";
|
||||
}
|
||||
|
||||
function ownsByNamespace(entityUri: string, ontologyUri: string): boolean {
|
||||
const stem = ontologyUri.replace(/[/#]+$/, "");
|
||||
return entityUri === ontologyUri
|
||||
|| entityUri.startsWith(`${stem}#`)
|
||||
|| entityUri.startsWith(`${stem}/`);
|
||||
}
|
||||
|
||||
export function inferOntologyUri(
|
||||
entries: RegistryEntry[],
|
||||
entityUri: string,
|
||||
explicitOwner?: string,
|
||||
): string | undefined {
|
||||
if (explicitOwner && entries.some((entry) => entry.uri === explicitOwner)) {
|
||||
return explicitOwner;
|
||||
}
|
||||
return [...entries]
|
||||
.filter((entry) => ownsByNamespace(entityUri, entry.uri))
|
||||
.sort((left, right) => right.uri.length - left.uri.length)[0]?.uri;
|
||||
}
|
||||
|
||||
export function isEditableEntityType(entityType?: EditorEntityType): boolean {
|
||||
return entityType === "class" || entityType === "property";
|
||||
}
|
||||
@@ -42,6 +42,9 @@ async function startVite(): Promise<void> {
|
||||
}
|
||||
|
||||
async function installApiFixture(page: Page): Promise<void> {
|
||||
await page.route("**/api/info", async (route) => {
|
||||
await route.fulfill({ json: { capabilities: { agent_memory: false } } });
|
||||
});
|
||||
await page.route("**/api/graph/**", async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname === "/api/graph/stats") {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
|
||||
import { fetchAgentMemoryAvailability } from "../src/explorerCapabilities.ts";
|
||||
|
||||
(globalThis as typeof globalThis & { React: typeof React }).React = React;
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
Node: dom.window.Node,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
});
|
||||
|
||||
const { cleanup, render } = await import("@testing-library/react");
|
||||
const { ExploreWorkspaceTabs } = await import("../src/ExploreWorkspaceTabs.tsx");
|
||||
|
||||
test.afterEach(cleanup);
|
||||
|
||||
test("reports AgentMemory when the Explorer host provides it", async () => {
|
||||
const available = await fetchAgentMemoryAvailability(async () => (
|
||||
new Response(
|
||||
JSON.stringify({ capabilities: { agent_memory: true } }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
)
|
||||
));
|
||||
|
||||
assert.equal(available, true);
|
||||
});
|
||||
|
||||
test("keeps AgentMemory hidden when the capability is absent or unavailable", async () => {
|
||||
const absent = await fetchAgentMemoryAvailability(async () => (
|
||||
new Response(JSON.stringify({ status: "active" }), { status: 200 })
|
||||
));
|
||||
const unavailable = await fetchAgentMemoryAvailability(async () => {
|
||||
throw new Error("network unavailable");
|
||||
});
|
||||
|
||||
assert.equal(absent, false);
|
||||
assert.equal(unavailable, false);
|
||||
});
|
||||
|
||||
test("shows the Memories tab only when the host provides AgentMemory", () => {
|
||||
const availableView = render(
|
||||
<ExploreWorkspaceTabs
|
||||
activeView="graph"
|
||||
agentMemoryAvailable
|
||||
onSelect={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.ok(availableView.getByRole("button", { name: "Memories" }));
|
||||
cleanup();
|
||||
|
||||
const unavailableView = render(
|
||||
<ExploreWorkspaceTabs
|
||||
activeView="graph"
|
||||
agentMemoryAvailable={false}
|
||||
onSelect={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.equal(
|
||||
unavailableView.queryByRole("button", { name: "Memories" }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
(globalThis as any).React = React;
|
||||
(globalThis as typeof globalThis & { React: typeof React }).React = React;
|
||||
|
||||
import { MarkdownContentViewer } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
|
||||
import { isSafeUrl } from "../src/workspaces/GraphWorkspace/markdownUrlSafety.ts";
|
||||
@@ -60,6 +60,29 @@ test("renders Preview mode with formatted Markdown elements and tabs", () => {
|
||||
assert.equal(html.includes("Item B"), true);
|
||||
});
|
||||
|
||||
test("stays read-only without a resource and exposes Edit for canonical resources", () => {
|
||||
const readOnly = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "Read-only body",
|
||||
}));
|
||||
const editable = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "Editable body",
|
||||
resource: { kind: "context-node", id: "node-1" },
|
||||
}));
|
||||
|
||||
assert.equal(readOnly.includes(">Edit</button>"), false);
|
||||
assert.equal(editable.includes(">Edit</button>"), true);
|
||||
});
|
||||
|
||||
test("empty canonical resources still expose Edit", () => {
|
||||
const html = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "",
|
||||
resource: { kind: "context-node", id: "empty-node" },
|
||||
}));
|
||||
|
||||
assert.equal(html.includes("No content available for this node."), true);
|
||||
assert.equal(html.includes(">Edit</button>"), true);
|
||||
});
|
||||
|
||||
test("renders Source mode with exact unmodified text inside pre/code", () => {
|
||||
const markdown = `# Title 🚀\n\n * Indented item\n\n\`\`\`python\ndef test():\n return "α + β"\n\`\`\``;
|
||||
const html = renderToString(React.createElement(MarkdownContentViewer, { content: markdown, defaultMode: "source" }));
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
|
||||
(globalThis as typeof globalThis & { React: typeof React }).React = React;
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
Node: dom.window.Node,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
});
|
||||
dom.window.confirm = () => true;
|
||||
|
||||
// Testing Library and the components must load after the jsdom globals above.
|
||||
const { act, cleanup, fireEvent, render, waitFor } = await import(
|
||||
"@testing-library/react"
|
||||
);
|
||||
const { MarkdownContentViewer } = await import(
|
||||
"../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx"
|
||||
);
|
||||
const { MemoryWorkspace } = await import(
|
||||
"../src/workspaces/MemoryWorkspace.tsx"
|
||||
);
|
||||
|
||||
test.afterEach(() => {
|
||||
cleanup();
|
||||
dom.window.confirm = () => true;
|
||||
});
|
||||
|
||||
const resource = { kind: "context-node" as const, id: "node-1" };
|
||||
const originalSource = "---\nid: node-1\ntype: Note\n---\n\nOriginal";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
test("Edit and Apply send canonical Markdown and publish the applied result", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
let appliedBody = "";
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requests.push({ url: String(input), init });
|
||||
if (init?.method === "PUT") {
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource.replace("Original", "Updated"),
|
||||
body: "Updated",
|
||||
revision: "sha256:updated",
|
||||
editable: true,
|
||||
changed: true,
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource,
|
||||
body: "Original",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<MarkdownContentViewer
|
||||
content="Original"
|
||||
resource={resource}
|
||||
onApplied={(result) => { appliedBody = result.body; }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
fireEvent.input(textarea, {
|
||||
target: { value: originalSource.replace("Original", "Updated") },
|
||||
});
|
||||
fireEvent.click(view.getByRole("button", { name: "Apply" }));
|
||||
|
||||
await waitFor(() => assert.equal(appliedBody, "Updated"));
|
||||
assert.deepEqual(requests.map(({ init }) => init?.method ?? "GET"), ["GET", "PUT"]);
|
||||
assert.equal(
|
||||
JSON.parse(String(requests[1].init?.body)).expected_revision,
|
||||
"sha256:original",
|
||||
);
|
||||
assert.equal(
|
||||
view.getByRole("tab", { name: "Preview" }).getAttribute("aria-selected"),
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("Cancel restores the previous view and never sends a PUT", async () => {
|
||||
const methods: string[] = [];
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
methods.push(init?.method ?? "GET");
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource,
|
||||
body: "Original",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MarkdownContentViewer content="Original" resource={resource} />);
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
fireEvent.input(textarea, {
|
||||
target: { value: originalSource.replace("Original", "Draft") },
|
||||
});
|
||||
fireEvent.click(view.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
assert.deepEqual(methods, ["GET"]);
|
||||
assert.equal(view.queryByRole("textbox", { name: "Markdown source" }), null);
|
||||
assert.equal(
|
||||
view.getByRole("tab", { name: "Preview" }).getAttribute("aria-selected"),
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("validation failures keep the draft visible for correction", async () => {
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
if (init?.method === "PUT") {
|
||||
return jsonResponse({
|
||||
detail: {
|
||||
code: "invalid_markdown_frontmatter",
|
||||
message: "Markdown frontmatter contains invalid YAML.",
|
||||
},
|
||||
}, 422);
|
||||
}
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource,
|
||||
body: "Original",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MarkdownContentViewer content="Original" resource={resource} />);
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
const invalidDraft = "---\nid: [\n---\n\nDraft";
|
||||
fireEvent.input(textarea, { target: { value: invalidDraft } });
|
||||
fireEvent.click(view.getByRole("button", { name: "Apply" }));
|
||||
|
||||
const alert = await view.findByRole("alert");
|
||||
assert.match(alert.textContent ?? "", /invalid YAML/);
|
||||
assert.equal(
|
||||
(view.getByRole("textbox", { name: "Markdown source" }) as HTMLTextAreaElement)
|
||||
.value,
|
||||
invalidDraft,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("resource changes discard the previous editor session", async () => {
|
||||
globalThis.fetch = async (input) => {
|
||||
const id = String(input).endsWith("node-2") ? "node-2" : "node-1";
|
||||
return jsonResponse({
|
||||
resource: { kind: "context-node", id },
|
||||
source: `---\nid: ${id}\ntype: Note\n---\n\n${id}`,
|
||||
body: id,
|
||||
revision: `sha256:${id}`,
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<MarkdownContentViewer content="node-1" resource={resource} />,
|
||||
);
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
fireEvent.input(textarea, {
|
||||
target: { value: `${(textarea as HTMLTextAreaElement).value}\nDraft` },
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<MarkdownContentViewer
|
||||
content="node-2"
|
||||
resource={{ kind: "context-node", id: "node-2" }}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => assert.equal(
|
||||
view.queryByRole("textbox", { name: "Markdown source" }),
|
||||
null,
|
||||
));
|
||||
|
||||
view.rerender(
|
||||
<MarkdownContentViewer content="node-1" resource={resource} />,
|
||||
);
|
||||
await waitFor(() => assert.equal(
|
||||
view.queryByRole("textbox", { name: "Markdown source" }),
|
||||
null,
|
||||
));
|
||||
assert.ok(view.getByRole("button", { name: "Edit" }));
|
||||
});
|
||||
|
||||
|
||||
test("unmounting a dirty editor clears the parent dirty guard", async () => {
|
||||
const dirtyStates: boolean[] = [];
|
||||
globalThis.fetch = async () => jsonResponse({
|
||||
resource,
|
||||
source: originalSource,
|
||||
body: "Original",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
|
||||
const view = render(
|
||||
<MarkdownContentViewer
|
||||
content="Original"
|
||||
resource={resource}
|
||||
onDirtyChange={(dirty) => dirtyStates.push(dirty)}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
fireEvent.input(textarea, {
|
||||
target: { value: originalSource.replace("Original", "Draft") },
|
||||
});
|
||||
await waitFor(() => assert.equal(dirtyStates.at(-1), true));
|
||||
|
||||
view.unmount();
|
||||
|
||||
assert.equal(dirtyStates.at(-1), false);
|
||||
});
|
||||
|
||||
test("MemoryWorkspace protects a dirty memory draft when selection changes", async () => {
|
||||
const requestedUrls: string[] = [];
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = String(input);
|
||||
requestedUrls.push(url);
|
||||
if (url.startsWith("/api/memories")) {
|
||||
return jsonResponse({
|
||||
items: [
|
||||
{ id: "mem-1", type: "note", excerpt: "First", updated_at: null },
|
||||
{ id: "mem-2", type: "note", excerpt: "Second", updated_at: null },
|
||||
],
|
||||
total: 2,
|
||||
skip: 0,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
const id = url.endsWith("mem-2") ? "mem-2" : "mem-1";
|
||||
return jsonResponse({
|
||||
resource: { kind: "agent-memory", id },
|
||||
source: `---\nid: ${id}\ntype: note\n---\n\n${id}`,
|
||||
body: id,
|
||||
revision: `sha256:${id}`,
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MemoryWorkspace />);
|
||||
await view.findByText("Selected memory");
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
fireEvent.input(textarea, {
|
||||
target: { value: `${(textarea as HTMLTextAreaElement).value}\nDraft` },
|
||||
});
|
||||
|
||||
dom.window.confirm = () => false;
|
||||
fireEvent.click(view.getByRole("button", { name: /mem-2/ }));
|
||||
|
||||
assert.equal(requestedUrls.some((url) => url.endsWith("mem-2")), false);
|
||||
assert.equal(view.getByText("mem-1", { selector: "strong" }).textContent, "mem-1");
|
||||
});
|
||||
|
||||
|
||||
test("MemoryWorkspace loads memories beyond the first server page", async () => {
|
||||
const requestedUrls: string[] = [];
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `mem-${index + 1}`,
|
||||
type: "note",
|
||||
excerpt: `Memory ${index + 1}`,
|
||||
updated_at: null,
|
||||
}));
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = String(input);
|
||||
requestedUrls.push(url);
|
||||
if (url === "/api/memories?skip=0&limit=100") {
|
||||
return jsonResponse({
|
||||
items: firstPage,
|
||||
total: 101,
|
||||
skip: 0,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
if (url === "/api/memories?skip=100&limit=100") {
|
||||
return jsonResponse({
|
||||
items: [{
|
||||
id: "mem-101",
|
||||
type: "note",
|
||||
excerpt: "Memory 101",
|
||||
updated_at: null,
|
||||
}],
|
||||
total: 101,
|
||||
skip: 100,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
resource: { kind: "agent-memory", id: "mem-1" },
|
||||
source: "---\nid: mem-1\ntype: note\n---\n\nmem-1",
|
||||
body: "mem-1",
|
||||
revision: "sha256:mem-1",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MemoryWorkspace />);
|
||||
await view.findByText("Selected memory");
|
||||
fireEvent.click(view.getByRole("button", { name: "Load more memories" }));
|
||||
|
||||
await view.findByRole("button", { name: /mem-101/ });
|
||||
assert.ok(requestedUrls.includes("/api/memories?skip=100&limit=100"));
|
||||
assert.equal(view.getByText("101 of 101 loaded").textContent, "101 of 101 loaded");
|
||||
});
|
||||
|
||||
|
||||
test("MemoryWorkspace ignores stale selection responses", async () => {
|
||||
let resolveMem2: ((response: Response) => void) | undefined;
|
||||
let resolveMem3: ((response: Response) => void) | undefined;
|
||||
const mem2Response = new Promise<Response>((resolve) => {
|
||||
resolveMem2 = resolve;
|
||||
});
|
||||
const mem3Response = new Promise<Response>((resolve) => {
|
||||
resolveMem3 = resolve;
|
||||
});
|
||||
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("/api/memories")) {
|
||||
return jsonResponse({
|
||||
items: [
|
||||
{ id: "mem-1", type: "note", excerpt: "First", updated_at: null },
|
||||
{ id: "mem-2", type: "note", excerpt: "Second", updated_at: null },
|
||||
{ id: "mem-3", type: "note", excerpt: "Third", updated_at: null },
|
||||
],
|
||||
total: 3,
|
||||
skip: 0,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
if (url.endsWith("mem-2")) return mem2Response;
|
||||
if (url.endsWith("mem-3")) return mem3Response;
|
||||
return jsonResponse({
|
||||
resource: { kind: "agent-memory", id: "mem-1" },
|
||||
source: "---\nid: mem-1\ntype: note\n---\n\nmem-1",
|
||||
body: "mem-1",
|
||||
revision: "sha256:mem-1",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MemoryWorkspace />);
|
||||
await view.findByText("Selected memory");
|
||||
fireEvent.click(view.getByRole("button", { name: /mem-2/ }));
|
||||
fireEvent.click(view.getByRole("button", { name: /mem-3/ }));
|
||||
|
||||
await act(async () => {
|
||||
resolveMem3?.(jsonResponse({
|
||||
resource: { kind: "agent-memory", id: "mem-3" },
|
||||
source: "---\nid: mem-3\ntype: note\n---\n\nmem-3",
|
||||
body: "mem-3",
|
||||
revision: "sha256:mem-3",
|
||||
editable: true,
|
||||
}));
|
||||
await mem3Response;
|
||||
});
|
||||
await waitFor(() => assert.equal(
|
||||
view.getByText("mem-3", { selector: "strong" }).textContent,
|
||||
"mem-3",
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
resolveMem2?.(jsonResponse({
|
||||
resource: { kind: "agent-memory", id: "mem-2" },
|
||||
source: "---\nid: mem-2\ntype: note\n---\n\nmem-2",
|
||||
body: "mem-2",
|
||||
revision: "sha256:mem-2",
|
||||
editable: true,
|
||||
}));
|
||||
await mem2Response;
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
view.getByText("mem-3", { selector: "strong" }).textContent,
|
||||
"mem-3",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("MemoryWorkspace refreshes frontmatter summaries after apply", async () => {
|
||||
let listRequests = 0;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("/api/memories")) {
|
||||
listRequests += 1;
|
||||
const saved = listRequests > 1;
|
||||
return jsonResponse({
|
||||
items: [{
|
||||
id: "mem-1",
|
||||
type: saved ? "decision" : "note",
|
||||
excerpt: saved ? "Updated memory" : "Original memory",
|
||||
updated_at: saved ? "2026-09-01T12:00:00+00:00" : null,
|
||||
}],
|
||||
total: 1,
|
||||
skip: 0,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
if (init?.method === "PUT") {
|
||||
return jsonResponse({
|
||||
resource: { kind: "agent-memory", id: "mem-1" },
|
||||
source: "---\nid: mem-1\ntype: decision\n---\n\nUpdated memory",
|
||||
body: "Updated memory",
|
||||
revision: "sha256:updated",
|
||||
editable: true,
|
||||
changed: true,
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
resource: { kind: "agent-memory", id: "mem-1" },
|
||||
source: "---\nid: mem-1\ntype: note\n---\n\nOriginal memory",
|
||||
body: "Original memory",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MemoryWorkspace />);
|
||||
await view.findByText("Selected memory");
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
fireEvent.input(textarea, {
|
||||
target: {
|
||||
value: (textarea as HTMLTextAreaElement).value
|
||||
.replace("type: note", "type: decision")
|
||||
.replace("Original memory", "Updated memory"),
|
||||
},
|
||||
});
|
||||
fireEvent.click(view.getByRole("button", { name: "Apply" }));
|
||||
|
||||
await view.findByText("decision");
|
||||
assert.equal(listRequests, 2);
|
||||
assert.equal(view.getAllByText("Updated memory").length, 2);
|
||||
});
|
||||
|
||||
test("HTTP 409 conflict preserves draft and shows conflict error with reload option", async () => {
|
||||
// After a 409, the user's draft must be kept and a recovery path available.
|
||||
const requests: Array<{ method: string; body?: unknown }> = [];
|
||||
let fetchCount = 0;
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
fetchCount += 1;
|
||||
const method = init?.method ?? "GET";
|
||||
let parsedBody: unknown = undefined;
|
||||
if (init?.body) {
|
||||
try { parsedBody = JSON.parse(String(init.body)); } catch { /* ignore */ }
|
||||
}
|
||||
requests.push({ method, body: parsedBody });
|
||||
|
||||
if (method === "PUT") {
|
||||
// First PUT returns 409 with current_revision
|
||||
return jsonResponse({
|
||||
detail: {
|
||||
code: "markdown_revision_conflict",
|
||||
message: "This item changed after editing began. Reload the latest version before applying.",
|
||||
current_revision: "sha256:newer",
|
||||
},
|
||||
}, 409);
|
||||
}
|
||||
// All GETs return the canonical document
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource,
|
||||
body: "Original",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MarkdownContentViewer content="Original" resource={resource} />);
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
const draftValue = originalSource.replace("Original", "My draft");
|
||||
fireEvent.input(textarea, { target: { value: draftValue } });
|
||||
|
||||
// Apply → receives 409
|
||||
fireEvent.click(view.getByRole("button", { name: "Apply" }));
|
||||
|
||||
// Conflict error must appear
|
||||
const alert = await view.findByRole("alert");
|
||||
assert.match(
|
||||
alert.textContent ?? "",
|
||||
/changed after editing|Reload/i,
|
||||
"conflict error message must be shown",
|
||||
);
|
||||
|
||||
// Draft must be preserved in the textarea
|
||||
const textareaAfterConflict = view.getByRole("textbox", { name: "Markdown source" }) as HTMLTextAreaElement;
|
||||
assert.equal(textareaAfterConflict.value, draftValue, "draft must be preserved after 409");
|
||||
|
||||
// A reload / recovery action must be available
|
||||
const reloadButton = view.queryByRole("button", { name: /reload latest/i });
|
||||
assert.ok(reloadButton !== null, "a 'Reload latest' recovery button must be shown");
|
||||
|
||||
// Click reload — should re-fetch the latest canonical document
|
||||
await act(async () => {
|
||||
fireEvent.click(reloadButton!);
|
||||
});
|
||||
|
||||
// After reload the editor is re-initialized with the server's canonical source
|
||||
await waitFor(() => {
|
||||
const refreshedTextarea = view.queryByRole("textbox", { name: "Markdown source" });
|
||||
assert.ok(refreshedTextarea !== null, "editor must still be open after reload");
|
||||
assert.equal(
|
||||
(refreshedTextarea as HTMLTextAreaElement).value,
|
||||
originalSource,
|
||||
"editor must show the server canonical source after reload",
|
||||
);
|
||||
});
|
||||
|
||||
// Reload must have triggered exactly one more GET
|
||||
const getCount = requests.filter((r) => r.method === "GET").length;
|
||||
assert.ok(getCount >= 2, "reload must issue a new GET to fetch the latest canonical document");
|
||||
});
|
||||
|
||||
|
||||
test("successful retry after 422 uses the original revision and persists changes", async () => {
|
||||
// After a 422 (validation failure), the baseRevision must remain valid so that
|
||||
// correcting the draft and re-applying succeeds without re-fetching the document.
|
||||
let putCallCount = 0;
|
||||
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
const method = init?.method ?? "GET";
|
||||
if (method === "PUT") {
|
||||
putCallCount += 1;
|
||||
if (putCallCount === 1) {
|
||||
// First PUT: validation failure — resource is unchanged
|
||||
return jsonResponse({
|
||||
detail: {
|
||||
code: "invalid_markdown_frontmatter",
|
||||
message: "Markdown frontmatter contains invalid YAML.",
|
||||
},
|
||||
}, 422);
|
||||
}
|
||||
// Second PUT: success with the corrected Markdown
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { markdown: string };
|
||||
const correctedBody = body.markdown.includes("Corrected") ? "Corrected body" : "body";
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource.replace("Original", "Corrected"),
|
||||
body: correctedBody,
|
||||
revision: "sha256:after-retry",
|
||||
editable: true,
|
||||
changed: true,
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource,
|
||||
body: "Original",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
let appliedRevision = "";
|
||||
const view = render(
|
||||
<MarkdownContentViewer
|
||||
content="Original"
|
||||
resource={resource}
|
||||
onApplied={(result) => { appliedRevision = result.revision; }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
|
||||
// First attempt: create an invalid draft
|
||||
const invalidDraft = "---\nid: [\n---\n\nInvalid body";
|
||||
fireEvent.input(textarea, { target: { value: invalidDraft } });
|
||||
fireEvent.click(view.getByRole("button", { name: "Apply" }));
|
||||
|
||||
// 422 error appears, draft is preserved
|
||||
const alert = await view.findByRole("alert");
|
||||
assert.match(alert.textContent ?? "", /invalid YAML/i);
|
||||
assert.equal(
|
||||
(view.getByRole("textbox", { name: "Markdown source" }) as HTMLTextAreaElement).value,
|
||||
invalidDraft,
|
||||
"invalid draft must be preserved after 422",
|
||||
);
|
||||
|
||||
// Correct the draft
|
||||
const correctedDraft = originalSource.replace("Original", "Corrected");
|
||||
fireEvent.input(view.getByRole("textbox", { name: "Markdown source" }), {
|
||||
target: { value: correctedDraft },
|
||||
});
|
||||
|
||||
// Apply is re-enabled (still dirty)
|
||||
const applyButton = view.getByRole("button", { name: "Apply" });
|
||||
assert.equal(
|
||||
(applyButton as HTMLButtonElement).disabled,
|
||||
false,
|
||||
"Apply must be re-enabled after correcting the draft",
|
||||
);
|
||||
|
||||
// Second attempt: apply corrected draft
|
||||
fireEvent.click(applyButton);
|
||||
|
||||
// Must succeed — server returns new revision
|
||||
await waitFor(() => assert.equal(appliedRevision, "sha256:after-retry"));
|
||||
|
||||
// Editor returns to preview mode after successful save
|
||||
assert.equal(
|
||||
view.getByRole("tab", { name: "Preview" }).getAttribute("aria-selected"),
|
||||
"true",
|
||||
"editor must return to preview after successful retry",
|
||||
);
|
||||
|
||||
// Error is cleared
|
||||
assert.equal(view.queryByRole("alert"), null, "error banner must be cleared after success");
|
||||
|
||||
// Both PUT attempts were made — retry used original revision (no extra GET between attempts)
|
||||
assert.equal(putCallCount, 2, "exactly two PUT requests must be made (failed + successful retry)");
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
cancelEdit,
|
||||
createEditSession,
|
||||
createLoadingSession,
|
||||
isDirty,
|
||||
saveFailed,
|
||||
saveStarted,
|
||||
saveSucceeded,
|
||||
shouldConfirmDiscard,
|
||||
updateDraft,
|
||||
} from "../src/workspaces/GraphWorkspace/markdownEditorState.ts";
|
||||
|
||||
const resource = { kind: "context-node" as const, id: "node-1" };
|
||||
|
||||
|
||||
test("enters loading and editing with the canonical source", () => {
|
||||
const loading = createLoadingSession(resource);
|
||||
const editing = createEditSession(resource, {
|
||||
source: "---\nid: node-1\n---\n\nBody",
|
||||
revision: "sha256:one",
|
||||
});
|
||||
|
||||
assert.equal(loading.status, "loading-document");
|
||||
assert.equal(editing.status, "editing");
|
||||
assert.equal(editing.draft, editing.baseSource);
|
||||
assert.equal(isDirty(editing), false);
|
||||
});
|
||||
|
||||
|
||||
test("draft changes derive dirty state and clear prior errors", () => {
|
||||
const session = createEditSession(resource, {
|
||||
source: "base",
|
||||
revision: "sha256:one",
|
||||
});
|
||||
const failed = saveFailed(session, {
|
||||
kind: "validation",
|
||||
message: "Invalid",
|
||||
});
|
||||
const edited = updateDraft(failed, "draft");
|
||||
|
||||
assert.equal(edited.status, "editing");
|
||||
assert.equal(edited.error, null);
|
||||
assert.equal(isDirty(edited), true);
|
||||
assert.equal(shouldConfirmDiscard(edited), true);
|
||||
});
|
||||
|
||||
|
||||
test("cancel discards the edit session without saving", () => {
|
||||
const session = updateDraft(
|
||||
createEditSession(resource, { source: "base", revision: "sha256:one" }),
|
||||
"draft",
|
||||
);
|
||||
|
||||
assert.equal(isDirty(session), true);
|
||||
assert.equal(cancelEdit(), null);
|
||||
});
|
||||
|
||||
|
||||
test("no-op save never enters saving state", () => {
|
||||
const session = createEditSession(resource, {
|
||||
source: "base",
|
||||
revision: "sha256:one",
|
||||
});
|
||||
|
||||
assert.equal(saveStarted(session), session);
|
||||
assert.equal(isDirty(session), false);
|
||||
});
|
||||
|
||||
|
||||
test("save success replaces the base source and revision", () => {
|
||||
const session = saveStarted(
|
||||
updateDraft(
|
||||
createEditSession(resource, { source: "base", revision: "sha256:one" }),
|
||||
"draft",
|
||||
),
|
||||
);
|
||||
const saved = saveSucceeded(session, {
|
||||
source: "canonical saved",
|
||||
revision: "sha256:two",
|
||||
});
|
||||
|
||||
assert.equal(saved.status, "viewing");
|
||||
assert.equal(saved.baseSource, "canonical saved");
|
||||
assert.equal(saved.draft, "canonical saved");
|
||||
assert.equal(saved.baseRevision, "sha256:two");
|
||||
assert.equal(isDirty(saved), false);
|
||||
});
|
||||
|
||||
|
||||
test("validation, conflict, and save failures retain the draft for retry", () => {
|
||||
const draft = updateDraft(
|
||||
createEditSession(resource, { source: "base", revision: "sha256:one" }),
|
||||
"draft",
|
||||
);
|
||||
|
||||
const validation = saveFailed(draft, {
|
||||
kind: "validation",
|
||||
message: "Invalid",
|
||||
});
|
||||
const conflict = saveFailed(draft, {
|
||||
kind: "conflict",
|
||||
message: "Stale",
|
||||
currentRevision: "sha256:two",
|
||||
});
|
||||
const network = saveFailed(draft, {
|
||||
kind: "network",
|
||||
message: "Offline",
|
||||
});
|
||||
|
||||
assert.equal(validation.status, "validation-error");
|
||||
assert.equal(conflict.status, "conflict");
|
||||
assert.equal(network.status, "save-error");
|
||||
assert.equal(validation.draft, "draft");
|
||||
assert.equal(conflict.draft, "draft");
|
||||
assert.equal(network.draft, "draft");
|
||||
assert.equal(saveStarted(network).status, "saving");
|
||||
});
|
||||
|
||||
|
||||
test("saving sessions do not allow a competing discard confirmation", () => {
|
||||
const saving = saveStarted(
|
||||
updateDraft(
|
||||
createEditSession(resource, { source: "base", revision: "sha256:one" }),
|
||||
"draft",
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(shouldConfirmDiscard(saving), false);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
NodeMarkdownRefreshGuard,
|
||||
buildNodeMarkdownAttributeUpdate,
|
||||
readNodeMarkdownAttributeUpdate,
|
||||
} from "../src/workspaces/GraphWorkspace/nodeMarkdownSync.ts";
|
||||
|
||||
|
||||
test("saved Markdown updates graph content and its visible label", () => {
|
||||
const update = buildNodeMarkdownAttributeUpdate(
|
||||
"issue-1327",
|
||||
"# Issue 1328888\n\nUpdated body",
|
||||
{ status: "implemented" },
|
||||
);
|
||||
|
||||
assert.equal(update.content, "# Issue 1328888\n\nUpdated body");
|
||||
assert.equal(update.label, "# Issue 1328888\n\nUpdated body");
|
||||
assert.deepEqual(update.properties, {
|
||||
status: "implemented",
|
||||
content: "# Issue 1328888\n\nUpdated body",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("empty Markdown falls back to the stable node id label", () => {
|
||||
const update = buildNodeMarkdownAttributeUpdate("issue-1327", "", {});
|
||||
|
||||
assert.equal(update.label, "issue-1327");
|
||||
assert.equal(update.content, "");
|
||||
});
|
||||
|
||||
|
||||
test("saved frontmatter is refreshed from the canonical graph node", async () => {
|
||||
const update = await readNodeMarkdownAttributeUpdate(
|
||||
"node/1",
|
||||
async (input) => {
|
||||
assert.equal(String(input), "/api/graph/node?node_id=node%2F1");
|
||||
return new Response(JSON.stringify({
|
||||
id: "node/1",
|
||||
type: "Decision",
|
||||
content: "Updated body",
|
||||
properties: {
|
||||
content: "Updated body",
|
||||
status: "accepted",
|
||||
valid_from: "2026-09-01T00:00:00Z",
|
||||
},
|
||||
valid_from: "2026-09-01T00:00:00Z",
|
||||
valid_until: null,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
);
|
||||
assert.deepEqual(update, {
|
||||
label: "Updated body",
|
||||
content: "Updated body",
|
||||
properties: {
|
||||
content: "Updated body",
|
||||
status: "accepted",
|
||||
valid_from: "2026-09-01T00:00:00Z",
|
||||
},
|
||||
nodeType: "Decision",
|
||||
valid_from: "2026-09-01T00:00:00Z",
|
||||
valid_until: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("realtime updates invalidate an older local-save refresh", () => {
|
||||
const guard = new NodeMarkdownRefreshGuard();
|
||||
const localSaveRefresh = guard.begin("node-1");
|
||||
|
||||
guard.invalidate("node-1");
|
||||
|
||||
assert.equal(guard.isCurrent("node-1", localSaveRefresh), false);
|
||||
});
|
||||
|
||||
|
||||
test("refresh invalidation is scoped to one node", () => {
|
||||
const guard = new NodeMarkdownRefreshGuard();
|
||||
const firstNodeRefresh = guard.begin("node-1");
|
||||
const secondNodeRefresh = guard.begin("node-2");
|
||||
|
||||
guard.invalidate("node-1");
|
||||
|
||||
assert.equal(guard.isCurrent("node-1", firstNodeRefresh), false);
|
||||
assert.equal(guard.isCurrent("node-2", secondNodeRefresh), true);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
classifyNodeType,
|
||||
compactNodeType,
|
||||
inferOntologyUri,
|
||||
isEditableEntityType,
|
||||
ONTOLOGY_MINIMAP_THEME,
|
||||
} from "../src/workspaces/OntologyWorkspace/ontologyEditorModel";
|
||||
|
||||
const registry = [
|
||||
{ uri: "https://example.test/foo", name: "Foo" },
|
||||
{ uri: "https://example.test/foo/nested", name: "Nested" },
|
||||
];
|
||||
|
||||
test("ontology inference requires a URI delimiter and prefers the closest namespace", () => {
|
||||
assert.equal(inferOntologyUri(registry, "https://example.test/foobar/Class"), undefined);
|
||||
assert.equal(
|
||||
inferOntologyUri(registry, "https://example.test/foo/nested#Class"),
|
||||
"https://example.test/foo/nested",
|
||||
);
|
||||
});
|
||||
|
||||
test("explicit scheme ownership wins when an entity uses another namespace", () => {
|
||||
assert.equal(
|
||||
inferOntologyUri(registry, "https://vocabulary.test/Class", "https://example.test/foo"),
|
||||
"https://example.test/foo",
|
||||
);
|
||||
});
|
||||
|
||||
test("only draft-supported class and property nodes are editable", () => {
|
||||
assert.equal(isEditableEntityType("class"), true);
|
||||
assert.equal(isEditableEntityType("property"), true);
|
||||
assert.equal(isEditableEntityType("ontology"), false);
|
||||
assert.equal(isEditableEntityType("external"), false);
|
||||
});
|
||||
|
||||
test("the ontology minimap has an explicit dark, high-contrast theme", () => {
|
||||
assert.equal(ONTOLOGY_MINIMAP_THEME.bgColor, "#0b1625");
|
||||
assert.equal(ONTOLOGY_MINIMAP_THEME.maskStrokeColor, "#5faeff");
|
||||
assert.equal(ONTOLOGY_MINIMAP_THEME.nodeStrokeColor, "#9acbff");
|
||||
assert.match(ONTOLOGY_MINIMAP_THEME.style.border, /#29435c/);
|
||||
});
|
||||
|
||||
test("node types classify identically in compact and full IRI form", () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
["owl:Ontology", "http://www.w3.org/2002/07/owl#Ontology", "ontology"],
|
||||
["owl:Class", "http://www.w3.org/2002/07/owl#Class", "class"],
|
||||
["rdfs:Class", "http://www.w3.org/2000/01/rdf-schema#Class", "class"],
|
||||
["owl:ObjectProperty", "http://www.w3.org/2002/07/owl#ObjectProperty", "property"],
|
||||
["owl:DatatypeProperty", "http://www.w3.org/2002/07/owl#DatatypeProperty", "property"],
|
||||
["owl:AnnotationProperty", "http://www.w3.org/2002/07/owl#AnnotationProperty", "property"],
|
||||
];
|
||||
for (const [compact, fullIri, expected] of cases) {
|
||||
assert.equal(classifyNodeType(compact), expected, compact);
|
||||
assert.equal(classifyNodeType(fullIri), expected, fullIri);
|
||||
}
|
||||
assert.equal(classifyNodeType("owl:NamedIndividual"), "external");
|
||||
assert.equal(classifyNodeType("http://www.w3.org/2004/02/skos/core#Concept"), "external");
|
||||
});
|
||||
|
||||
test("compactNodeType leaves unknown namespaces untouched", () => {
|
||||
assert.equal(compactNodeType("https://example.org/custom#Thing"), "https://example.org/custom#Thing");
|
||||
assert.equal(compactNodeType("owl:Class"), "owl:Class");
|
||||
});
|
||||
+36
-7
@@ -47,7 +47,11 @@ dependencies = [
|
||||
"numpy>=2.0.2",
|
||||
"pandas>=1.3.0",
|
||||
"scipy>=1.13.1",
|
||||
"scikit-learn>=1.7.2",
|
||||
# scikit-learn dropped Python 3.9 support at 1.7.0 (requires_python >=3.10),
|
||||
# so an unqualified >=1.7.2 floor is unsatisfiable on 3.9. Cap 3.9 to the
|
||||
# last 3.9-compatible release line; 3.10+ is left unconstrained.
|
||||
"scikit-learn>=1.6.1,<1.7.0; python_version < '3.10'",
|
||||
"scikit-learn>=1.7.2; python_version >= '3.10'",
|
||||
"umap-learn>=0.5.12",
|
||||
# thinc (spacy's core dep) dropped Python 3.9 wheels at 8.3.10, and later
|
||||
# spacy patch releases (3.8.8+) require thinc>=8.3.9-only-on-3.10+ ranges,
|
||||
@@ -66,24 +70,49 @@ dependencies = [
|
||||
"seaborn>=0.13.2",
|
||||
"plotly>=6.8.0",
|
||||
"ipywidgets>=8.0.0",
|
||||
"requests>=2.34.2",
|
||||
# requests dropped Python 3.9 support at 2.33.0 (requires_python >=3.10),
|
||||
# so an unqualified >=2.34.2 floor is unsatisfiable on 3.9. Cap 3.9 to the
|
||||
# last 3.9-compatible release; 3.10+ is left unconstrained.
|
||||
"requests>=2.32.5,<2.33.0; python_version < '3.10'",
|
||||
"requests>=2.34.2; python_version >= '3.10'",
|
||||
"GitPython>=3.1.58",
|
||||
"chardet>=7.4.3",
|
||||
# chardet dropped Python 3.9 support at 6.0.0 (requires_python >=3.10), so
|
||||
# an unqualified >=7.4.3 floor is unsatisfiable on 3.9. Cap 3.9 to the last
|
||||
# 3.9-compatible release; 3.10+ is left unconstrained.
|
||||
"chardet>=5.2.0,<6.0.0; python_version < '3.10'",
|
||||
"chardet>=7.4.3; python_version >= '3.10'",
|
||||
"protobuf>=5.29.1,<8.0",
|
||||
"grpcio>=1.81.1",
|
||||
# grpcio dropped Python 3.9 support at 1.81.0 (requires_python >=3.10), so
|
||||
# an unqualified >=1.81.1 floor is unsatisfiable on 3.9. Cap 3.9 to the last
|
||||
# 3.9-compatible release; 3.10+ is left unconstrained.
|
||||
"grpcio>=1.80.0,<1.81.0; python_version < '3.10'",
|
||||
"grpcio>=1.81.1; python_version >= '3.10'",
|
||||
"beautifulsoup4>=4.15.0",
|
||||
"lxml>=6.1.1",
|
||||
"python-docx>=1.2.0",
|
||||
"openpyxl>=3.1.5",
|
||||
"pillow>=12.2.0",
|
||||
# pillow dropped Python 3.9 support at 12.0.0 (requires_python >=3.10), so
|
||||
# an unqualified >=12.2.0 floor is unsatisfiable on 3.9. Cap 3.9 to the last
|
||||
# 3.9-compatible release; 3.10+ is left unconstrained.
|
||||
"pillow>=11.3.0,<12.0.0; python_version < '3.10'",
|
||||
"pillow>=12.2.0; python_version >= '3.10'",
|
||||
"librosa>=0.9.0",
|
||||
"opencv-python>=4.13.0.92",
|
||||
"faiss-cpu>=1.7.0",
|
||||
"fastembed>=0.2.0",
|
||||
"onnxruntime>=1.20.1",
|
||||
# onnxruntime stopped shipping cp39 wheels at 1.20.0 (its PyPI metadata
|
||||
# still claims requires_python >=3.9, but no matching wheel exists), so an
|
||||
# unqualified >=1.20.1 floor is unsatisfiable on 3.9. Cap 3.9 to the last
|
||||
# release with a cp39 wheel; 3.10+ is left unconstrained.
|
||||
"onnxruntime>=1.19.2,<1.20.0; python_version < '3.10'",
|
||||
"onnxruntime>=1.20.1; python_version >= '3.10'",
|
||||
"tokenizers>=0.15.0",
|
||||
"pydantic>=2.13.4",
|
||||
"click>=8.4.2",
|
||||
# click dropped Python 3.9 support at 8.2.0 (requires_python >=3.10), so an
|
||||
# unqualified >=8.4.2 floor is unsatisfiable on 3.9. Cap 3.9 to the last
|
||||
# 3.9-compatible release; 3.10+ is left unconstrained.
|
||||
"click>=8.1.8,<8.2.0; python_version < '3.10'",
|
||||
"click>=8.4.2; python_version >= '3.10'",
|
||||
"rich>=12.5.0",
|
||||
"tqdm>=4.68.3",
|
||||
"pyyaml>=6.0",
|
||||
|
||||
@@ -65,9 +65,11 @@ import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
@@ -78,6 +80,12 @@ from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from ..utils.types import EntityDict, RelationshipDict
|
||||
from ._markdown_filesystem import find_filesystem_link
|
||||
from .markdown import (
|
||||
MarkdownIdentityError,
|
||||
MarkdownResourceNotFoundError,
|
||||
MarkdownRevisionConflictError,
|
||||
markdown_document_revision,
|
||||
)
|
||||
|
||||
|
||||
class _UniqueKeySafeLoader(yaml.SafeLoader):
|
||||
@@ -158,6 +166,17 @@ class MemoryItem:
|
||||
)
|
||||
|
||||
|
||||
def _with_memory_lock(method):
|
||||
"""Serialize AgentMemory state mutations and Markdown revision checks."""
|
||||
|
||||
@wraps(method)
|
||||
def locked(self, *args, **kwargs):
|
||||
with self._memory_lock:
|
||||
return method(self, *args, **kwargs)
|
||||
|
||||
return locked
|
||||
|
||||
|
||||
class AgentMemory:
|
||||
"""
|
||||
Agent memory manager with RAG integration and Hierarchical Memory.
|
||||
@@ -197,6 +216,7 @@ class AgentMemory:
|
||||
self.logger = get_logger("agent_memory")
|
||||
self.config = config or {}
|
||||
self.config.update(kwargs)
|
||||
self._memory_lock = threading.RLock()
|
||||
|
||||
self.vector_store = self.config.get("vector_store")
|
||||
self.knowledge_graph = self.config.get("knowledge_graph")
|
||||
@@ -249,6 +269,7 @@ class AgentMemory:
|
||||
|
||||
self.logger.info(f"Saved agent memory to {path}")
|
||||
|
||||
@_with_memory_lock
|
||||
def load(self, path: str) -> None:
|
||||
"""
|
||||
Load memory state from disk.
|
||||
@@ -298,6 +319,7 @@ class AgentMemory:
|
||||
|
||||
self.logger.info(f"Loaded agent memory from {path}")
|
||||
|
||||
@_with_memory_lock
|
||||
def store(
|
||||
self,
|
||||
content: str,
|
||||
@@ -574,6 +596,7 @@ class AgentMemory:
|
||||
"relationships": memory_item.relationships,
|
||||
}
|
||||
|
||||
@_with_memory_lock
|
||||
def delete_memory(self, memory_id: str, *, skip_vector: bool = False) -> bool:
|
||||
"""
|
||||
Delete memory item.
|
||||
@@ -625,6 +648,7 @@ class AgentMemory:
|
||||
self.logger.debug(f"Deleted memory item: {memory_id}")
|
||||
return True
|
||||
|
||||
@_with_memory_lock
|
||||
def vector_ids_for(self, memory_id: str) -> List[str]:
|
||||
"""Return the vector-store ids owned by a memory item.
|
||||
|
||||
@@ -1136,6 +1160,7 @@ class AgentMemory:
|
||||
"""
|
||||
return self.get_memory(memory_id)
|
||||
|
||||
@_with_memory_lock
|
||||
def update(
|
||||
self,
|
||||
memory_id: str,
|
||||
@@ -1416,6 +1441,13 @@ class AgentMemory:
|
||||
|
||||
return results
|
||||
|
||||
@_with_memory_lock
|
||||
def list_snapshot(
|
||||
self, *, limit: int = 100, offset: int = 0
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""Return one memory page and its total from the same locked state."""
|
||||
return self.list(limit=limit, offset=offset), len(self.memory_items)
|
||||
|
||||
def get_by_conversation(
|
||||
self, conversation_id: str, limit: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -1631,6 +1663,88 @@ class AgentMemory:
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
@_with_memory_lock
|
||||
def export_item_markdown(self, memory_id: str) -> str:
|
||||
"""Return one existing memory item as canonical Markdown.
|
||||
|
||||
Args:
|
||||
memory_id: Stable identifier of the memory item to export.
|
||||
|
||||
Returns:
|
||||
Canonical Markdown containing the memory frontmatter and body.
|
||||
|
||||
Raises:
|
||||
MarkdownResourceNotFoundError: If ``memory_id`` does not exist.
|
||||
"""
|
||||
memory = self.get(memory_id)
|
||||
if memory is None:
|
||||
raise MarkdownResourceNotFoundError(
|
||||
f"AgentMemory item {memory_id!r} was not found."
|
||||
)
|
||||
return self._memory_to_markdown(memory)
|
||||
|
||||
@_with_memory_lock
|
||||
def apply_item_markdown(
|
||||
self,
|
||||
memory_id: str,
|
||||
document: str,
|
||||
*,
|
||||
expected_revision: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Validate and atomically replace one existing memory item.
|
||||
|
||||
Args:
|
||||
memory_id: Stable identifier of the memory item to update.
|
||||
document: Canonical Markdown containing the replacement item.
|
||||
expected_revision: Optional revision returned by
|
||||
:meth:`export_item_markdown`. A mismatch rejects stale edits.
|
||||
|
||||
Returns:
|
||||
``True`` when the item changed, otherwise ``False``.
|
||||
|
||||
Raises:
|
||||
ValueError: If the Markdown or frontmatter is invalid.
|
||||
MarkdownIdentityError: If the frontmatter changes the memory ID.
|
||||
MarkdownResourceNotFoundError: If ``memory_id`` does not exist.
|
||||
MarkdownRevisionConflictError: If ``expected_revision`` is stale.
|
||||
RuntimeError: If the validated item cannot be persisted.
|
||||
"""
|
||||
memory = self._markdown_to_memory_dict(document, source=f"memory {memory_id!r}")
|
||||
document_id = memory["memory_id"]
|
||||
if document_id != memory_id:
|
||||
raise MarkdownIdentityError(
|
||||
f"Frontmatter id {document_id!r} does not match resource id "
|
||||
f"{memory_id!r}."
|
||||
)
|
||||
if not self.exists(memory_id):
|
||||
raise MarkdownResourceNotFoundError(
|
||||
f"AgentMemory item {memory_id!r} was not found."
|
||||
)
|
||||
if expected_revision is not None:
|
||||
# _memory_lock is an RLock; this re-entrant call into
|
||||
# export_item_markdown (also @_with_memory_lock) is intentional
|
||||
# and safe because RLock allows the same thread to re-acquire.
|
||||
current_revision = markdown_document_revision(
|
||||
self.export_item_markdown(memory_id)
|
||||
)
|
||||
if current_revision != expected_revision:
|
||||
raise MarkdownRevisionConflictError(current_revision)
|
||||
if self._markdown_record_matches(memory_id, memory):
|
||||
return False
|
||||
|
||||
success = self._replace_memory_item(
|
||||
memory_id,
|
||||
memory["content"],
|
||||
metadata=memory["metadata"],
|
||||
entities=memory["entities"],
|
||||
relationships=memory["relationships"],
|
||||
timestamp=memory["timestamp"],
|
||||
skip_graph=True,
|
||||
)
|
||||
if not success:
|
||||
raise RuntimeError(f"AgentMemory item {memory_id!r} could not be replaced.")
|
||||
return True
|
||||
|
||||
# Export/Import
|
||||
def export(
|
||||
self,
|
||||
@@ -1679,6 +1793,7 @@ class AgentMemory:
|
||||
return self._export_markdown(memories, destination=destination)
|
||||
return export_data
|
||||
|
||||
@_with_memory_lock
|
||||
def import_data(
|
||||
self, data: Union[str, Path, Dict[str, Any]], format: str = "json"
|
||||
) -> int:
|
||||
|
||||
@@ -131,6 +131,12 @@ from ..utils.progress_tracker import get_progress_tracker
|
||||
from ..utils.skos import is_skos_hierarchy_edge, validate_skos_hierarchy
|
||||
from ._markdown_filesystem import find_filesystem_link
|
||||
from .entity_linker import EntityLinker
|
||||
from .markdown import (
|
||||
MarkdownIdentityError,
|
||||
MarkdownResourceNotFoundError,
|
||||
MarkdownRevisionConflictError,
|
||||
markdown_document_revision,
|
||||
)
|
||||
|
||||
|
||||
class _UniqueKeySafeLoader(yaml.SafeLoader):
|
||||
@@ -1167,6 +1173,157 @@ class ContextGraph:
|
||||
)
|
||||
)
|
||||
|
||||
def export_node_markdown(self, node_id: str) -> str:
|
||||
"""Return one existing node as canonical Markdown.
|
||||
|
||||
Args:
|
||||
node_id: Stable identifier of the node to export.
|
||||
|
||||
Returns:
|
||||
Canonical Markdown containing the node frontmatter and body.
|
||||
|
||||
Raises:
|
||||
MarkdownResourceNotFoundError: If ``node_id`` does not exist.
|
||||
"""
|
||||
with self._lock:
|
||||
node = self.nodes.get(node_id)
|
||||
if node is None:
|
||||
raise MarkdownResourceNotFoundError(
|
||||
f"ContextGraph node {node_id!r} was not found."
|
||||
)
|
||||
return self._node_markdown_source(node)
|
||||
|
||||
def _node_markdown_source(self, node: ContextNode) -> str:
|
||||
frontmatter = {
|
||||
"id": node.node_id,
|
||||
"type": node.node_type,
|
||||
"properties": copy.deepcopy(node.properties),
|
||||
"metadata": copy.deepcopy(node.metadata),
|
||||
"valid_from": node.valid_from,
|
||||
"valid_until": node.valid_until,
|
||||
}
|
||||
return self._render_markdown_document(
|
||||
frontmatter, node.content, f"node {node.node_id!r}"
|
||||
)
|
||||
|
||||
def apply_node_markdown(
|
||||
self,
|
||||
node_id: str,
|
||||
document: str,
|
||||
*,
|
||||
expected_revision: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Validate and atomically replace one existing node.
|
||||
|
||||
Args:
|
||||
node_id: Stable identifier of the node to update.
|
||||
document: Canonical Markdown containing the replacement node.
|
||||
expected_revision: Optional revision returned by
|
||||
:meth:`export_node_markdown`. A mismatch rejects stale edits.
|
||||
|
||||
Returns:
|
||||
``True`` when the node changed, otherwise ``False``.
|
||||
|
||||
Raises:
|
||||
ValueError: If the Markdown or frontmatter is invalid.
|
||||
MarkdownIdentityError: If the frontmatter changes the node ID.
|
||||
MarkdownResourceNotFoundError: If ``node_id`` does not exist.
|
||||
MarkdownRevisionConflictError: If ``expected_revision`` is stale.
|
||||
"""
|
||||
source = f"node {node_id!r}"
|
||||
frontmatter, body = self._parse_markdown_document(document, source)
|
||||
candidate = self._parse_markdown_node(frontmatter, body, source)
|
||||
if candidate.node_id != node_id:
|
||||
raise MarkdownIdentityError(
|
||||
f"Frontmatter id {candidate.node_id!r} does not match resource id "
|
||||
f"{node_id!r}."
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
existing = self.nodes.get(node_id)
|
||||
if existing is None:
|
||||
raise MarkdownResourceNotFoundError(
|
||||
f"ContextGraph node {node_id!r} was not found."
|
||||
)
|
||||
if expected_revision is not None:
|
||||
current_revision = markdown_document_revision(
|
||||
self._node_markdown_source(existing)
|
||||
)
|
||||
if current_revision != expected_revision:
|
||||
raise MarkdownRevisionConflictError(current_revision)
|
||||
if existing == candidate:
|
||||
return False
|
||||
|
||||
# Decision index rebuilding can still reject YAML-valid property
|
||||
# shapes, so retain every affected structure until commit succeeds.
|
||||
decision_state_before = {}
|
||||
if (
|
||||
existing.node_type.lower() == "decision"
|
||||
or candidate.node_type.lower() == "decision"
|
||||
):
|
||||
for attribute in (
|
||||
"_decisions",
|
||||
"_decision_index",
|
||||
"_entity_index",
|
||||
"_temporal_index",
|
||||
):
|
||||
if not hasattr(self, attribute):
|
||||
decision_state_before[attribute] = None
|
||||
elif attribute in {"_decision_index", "_entity_index"}:
|
||||
decision_state_before[attribute] = {
|
||||
key: set(values)
|
||||
for key, values in getattr(self, attribute).items()
|
||||
}
|
||||
elif attribute == "_temporal_index":
|
||||
decision_state_before[attribute] = list(
|
||||
getattr(self, attribute)
|
||||
)
|
||||
else:
|
||||
decision_state_before[attribute] = dict(
|
||||
getattr(self, attribute)
|
||||
)
|
||||
|
||||
old_type = existing.node_type
|
||||
try:
|
||||
old_bucket = self.node_type_index.get(old_type)
|
||||
if old_bucket is not None:
|
||||
old_bucket.discard(node_id)
|
||||
if not old_bucket:
|
||||
del self.node_type_index[old_type]
|
||||
|
||||
self.nodes[node_id] = candidate
|
||||
self.node_type_index[candidate.node_type].add(node_id)
|
||||
if (
|
||||
old_type.lower() == "decision"
|
||||
or candidate.node_type.lower() == "decision"
|
||||
):
|
||||
self._sync_decision_from_node(node_id)
|
||||
payload = candidate.to_dict()
|
||||
self._analytics_cache.clear()
|
||||
except Exception:
|
||||
self.nodes[node_id] = existing
|
||||
candidate_bucket = self.node_type_index.get(candidate.node_type)
|
||||
if candidate_bucket is not None:
|
||||
candidate_bucket.discard(node_id)
|
||||
if not candidate_bucket:
|
||||
del self.node_type_index[candidate.node_type]
|
||||
self.node_type_index[old_type].add(node_id)
|
||||
for attribute, state in decision_state_before.items():
|
||||
if state is None:
|
||||
if hasattr(self, attribute):
|
||||
delattr(self, attribute)
|
||||
else:
|
||||
restored = (
|
||||
defaultdict(set, state)
|
||||
if attribute in {"_decision_index", "_entity_index"}
|
||||
else state
|
||||
)
|
||||
setattr(self, attribute, restored)
|
||||
raise
|
||||
|
||||
self._emit_mutation("UPDATE_NODE", node_id, payload)
|
||||
return True
|
||||
|
||||
def save_to_file(
|
||||
self, path: Union[str, Path], format: str = "json"
|
||||
) -> None:
|
||||
@@ -4947,32 +5104,42 @@ class ContextGraph:
|
||||
def _sync_decision_from_node(self, node_id: str) -> None:
|
||||
"""Synchronise a single decision index entry from the node store.
|
||||
|
||||
Called after ``add_node_attribute`` mutates a decision node so that
|
||||
``_decisions`` and the derived indexes stay consistent without
|
||||
requiring a full rebuild of all decisions.
|
||||
Called after ``add_node_attribute`` mutates a decision node and after
|
||||
``apply_node_markdown`` replaces a node whose old or new type is
|
||||
``"decision"``, so that ``_decisions`` and the derived indexes stay
|
||||
consistent without requiring a full rebuild of all decisions.
|
||||
|
||||
Temporal index cleanup (``_temporal_index``) runs unconditionally
|
||||
before the node-type guard so that stale entries are removed even when
|
||||
transitioning a decision node to a non-decision type. Callers are
|
||||
expected to ensure this is only invoked when at least one of the
|
||||
current or previous node types is ``"decision"``; callers that bypass
|
||||
that invariant will have ``node_id`` silently removed from
|
||||
``_temporal_index`` even if it was never a decision node.
|
||||
"""
|
||||
node = self.nodes.get(node_id)
|
||||
if node is None:
|
||||
return
|
||||
if (getattr(node, "node_type", None) or "").lower() != "decision":
|
||||
return
|
||||
|
||||
if not hasattr(self, "_decisions"):
|
||||
# Indexes don't exist yet — a full rebuild is safer.
|
||||
self._rebuild_decision_indexes()
|
||||
return
|
||||
|
||||
# Remove stale index entries for this decision ID.
|
||||
old = self._decisions.get(node_id)
|
||||
# Remove stale index entries before deciding whether the current node
|
||||
# still belongs in the decision indexes.
|
||||
old = self._decisions.pop(node_id, None)
|
||||
if old:
|
||||
old_cat = old.get("category", "")
|
||||
if old_cat and node_id in self._decision_index.get(old_cat, set()):
|
||||
if old_cat:
|
||||
self._decision_index[old_cat].discard(node_id)
|
||||
for ent in old.get("entities", []):
|
||||
self._entity_index[ent].discard(node_id)
|
||||
self._temporal_index = [
|
||||
(nid, ts) for nid, ts in self._temporal_index if nid != node_id
|
||||
]
|
||||
self._temporal_index = [
|
||||
(nid, ts) for nid, ts in self._temporal_index if nid != node_id
|
||||
]
|
||||
|
||||
if node is None:
|
||||
return
|
||||
if (getattr(node, "node_type", None) or "").lower() != "decision":
|
||||
return
|
||||
|
||||
# Rebuild the entry for this node and re-insert index entries.
|
||||
meta: Dict[str, Any] = {}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Shared revision helpers and errors for single-resource Markdown operations."""
|
||||
|
||||
import hashlib
|
||||
|
||||
|
||||
class MarkdownResourceNotFoundError(KeyError):
|
||||
"""Raised when a Markdown operation targets a missing resource."""
|
||||
|
||||
|
||||
class MarkdownIdentityError(ValueError):
|
||||
"""Raised when frontmatter changes a resource's stable identity."""
|
||||
|
||||
|
||||
class MarkdownRevisionConflictError(ValueError):
|
||||
"""Raised when a resource changed after an edit session began."""
|
||||
|
||||
def __init__(self, current_revision: str) -> None:
|
||||
super().__init__("Markdown resource revision does not match.")
|
||||
self.current_revision = current_revision
|
||||
|
||||
|
||||
def markdown_document_revision(source: str) -> str:
|
||||
"""Return the stable revision token for a canonical Markdown document."""
|
||||
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()
|
||||
return f"sha256:{digest}"
|
||||
+30
-74
@@ -8,16 +8,19 @@ from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .. import __version__
|
||||
from ..context.agent_memory import AgentMemory
|
||||
from ..context.context_graph import ContextGraph
|
||||
from .dependencies import anonymous_access_allowed, get_expected_api_key, is_valid_api_key, require_auth
|
||||
from .dependencies import anonymous_access_allowed, get_expected_api_key, require_auth
|
||||
from .markdown_resources import MarkdownResourceRegistry
|
||||
from .runtime import explorer_capabilities, install_mutation_bridge
|
||||
from .session import GraphSession
|
||||
from .ws import ConnectionManager
|
||||
from .ws import ConnectionManager, install_graph_updates_websocket
|
||||
|
||||
|
||||
def _read_int_env(name: str, default: int) -> int:
|
||||
@@ -53,37 +56,23 @@ def _read_explorer_settings() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
|
||||
if getattr(session.graph, "_mutation_bridge_installed", False):
|
||||
return
|
||||
session.graph._mutation_bridge_installed = True
|
||||
previous_callback = getattr(session.graph, "mutation_callback", None)
|
||||
|
||||
def on_mutation(event_type: str, entity_id: str, payload: dict) -> None:
|
||||
session.handle_graph_mutation(event_type, entity_id, payload)
|
||||
if callable(previous_callback):
|
||||
previous_callback(event_type, entity_id, payload)
|
||||
loop = getattr(app.state, "event_loop", None)
|
||||
manager = getattr(app.state, "ws_manager", None)
|
||||
if loop is None or manager is None or loop.is_closed():
|
||||
return
|
||||
message = {
|
||||
"event_type": event_type,
|
||||
"entity_id": entity_id,
|
||||
"payload": payload,
|
||||
}
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
manager.broadcast("graph_mutation", message),
|
||||
loop,
|
||||
)
|
||||
|
||||
session.graph.mutation_callback = on_mutation
|
||||
|
||||
|
||||
def create_app(
|
||||
session: Optional[GraphSession] = None,
|
||||
provenance_storage_path: Optional[str] = None,
|
||||
agent_memory: Optional[AgentMemory] = None,
|
||||
) -> FastAPI:
|
||||
"""Create an Explorer application over live graph and memory objects.
|
||||
|
||||
Args:
|
||||
session: Graph session exposed by the Explorer. A new in-memory graph
|
||||
session is created when omitted.
|
||||
provenance_storage_path: Optional per-app provenance database path.
|
||||
agent_memory: Existing AgentMemory instance to expose in the Memories
|
||||
workspace. The workspace is unavailable when omitted.
|
||||
|
||||
Returns:
|
||||
Configured FastAPI application.
|
||||
"""
|
||||
settings = _read_explorer_settings()
|
||||
prov_path = provenance_storage_path or settings.get("provenance_storage_path")
|
||||
if session is None:
|
||||
@@ -95,6 +84,7 @@ def create_app(
|
||||
active_session = session
|
||||
if prov_path is not None:
|
||||
active_session.set_provenance_storage_path(prov_path)
|
||||
markdown_resources = MarkdownResourceRegistry(active_session.graph, agent_memory)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -117,7 +107,9 @@ def create_app(
|
||||
app.state.event_loop = asyncio.get_running_loop()
|
||||
app.state.ws_manager = ConnectionManager()
|
||||
app.state.session = active_session
|
||||
_install_mutation_bridge(app, active_session)
|
||||
app.state.agent_memory = agent_memory
|
||||
app.state.markdown_resources = markdown_resources
|
||||
install_mutation_bridge(app, active_session)
|
||||
yield
|
||||
|
||||
app = FastAPI(
|
||||
@@ -139,7 +131,7 @@ def create_app(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings["allowed_origins"],
|
||||
allow_credentials=_allow_credentials,
|
||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
|
||||
max_age=600,
|
||||
)
|
||||
@@ -170,6 +162,8 @@ def create_app(
|
||||
from .routes.enrich import router as enrich_router
|
||||
from .routes.export_import import router as export_import_router
|
||||
from .routes.graph import router as graph_router
|
||||
from .routes.markdown import router as markdown_router
|
||||
from .routes.memories import router as memories_router
|
||||
from .routes.ontology import router as ontology_router
|
||||
from .routes.provenance import router as provenance_router
|
||||
from .routes.sparql import router as sparql_router
|
||||
@@ -183,54 +177,15 @@ def create_app(
|
||||
app.include_router(temporal_router, dependencies=_auth)
|
||||
app.include_router(enrich_router, dependencies=_auth)
|
||||
app.include_router(export_import_router, dependencies=_auth)
|
||||
app.include_router(markdown_router, dependencies=_auth)
|
||||
app.include_router(memories_router, dependencies=_auth)
|
||||
app.include_router(annotations_router, dependencies=_auth)
|
||||
app.include_router(sparql_router, dependencies=_auth)
|
||||
app.include_router(provenance_router, dependencies=_auth)
|
||||
app.include_router(vocabulary_router, dependencies=_auth)
|
||||
app.include_router(ontology_router, dependencies=_auth)
|
||||
|
||||
_WS_MAX_MESSAGE_BYTES = 64 * 1024 # 64 KB — control messages only
|
||||
|
||||
@app.websocket("/ws/graph-updates")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
# CORSMiddleware doesn't cover WebSocket handshakes (Starlette's
|
||||
# CORS support only wraps HTTP), so under SEMANTICA_ALLOW_ANONYMOUS
|
||||
# the key check below accepts any origin — loopback binding isn't a
|
||||
# boundary against a browser, since any page the operator has open
|
||||
# can still reach ws://localhost:.../ws/graph-updates directly.
|
||||
# Reject a foreign Origin explicitly here, against the same
|
||||
# allowlist CORSMiddleware already enforces for HTTP
|
||||
# (GHSA-4643-wpgq-w329). Browsers always send Origin on a
|
||||
# cross-origin WebSocket handshake; native/CLI clients omit it
|
||||
# entirely, so a missing Origin is allowed through — the browser is
|
||||
# the only threat this check is closing.
|
||||
origin = websocket.headers.get("origin")
|
||||
allowed_origins = app.state.explorer_settings["allowed_origins"]
|
||||
if origin is not None and origin not in allowed_origins:
|
||||
await websocket.close(code=4403) # forbidden
|
||||
return
|
||||
|
||||
# Browsers can't set custom headers on a WebSocket handshake, so
|
||||
# accept the key via header (non-browser clients) or query param
|
||||
# (browser clients), same SEMANTICA_API_KEY the REST routes check.
|
||||
candidate = websocket.headers.get("x-api-key") or websocket.query_params.get("api_key")
|
||||
if not is_valid_api_key(candidate):
|
||||
await websocket.close(code=4401) # unauthorized
|
||||
return
|
||||
|
||||
manager: ConnectionManager = app.state.ws_manager
|
||||
await manager.connect(websocket)
|
||||
await manager.send_personal(websocket, "connection_ack", {"connected": True})
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive_text()
|
||||
if len(message) > _WS_MAX_MESSAGE_BYTES:
|
||||
await websocket.close(code=1009) # 1009 = message too big
|
||||
break
|
||||
if message.strip().lower() == "ping":
|
||||
await manager.send_personal(websocket, "pong", {"ok": True})
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
install_graph_updates_websocket(app, settings["allowed_origins"])
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
@@ -269,6 +224,7 @@ def create_app(
|
||||
"name": "Semantica Knowledge Explorer",
|
||||
"version": __version__,
|
||||
"status": "active",
|
||||
"capabilities": explorer_capabilities(agent_memory),
|
||||
}
|
||||
|
||||
static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||
|
||||
@@ -14,6 +14,8 @@ from typing import Optional
|
||||
from fastapi import Request, HTTPException, Security, status
|
||||
from fastapi.security.api_key import APIKeyHeader
|
||||
|
||||
from ..context.agent_memory import AgentMemory
|
||||
from .markdown_resources import MarkdownResourceRegistry
|
||||
from .session import GraphSession
|
||||
|
||||
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
@@ -80,3 +82,25 @@ def get_session(request: Request) -> GraphSession:
|
||||
detail="GraphSession not initialized."
|
||||
)
|
||||
return request.app.state.session
|
||||
|
||||
|
||||
def get_markdown_resources(request: Request) -> MarkdownResourceRegistry:
|
||||
"""Retrieve the Markdown resource registry stored on ``app.state``."""
|
||||
resources = getattr(request.app.state, "markdown_resources", None)
|
||||
if resources is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Markdown resources are not initialized.",
|
||||
)
|
||||
return resources
|
||||
|
||||
|
||||
def get_agent_memory(request: Request) -> AgentMemory:
|
||||
"""Retrieve the optional AgentMemory configured for Explorer."""
|
||||
memory = getattr(request.app.state, "agent_memory", None)
|
||||
if memory is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="AgentMemory is not configured for this Explorer instance.",
|
||||
)
|
||||
return memory
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Single-resource Markdown access for Explorer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Callable, Dict, Optional, Protocol, TypeVar
|
||||
|
||||
import yaml
|
||||
|
||||
from ..context.agent_memory import AgentMemory
|
||||
from ..context.context_graph import ContextGraph
|
||||
from ..context.markdown import (
|
||||
MarkdownIdentityError,
|
||||
MarkdownResourceNotFoundError,
|
||||
MarkdownRevisionConflictError,
|
||||
markdown_document_revision,
|
||||
)
|
||||
|
||||
|
||||
class MarkdownResourceKind(str, Enum):
|
||||
CONTEXT_NODE = "context-node"
|
||||
AGENT_MEMORY = "agent-memory"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkdownResourceRef:
|
||||
kind: MarkdownResourceKind
|
||||
id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkdownDocument:
|
||||
resource: MarkdownResourceRef
|
||||
source: str
|
||||
body: str
|
||||
revision: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkdownApplyResult(MarkdownDocument):
|
||||
changed: bool
|
||||
|
||||
|
||||
class MarkdownResourceError(Exception):
|
||||
"""Base class for safe, structured Explorer Markdown failures."""
|
||||
|
||||
code = "markdown_resource_error"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
field: Optional[str] = None,
|
||||
current_revision: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.field = field
|
||||
self.current_revision = current_revision
|
||||
|
||||
|
||||
class MarkdownResourceNotFound(MarkdownResourceError):
|
||||
code = "markdown_resource_not_found"
|
||||
|
||||
|
||||
class InvalidMarkdownFrontmatter(MarkdownResourceError):
|
||||
code = "invalid_markdown_frontmatter"
|
||||
|
||||
|
||||
class ResourceIdentityMismatch(MarkdownResourceError):
|
||||
code = "resource_identity_mismatch"
|
||||
|
||||
|
||||
class MarkdownRevisionConflict(MarkdownResourceError):
|
||||
code = "markdown_revision_conflict"
|
||||
|
||||
|
||||
class MarkdownSaveFailed(MarkdownResourceError):
|
||||
code = "markdown_save_failed"
|
||||
|
||||
|
||||
class MarkdownAdapter(Protocol):
|
||||
def export(self, resource_id: str) -> str:
|
||||
...
|
||||
|
||||
def apply(self, resource_id: str, source: str, expected_revision: str) -> bool:
|
||||
...
|
||||
|
||||
|
||||
_Result = TypeVar("_Result")
|
||||
|
||||
|
||||
def _translate_domain_errors(operation: Callable[[], _Result]) -> _Result:
|
||||
try:
|
||||
return operation()
|
||||
except MarkdownResourceNotFoundError as exc:
|
||||
raise MarkdownResourceNotFound(str(exc.args[0])) from exc
|
||||
except MarkdownRevisionConflictError as exc:
|
||||
raise MarkdownRevisionConflict(
|
||||
"This item changed after editing began. Reload the latest "
|
||||
"version before applying.",
|
||||
current_revision=exc.current_revision,
|
||||
) from exc
|
||||
except MarkdownIdentityError as exc:
|
||||
raise ResourceIdentityMismatch(str(exc), field="id") from exc
|
||||
except ValueError as exc:
|
||||
raise InvalidMarkdownFrontmatter(_safe_validation_message(exc)) from exc
|
||||
|
||||
|
||||
class ContextGraphNodeMarkdownAdapter:
|
||||
def __init__(self, graph: ContextGraph) -> None:
|
||||
self._graph = graph
|
||||
|
||||
def export(self, resource_id: str) -> str:
|
||||
return _translate_domain_errors(
|
||||
lambda: self._graph.export_node_markdown(resource_id)
|
||||
)
|
||||
|
||||
def apply(self, resource_id: str, source: str, expected_revision: str) -> bool:
|
||||
return _translate_domain_errors(
|
||||
lambda: self._graph.apply_node_markdown(
|
||||
resource_id,
|
||||
source,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class AgentMemoryItemMarkdownAdapter:
|
||||
def __init__(self, memory: AgentMemory) -> None:
|
||||
self._memory = memory
|
||||
|
||||
def export(self, resource_id: str) -> str:
|
||||
return _translate_domain_errors(
|
||||
lambda: self._memory.export_item_markdown(resource_id)
|
||||
)
|
||||
|
||||
def apply(self, resource_id: str, source: str, expected_revision: str) -> bool:
|
||||
return _translate_domain_errors(
|
||||
lambda: self._memory.apply_item_markdown(
|
||||
resource_id,
|
||||
source,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _safe_validation_message(exc: ValueError) -> str:
|
||||
if isinstance(exc.__cause__, yaml.YAMLError):
|
||||
return "Markdown frontmatter contains invalid YAML."
|
||||
return str(exc)
|
||||
|
||||
|
||||
def document_revision(source: str) -> str:
|
||||
"""Return the stable revision token for canonical Markdown.
|
||||
|
||||
Args:
|
||||
source: Canonical Markdown source.
|
||||
|
||||
Returns:
|
||||
A SHA-256 revision token suitable for optimistic concurrency checks.
|
||||
"""
|
||||
return markdown_document_revision(source)
|
||||
|
||||
|
||||
def markdown_body(source: str) -> str:
|
||||
"""Extract the body from canonical Markdown emitted by a domain model."""
|
||||
lines = source.splitlines(keepends=True)
|
||||
if not lines or lines[0].rstrip("\r\n") != "---":
|
||||
raise MarkdownSaveFailed("The resource produced invalid canonical Markdown.")
|
||||
closing_index = next(
|
||||
(
|
||||
index
|
||||
for index, line in enumerate(lines[1:], start=1)
|
||||
if line.rstrip("\r\n") == "---"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if closing_index is None:
|
||||
raise MarkdownSaveFailed("The resource produced invalid canonical Markdown.")
|
||||
body = "".join(lines[closing_index + 1 :])
|
||||
if body.startswith("\r\n"):
|
||||
return body[2:]
|
||||
if body.startswith("\n"):
|
||||
return body[1:]
|
||||
return body
|
||||
|
||||
|
||||
class MarkdownResourceRegistry:
|
||||
"""Route Markdown operations to their owning domain models."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context_graph: ContextGraph,
|
||||
agent_memory: Optional[AgentMemory] = None,
|
||||
) -> None:
|
||||
self._adapters: Dict[MarkdownResourceKind, MarkdownAdapter] = {
|
||||
MarkdownResourceKind.CONTEXT_NODE: ContextGraphNodeMarkdownAdapter(
|
||||
context_graph
|
||||
)
|
||||
}
|
||||
if agent_memory is not None:
|
||||
self._adapters[
|
||||
MarkdownResourceKind.AGENT_MEMORY
|
||||
] = AgentMemoryItemMarkdownAdapter(agent_memory)
|
||||
|
||||
def _adapter(self, kind: MarkdownResourceKind) -> MarkdownAdapter:
|
||||
adapter = self._adapters.get(kind)
|
||||
if adapter is None:
|
||||
raise MarkdownResourceNotFound(
|
||||
f"Markdown resource kind {kind.value!r} is not available."
|
||||
)
|
||||
return adapter
|
||||
|
||||
def read(self, ref: MarkdownResourceRef) -> MarkdownDocument:
|
||||
"""Read one resource as canonical Markdown.
|
||||
|
||||
Args:
|
||||
ref: Explicit resource kind and stable identifier.
|
||||
|
||||
Returns:
|
||||
The canonical source, body, and current revision.
|
||||
|
||||
Raises:
|
||||
MarkdownResourceError: If the resource is unavailable or invalid.
|
||||
"""
|
||||
try:
|
||||
source = self._adapter(ref.kind).export(ref.id)
|
||||
except MarkdownResourceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise MarkdownSaveFailed(
|
||||
"The Markdown resource could not be read."
|
||||
) from exc
|
||||
return MarkdownDocument(
|
||||
resource=ref,
|
||||
source=source,
|
||||
body=markdown_body(source),
|
||||
revision=document_revision(source),
|
||||
)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
ref: MarkdownResourceRef,
|
||||
markdown: str,
|
||||
expected_revision: str,
|
||||
) -> MarkdownApplyResult:
|
||||
"""Apply validated Markdown to one existing resource.
|
||||
|
||||
Args:
|
||||
ref: Explicit resource kind and stable identifier.
|
||||
markdown: Replacement canonical Markdown.
|
||||
expected_revision: Revision observed when editing began.
|
||||
|
||||
Returns:
|
||||
The saved canonical document and whether it changed.
|
||||
|
||||
Raises:
|
||||
MarkdownResourceError: If validation, identity, persistence, or
|
||||
revision checks fail.
|
||||
"""
|
||||
try:
|
||||
changed = self._adapter(ref.kind).apply(
|
||||
ref.id,
|
||||
markdown,
|
||||
expected_revision,
|
||||
)
|
||||
except MarkdownResourceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise MarkdownSaveFailed(
|
||||
"The edit could not be applied. The existing item was not changed."
|
||||
) from exc
|
||||
|
||||
saved = self.read(ref)
|
||||
return MarkdownApplyResult(
|
||||
resource=saved.resource,
|
||||
source=saved.source,
|
||||
body=saved.body,
|
||||
revision=saved.revision,
|
||||
changed=changed,
|
||||
)
|
||||
@@ -87,6 +87,13 @@ def _node_response(node: dict) -> NodeResponse:
|
||||
return NodeResponse(**node)
|
||||
|
||||
|
||||
async def _get_node_or_404(node_id: str, session: GraphSession) -> NodeResponse:
|
||||
node = await asyncio.to_thread(session.get_node, node_id)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
return _node_response(node)
|
||||
|
||||
|
||||
def _edge_response(edge: dict) -> EdgeResponse:
|
||||
return EdgeResponse(**edge)
|
||||
|
||||
@@ -121,15 +128,20 @@ async def list_nodes(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/node", response_model=NodeResponse)
|
||||
async def get_node_by_query(
|
||||
node_id: str = Query(..., description="Exact node ID"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
return await _get_node_or_404(node_id, session)
|
||||
|
||||
|
||||
@router.get("/node/{node_id}", response_model=NodeResponse)
|
||||
async def get_node(
|
||||
node_id: str,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
node = await asyncio.to_thread(session.get_node, node_id)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
return _node_response(node)
|
||||
return await _get_node_or_404(node_id, session)
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/neighbors", response_model=list[NeighborResponse])
|
||||
@@ -441,7 +453,7 @@ async def distance_matrix(
|
||||
embeddings = _get_cached_embeddings(session)
|
||||
src_embedding = embeddings.get(src)
|
||||
tgt_embedding = embeddings.get(tgt)
|
||||
|
||||
|
||||
if src_embedding is None or tgt_embedding is None:
|
||||
val = None
|
||||
else:
|
||||
@@ -451,7 +463,7 @@ async def distance_matrix(
|
||||
tgt_vec = np.array(tgt_embedding)
|
||||
sim = np.dot(src_vec, tgt_vec) / (np.linalg.norm(src_vec) * np.linalg.norm(tgt_vec))
|
||||
val = 1.0 - float(sim) if isinstance(sim, (int, float)) else None
|
||||
|
||||
|
||||
matrix[i][j] = val
|
||||
matrix[j][i] = val
|
||||
elif path_finder is not None:
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Explorer routes for canonical Markdown resources."""
|
||||
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from ..dependencies import get_markdown_resources
|
||||
from ..markdown_resources import (
|
||||
InvalidMarkdownFrontmatter,
|
||||
MarkdownApplyResult,
|
||||
MarkdownDocument,
|
||||
MarkdownResourceError,
|
||||
MarkdownResourceKind,
|
||||
MarkdownResourceNotFound,
|
||||
MarkdownResourceRef,
|
||||
MarkdownResourceRegistry,
|
||||
MarkdownRevisionConflict,
|
||||
ResourceIdentityMismatch,
|
||||
)
|
||||
from ..schemas import (
|
||||
MarkdownApplyRequest,
|
||||
MarkdownApplyResponse,
|
||||
MarkdownDocumentResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/markdown", tags=["markdown"])
|
||||
|
||||
|
||||
def _resource_ref(kind: str, resource_id: str) -> MarkdownResourceRef:
|
||||
try:
|
||||
resource_kind = MarkdownResourceKind(kind)
|
||||
except ValueError:
|
||||
_raise_http_error(
|
||||
MarkdownResourceNotFound(
|
||||
f"Markdown resource kind {kind!r} is not available."
|
||||
)
|
||||
)
|
||||
return MarkdownResourceRef(kind=resource_kind, id=resource_id)
|
||||
|
||||
|
||||
def _error_detail(error: MarkdownResourceError) -> dict:
|
||||
detail = {"code": error.code, "message": error.message}
|
||||
if error.field is not None:
|
||||
detail["field"] = error.field
|
||||
if error.current_revision is not None:
|
||||
detail["current_revision"] = error.current_revision
|
||||
return detail
|
||||
|
||||
|
||||
def _raise_http_error(error: MarkdownResourceError) -> NoReturn:
|
||||
if isinstance(error, MarkdownResourceNotFound):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
elif isinstance(error, MarkdownRevisionConflict):
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
elif isinstance(error, (InvalidMarkdownFrontmatter, ResourceIdentityMismatch)):
|
||||
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
else:
|
||||
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
raise HTTPException(status_code=status_code, detail=_error_detail(error)) from error
|
||||
|
||||
|
||||
def _document_response(
|
||||
document: MarkdownDocument,
|
||||
) -> MarkdownDocumentResponse:
|
||||
return MarkdownDocumentResponse(
|
||||
resource={
|
||||
"kind": document.resource.kind.value,
|
||||
"id": document.resource.id,
|
||||
},
|
||||
source=document.source,
|
||||
body=document.body,
|
||||
revision=document.revision,
|
||||
editable=True,
|
||||
)
|
||||
|
||||
|
||||
def _apply_response(result: MarkdownApplyResult) -> MarkdownApplyResponse:
|
||||
document = _document_response(result)
|
||||
return MarkdownApplyResponse(**document.model_dump(), changed=result.changed)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{kind}/{resource_id:path}",
|
||||
response_model=MarkdownDocumentResponse,
|
||||
)
|
||||
def read_markdown_resource(
|
||||
kind: str,
|
||||
resource_id: str,
|
||||
resources: MarkdownResourceRegistry = Depends(get_markdown_resources),
|
||||
) -> MarkdownDocumentResponse:
|
||||
try:
|
||||
return _document_response(resources.read(_resource_ref(kind, resource_id)))
|
||||
except MarkdownResourceError as error:
|
||||
_raise_http_error(error)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{kind}/{resource_id:path}",
|
||||
response_model=MarkdownApplyResponse,
|
||||
)
|
||||
def apply_markdown_resource(
|
||||
kind: str,
|
||||
resource_id: str,
|
||||
request: MarkdownApplyRequest,
|
||||
resources: MarkdownResourceRegistry = Depends(get_markdown_resources),
|
||||
) -> MarkdownApplyResponse:
|
||||
try:
|
||||
result = resources.apply(
|
||||
_resource_ref(kind, resource_id),
|
||||
request.markdown,
|
||||
request.expected_revision,
|
||||
)
|
||||
return _apply_response(result)
|
||||
except MarkdownResourceError as error:
|
||||
_raise_http_error(error)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Minimal AgentMemory selection surface for Explorer."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from ...context.agent_memory import AgentMemory
|
||||
from ..dependencies import get_agent_memory
|
||||
from ..schemas import MemoryListResponse, MemorySummaryResponse
|
||||
|
||||
router = APIRouter(prefix="/api/memories", tags=["memories"])
|
||||
|
||||
|
||||
@router.get("", response_model=MemoryListResponse)
|
||||
def list_memories(
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=100),
|
||||
memory: AgentMemory = Depends(get_agent_memory),
|
||||
) -> MemoryListResponse:
|
||||
records, total = memory.list_snapshot(offset=skip, limit=limit)
|
||||
items = []
|
||||
for record in records:
|
||||
metadata = record.get("metadata") or {}
|
||||
content = record.get("content") or ""
|
||||
excerpt = " ".join(str(content).split())[:160]
|
||||
items.append(
|
||||
MemorySummaryResponse(
|
||||
id=record["memory_id"],
|
||||
type=str(metadata.get("type") or "general"),
|
||||
excerpt=excerpt,
|
||||
updated_at=(
|
||||
str(metadata["updated_at"])
|
||||
if metadata.get("updated_at") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return MemoryListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -234,6 +234,12 @@ class EntityDetailResponse(BaseModel):
|
||||
properties: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OntologyGraphResponse(BaseModel):
|
||||
uri: str
|
||||
nodes: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
edges: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SKOSScheme(BaseModel):
|
||||
uri: str
|
||||
title: str
|
||||
@@ -664,6 +670,7 @@ def _convert_ontology_to_graph(ontology_dict: Dict[str, Any]) -> Tuple[List[Dict
|
||||
"rdfs:label": cls.get("label", cls.get("name", "")),
|
||||
"rdfs:comment": cls.get("description", ""),
|
||||
"uri": cls_uri,
|
||||
"scheme_uri": ontology_uri,
|
||||
},
|
||||
}
|
||||
nodes.append(node)
|
||||
@@ -680,14 +687,21 @@ def _convert_ontology_to_graph(ontology_dict: Dict[str, Any]) -> Tuple[List[Dict
|
||||
# Add property nodes and edges
|
||||
for prop in ontology_dict.get("properties", []):
|
||||
prop_uri = prop.get("uri", f"temp:prop:{uuid.uuid4().hex[:12]}")
|
||||
property_type = {
|
||||
"object": "owl:ObjectProperty",
|
||||
"data": "owl:DatatypeProperty",
|
||||
"datatype": "owl:DatatypeProperty",
|
||||
"annotation": "owl:AnnotationProperty",
|
||||
}.get(str(prop.get("type", "object")).lower(), "owl:ObjectProperty")
|
||||
node = {
|
||||
"id": prop_uri,
|
||||
"type": f"owl:{prop.get('type', 'Object').title()}Property",
|
||||
"type": property_type,
|
||||
"content": prop.get("name", prop.get("label", "")),
|
||||
"properties": {
|
||||
"rdfs:label": prop.get("label", prop.get("name", "")),
|
||||
"rdfs:comment": prop.get("description", ""),
|
||||
"uri": prop_uri,
|
||||
"scheme_uri": ontology_uri,
|
||||
},
|
||||
}
|
||||
nodes.append(node)
|
||||
@@ -748,14 +762,38 @@ def _node_source_ontology(node: Dict[str, Any]) -> Optional[str]:
|
||||
)
|
||||
|
||||
|
||||
def _node_belongs_to_ontology(node: Dict[str, Any], ontology_uri: str) -> bool:
|
||||
def _node_belongs_to_ontology(
|
||||
node: Dict[str, Any],
|
||||
ontology_uri: str,
|
||||
known_ontology_uris: Optional[set[str]] = None,
|
||||
) -> bool:
|
||||
nid = node.get("id", "")
|
||||
if nid == ontology_uri:
|
||||
return True
|
||||
if _node_source_ontology(node) == ontology_uri:
|
||||
return True
|
||||
owner = _node_source_ontology(node)
|
||||
if owner:
|
||||
return owner == ontology_uri
|
||||
if known_ontology_uris:
|
||||
namespace_owners = [
|
||||
candidate
|
||||
for candidate in known_ontology_uris
|
||||
if nid == candidate
|
||||
or nid.startswith(
|
||||
(candidate.rstrip("#/") + "#", candidate.rstrip("#/") + "/")
|
||||
)
|
||||
]
|
||||
if namespace_owners and max(namespace_owners, key=len) != ontology_uri:
|
||||
return False
|
||||
stem = ontology_uri.rstrip("#/")
|
||||
return nid.startswith((stem + "#", stem + "/"))
|
||||
if not nid.startswith((stem + "#", stem + "/")):
|
||||
return False
|
||||
# Prefix ownership only extends to names minted directly in the
|
||||
# ontology's namespace (<stem>#Term or <stem>/Term). Any further
|
||||
# delimiter marks a nested vocabulary (<stem>/child#Term,
|
||||
# <stem>/child/Term), which must not be absorbed into the parent
|
||||
# until it is registered or carries an explicit owner.
|
||||
local_name = nid[len(stem) + 1 :]
|
||||
return "#" not in local_name and "/" not in local_name
|
||||
|
||||
|
||||
def _is_ontology_entity(node: Dict[str, Any]) -> bool:
|
||||
@@ -1221,7 +1259,8 @@ def _parse_rdf_sync(content: bytes, fmt: str) -> tuple:
|
||||
metadata.setdefault("description", str(obj))
|
||||
break
|
||||
|
||||
if "uri" not in metadata:
|
||||
synthetic_uri = "uri" not in metadata
|
||||
if synthetic_uri:
|
||||
metadata["uri"] = f"urn:semantica:onto:{uuid.uuid4().hex[:8]}"
|
||||
metadata.setdefault("name", metadata["uri"].rsplit("/", 1)[-1].rsplit("#", 1)[-1] or "Unnamed")
|
||||
metadata["triple_count"] = len(g)
|
||||
@@ -1268,6 +1307,20 @@ def _parse_rdf_sync(content: bytes, fmt: str) -> tuple:
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
if synthetic_uri:
|
||||
# No owl:Ontology / skos:ConceptScheme declaration exists, so the
|
||||
# synthetic registry URI shares no namespace with any node. Ownership
|
||||
# must be recorded explicitly, and the editor needs a matching graph
|
||||
# node, or the registered ontology resolves to an empty core and 404s.
|
||||
for node in nodes:
|
||||
node["properties"].setdefault("scheme_uri", metadata["uri"])
|
||||
nodes.append({
|
||||
"id": metadata["uri"],
|
||||
"type": "owl:Ontology",
|
||||
"content": metadata["name"],
|
||||
"properties": {"rdfs:label": metadata["name"], "uri": metadata["uri"]},
|
||||
})
|
||||
|
||||
return nodes, edges, metadata
|
||||
|
||||
|
||||
@@ -1768,6 +1821,107 @@ async def search_entities(
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/graph", response_model=OntologyGraphResponse)
|
||||
async def get_ontology_graph(
|
||||
request: Request,
|
||||
uri: str = Query(..., min_length=1),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Return the editable schema subgraph for one registered ontology."""
|
||||
registry = _get_registry(request)
|
||||
ontology_nodes: List[Dict[str, Any]] = []
|
||||
for node_type in _ONTOLOGY_TYPES:
|
||||
nodes, _ = await asyncio.to_thread(
|
||||
session.get_nodes, node_type=node_type, skip=0, limit=2**63 - 1
|
||||
)
|
||||
ontology_nodes.extend(nodes)
|
||||
known_ontology_uris = set(registry) | {
|
||||
str(node.get("id", "")) for node in ontology_nodes if node.get("id")
|
||||
}
|
||||
if uri not in known_ontology_uris:
|
||||
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
|
||||
|
||||
schema_types = _CLASS_TYPES | _PROPERTY_TYPES | _CONCEPT_TYPES | _ONTOLOGY_TYPES
|
||||
candidates_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
for node_type in schema_types:
|
||||
nodes, _ = await asyncio.to_thread(
|
||||
session.get_nodes, node_type=node_type, skip=0, limit=2**63 - 1
|
||||
)
|
||||
candidates_by_id.update(
|
||||
(str(node.get("id", "")), node) for node in nodes if node.get("id")
|
||||
)
|
||||
|
||||
core_node_ids = {
|
||||
str(node.get("id", ""))
|
||||
for node in candidates_by_id.values()
|
||||
if _node_belongs_to_ontology(node, uri, known_ontology_uris)
|
||||
}
|
||||
if not core_node_ids:
|
||||
raise HTTPException(status_code=404, detail="Ontology graph not found.")
|
||||
|
||||
structure_edge_types = {
|
||||
"rdf:type",
|
||||
"rdfs:subClassOf",
|
||||
"rdfs:domain",
|
||||
"rdfs:range",
|
||||
"owl:disjointWith",
|
||||
"owl:equivalentClass",
|
||||
"owl:equivalentProperty",
|
||||
"owl:inverseOf",
|
||||
"skos:broader",
|
||||
"skos:narrower",
|
||||
"skos:related",
|
||||
}
|
||||
selected_edges: List[Dict[str, Any]] = []
|
||||
for edge_type in structure_edge_types:
|
||||
edges, _ = await asyncio.to_thread(
|
||||
session.get_edges,
|
||||
edge_type=edge_type,
|
||||
skip=0,
|
||||
limit=2**63 - 1,
|
||||
)
|
||||
# Keep only edges whose source is a core node: the requested ontology
|
||||
# may reference outward (e.g. rdfs:range to an external vocabulary),
|
||||
# but an unrelated ontology's property pointing at a core class must
|
||||
# not leak inward.
|
||||
selected_edges.extend(
|
||||
edge for edge in edges
|
||||
if str(edge.get("source", "")) in core_node_ids
|
||||
)
|
||||
if (
|
||||
len(core_node_ids) > _MAX_ANALYSIS_NODES
|
||||
or len(selected_edges) > _MAX_ANALYSIS_NODES
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=(
|
||||
"Ontology editor graph exceeds the maximum size "
|
||||
f"({_MAX_ANALYSIS_NODES} nodes or edges)."
|
||||
),
|
||||
)
|
||||
|
||||
selected_node_ids = set(core_node_ids)
|
||||
for edge in selected_edges:
|
||||
selected_node_ids.add(str(edge.get("source", "")))
|
||||
selected_node_ids.add(str(edge.get("target", "")))
|
||||
|
||||
selected_nodes = [candidates_by_id[node_id] for node_id in core_node_ids]
|
||||
for node_id in selected_node_ids - core_node_ids:
|
||||
external = await asyncio.to_thread(session.get_node, node_id)
|
||||
if external is not None:
|
||||
selected_nodes.append(external)
|
||||
selected_nodes.sort(key=lambda node: str(node.get("id", "")))
|
||||
selected_edges.sort(
|
||||
key=lambda edge: (
|
||||
str(edge.get("source", "")),
|
||||
str(edge.get("type", "")),
|
||||
str(edge.get("target", "")),
|
||||
str(edge.get("id", "")),
|
||||
)
|
||||
)
|
||||
return OntologyGraphResponse(uri=uri, nodes=selected_nodes, edges=selected_edges)
|
||||
|
||||
|
||||
@router.get("/entity/{entity_uri:path}", response_model=EntityDetailResponse)
|
||||
async def get_entity_detail(
|
||||
entity_uri: str,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Shared runtime assembly for Explorer entry points."""
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ..context.agent_memory import AgentMemory
|
||||
from .session import GraphSession
|
||||
|
||||
|
||||
def explorer_capabilities(agent_memory: Optional[AgentMemory]) -> Dict[str, bool]:
|
||||
"""Describe optional Explorer features exposed by the current host."""
|
||||
return {"agent_memory": agent_memory is not None}
|
||||
|
||||
|
||||
def install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
|
||||
"""Keep Explorer indexes and WebSocket clients in sync with graph writes."""
|
||||
if getattr(app.state, "_semantica_mutation_bridge_session", None) is session:
|
||||
return
|
||||
previous_callback = getattr(session.graph, "mutation_callback", None)
|
||||
|
||||
def on_mutation(event_type: str, entity_id: str, payload: dict) -> None:
|
||||
session.handle_graph_mutation(event_type, entity_id, payload)
|
||||
if callable(previous_callback):
|
||||
previous_callback(event_type, entity_id, payload)
|
||||
loop = getattr(app.state, "event_loop", None)
|
||||
manager = getattr(app.state, "ws_manager", None)
|
||||
if loop is None or manager is None or loop.is_closed():
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
manager.broadcast(
|
||||
"graph_mutation",
|
||||
{
|
||||
"event_type": event_type,
|
||||
"entity_id": entity_id,
|
||||
"payload": payload,
|
||||
},
|
||||
),
|
||||
loop,
|
||||
)
|
||||
|
||||
session.graph.mutation_callback = on_mutation
|
||||
app.state._semantica_mutation_bridge = on_mutation
|
||||
app.state._semantica_mutation_bridge_session = session
|
||||
@@ -428,3 +428,39 @@ class DistanceExportRequest(BaseModel):
|
||||
include: List[str] = Field(
|
||||
default_factory=lambda: ["source_id", "target_id", "hop_count", "distance_band"],
|
||||
)
|
||||
|
||||
|
||||
class MarkdownResourceRefResponse(BaseModel):
|
||||
kind: Literal["context-node", "agent-memory"]
|
||||
id: str
|
||||
|
||||
|
||||
class MarkdownDocumentResponse(BaseModel):
|
||||
resource: MarkdownResourceRefResponse
|
||||
source: str
|
||||
body: str
|
||||
revision: str
|
||||
editable: bool = True
|
||||
|
||||
|
||||
class MarkdownApplyRequest(BaseModel):
|
||||
markdown: str
|
||||
expected_revision: str = Field(..., pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class MarkdownApplyResponse(MarkdownDocumentResponse):
|
||||
changed: bool
|
||||
|
||||
|
||||
class MemorySummaryResponse(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
excerpt: str
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class MemoryListResponse(BaseModel):
|
||||
items: List[MemorySummaryResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
@@ -9,9 +9,53 @@ import asyncio
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Set
|
||||
from typing import Any, Dict, List, Sequence, Set
|
||||
|
||||
from fastapi import WebSocket
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
|
||||
from .dependencies import is_valid_api_key
|
||||
|
||||
_WS_MAX_MESSAGE_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def install_graph_updates_websocket(
|
||||
app: FastAPI,
|
||||
allowed_origins: Sequence[str],
|
||||
) -> None:
|
||||
"""Install the authenticated graph-mutation WebSocket endpoint."""
|
||||
allowed_origin_set = frozenset(allowed_origins)
|
||||
|
||||
@app.websocket("/ws/graph-updates")
|
||||
async def websocket_endpoint(websocket: WebSocket) -> None:
|
||||
# CORSMiddleware does not cover WebSocket handshakes. Reject foreign
|
||||
# browser origins against the same allowlist used for HTTP CORS.
|
||||
origin = websocket.headers.get("origin")
|
||||
if origin is not None and origin not in allowed_origin_set:
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
# Browser clients pass the API key as a query parameter because the
|
||||
# WebSocket API cannot set custom headers.
|
||||
candidate = websocket.headers.get("x-api-key") or websocket.query_params.get(
|
||||
"api_key"
|
||||
)
|
||||
if not is_valid_api_key(candidate):
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
manager: ConnectionManager = app.state.ws_manager
|
||||
await manager.connect(websocket)
|
||||
await manager.send_personal(websocket, "connection_ack", {"connected": True})
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive_text()
|
||||
if len(message) > _WS_MAX_MESSAGE_BYTES:
|
||||
await websocket.close(code=1009)
|
||||
break
|
||||
if message.strip().lower() == "ping":
|
||||
await manager.send_personal(websocket, "pong", {"ok": True})
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
@@ -63,8 +107,7 @@ class ConnectionManager:
|
||||
with self._lock:
|
||||
connections = set(self._active_connections)
|
||||
|
||||
|
||||
disconnected: list[WebSocket] = []
|
||||
disconnected: List[WebSocket] = []
|
||||
for ws in connections:
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
|
||||
@@ -301,7 +301,10 @@ class GraphValidator:
|
||||
code="ORPHAN_NODES",
|
||||
message=f"Found {len(isolates)} orphan nodes (no relationships).",
|
||||
severity=ValidationSeverity.WARNING,
|
||||
details={"count": len(isolates), "ids": isolates[:10]} # Limit output
|
||||
details={
|
||||
"count": len(isolates),
|
||||
"ids": sorted(isolates, key=str)[:10],
|
||||
} # Limit output deterministically
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -35,7 +35,7 @@ Example Usage:
|
||||
>>> llm = LiteLLM(model="openai/gpt-4o", api_key="your-key")
|
||||
>>> response = llm.generate("Hello, world!")
|
||||
>>> # Or use other providers via LiteLLM
|
||||
>>> llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
>>> llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
>>> response = llm.generate("Hello, world!")
|
||||
>>>
|
||||
>>> # Anthropic provider
|
||||
|
||||
@@ -31,7 +31,7 @@ class LiteLLM:
|
||||
Provides unified interface to 100+ LLM providers through LiteLLM library.
|
||||
Supports providers like OpenAI, Anthropic, Groq, Azure, Bedrock, Vertex AI, etc.
|
||||
|
||||
Model format: "provider/model-name" (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-20250514", "groq/llama-3.1-8b-instant")
|
||||
Model format: "provider/model-name" (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-5", "groq/llama-3.1-8b-instant")
|
||||
|
||||
Example:
|
||||
>>> from semantica.llms import LiteLLM
|
||||
@@ -39,7 +39,7 @@ class LiteLLM:
|
||||
>>> response = llm.generate("What is AI?")
|
||||
>>>
|
||||
>>> # Use with different providers
|
||||
>>> llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
>>> llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
>>> response = llm.generate("Hello!")
|
||||
"""
|
||||
|
||||
@@ -54,7 +54,7 @@ class LiteLLM:
|
||||
|
||||
Args:
|
||||
model: Model identifier in format "provider/model-name"
|
||||
Examples: "openai/gpt-4o", "anthropic/claude-sonnet-4-20250514",
|
||||
Examples: "openai/gpt-4o", "anthropic/claude-sonnet-5",
|
||||
"groq/llama-3.1-8b-instant", "azure/gpt-4", etc.
|
||||
api_key: API key (optional, can use environment variables)
|
||||
**kwargs: Additional LiteLLM options (temperature, max_tokens, etc.)
|
||||
|
||||
@@ -162,6 +162,13 @@ from .ontology_validator import (
|
||||
run_shacl_validation,
|
||||
validate_ontology,
|
||||
)
|
||||
from .quality_gate import (
|
||||
OntologyQualityGate,
|
||||
OntologyQualityReport,
|
||||
QualityIssue,
|
||||
QualitySeverity,
|
||||
ontology_quality_check,
|
||||
)
|
||||
from .owl_generator import OWLGenerator
|
||||
from .property_generator import PropertyGenerator
|
||||
from .registry import MethodRegistry, method_registry
|
||||
@@ -194,6 +201,11 @@ __all__ = [
|
||||
"SHACLValidationReport",
|
||||
"SHACLViolation",
|
||||
"run_shacl_validation",
|
||||
"OntologyQualityGate",
|
||||
"OntologyQualityReport",
|
||||
"QualityIssue",
|
||||
"QualitySeverity",
|
||||
"ontology_quality_check",
|
||||
# OWL/RDF generation
|
||||
"OWLGenerator",
|
||||
# Requirements and competency questions
|
||||
|
||||
@@ -9,6 +9,7 @@ from .property_generator import PropertyGenerator
|
||||
from .owl_generator import OWLGenerator
|
||||
from .ontology_evaluator import OntologyEvaluator
|
||||
from .ontology_validator import OntologyValidator
|
||||
from .quality_gate import OntologyQualityGate, OntologyQualityReport
|
||||
from .llm_generator import LLMOntologyGenerator
|
||||
from ..semantic_extract.triplet_extractor import Triplet
|
||||
|
||||
@@ -25,6 +26,9 @@ class OntologyEngine:
|
||||
self.owl = OWLGenerator(**config)
|
||||
self.evaluator = OntologyEvaluator(**config)
|
||||
self.validator = OntologyValidator(**config)
|
||||
self.quality_gate = OntologyQualityGate(
|
||||
validator=self.validator, evaluator=self.evaluator
|
||||
)
|
||||
self.llm = LLMOntologyGenerator(**config)
|
||||
self.store = config.get("store")
|
||||
|
||||
@@ -593,6 +597,15 @@ class OntologyEngine:
|
||||
def validate(self, ontology: Dict[str, Any], **options):
|
||||
return self.validator.validate(ontology, **options)
|
||||
|
||||
def quality_check(
|
||||
self,
|
||||
ontology: Dict[str, Any],
|
||||
graph_data: Optional[Dict[str, Any]] = None,
|
||||
**options,
|
||||
) -> OntologyQualityReport:
|
||||
"""Run deterministic ontology quality checks suitable for CI."""
|
||||
return self.quality_gate.check(ontology, graph_data=graph_data, **options)
|
||||
|
||||
def to_owl(self, ontology: Dict[str, Any], format: str = "turtle", **options):
|
||||
return self.owl.generate_owl(ontology, format=format, **options)
|
||||
|
||||
|
||||
@@ -37,6 +37,29 @@ from .naming_conventions import NamingConventions
|
||||
from .relationship_utils import build_entity_aliases, resolve_relationship_endpoint_type
|
||||
|
||||
|
||||
# Top-level entity keys that describe structure or provenance rather than
|
||||
# business attributes. GraphBuilder and EntityMerger attach these to entity
|
||||
# dicts (relationships list, nested properties/metadata maps, merge history),
|
||||
# so they must not be inferred as datatype properties. Each key mirrors what
|
||||
# the framework actually writes to a merged entity top level
|
||||
# (see MergeStrategyManager._merge_entities merged_entity dict and GraphBuilder).
|
||||
_CONTROL_FIELDS = frozenset(
|
||||
{
|
||||
"id",
|
||||
"type",
|
||||
"entity_type",
|
||||
"text",
|
||||
"label",
|
||||
"confidence",
|
||||
"properties",
|
||||
"relationships",
|
||||
"metadata",
|
||||
"merged_from",
|
||||
"merge_strategy",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PropertyGenerator:
|
||||
"""
|
||||
Property generation engine for ontologies.
|
||||
@@ -347,7 +370,7 @@ class PropertyGenerator:
|
||||
|
||||
for entity in entities:
|
||||
for key, value in entity.items():
|
||||
if key in ["id", "type", "entity_type", "text", "label", "confidence"]:
|
||||
if key in _CONTROL_FIELDS:
|
||||
continue
|
||||
|
||||
# Infer type
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
"""Deterministic quality checks for ontology and KG pipelines."""
|
||||
|
||||
import copy
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
|
||||
|
||||
from ..kg.graph_validator import GraphValidator
|
||||
from .ontology_evaluator import OntologyEvaluator
|
||||
from .ontology_validator import OntologyValidator
|
||||
|
||||
|
||||
class QualitySeverity(str, Enum):
|
||||
"""Severity assigned to a quality finding."""
|
||||
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityIssue:
|
||||
"""A single machine-readable ontology quality finding."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
severity: QualitySeverity
|
||||
element_id: Optional[str] = None
|
||||
element_type: Optional[str] = None
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return a JSON-friendly representation of the issue."""
|
||||
return {
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"severity": self.severity.value,
|
||||
"element_id": self.element_id,
|
||||
"element_type": self.element_type,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OntologyQualityReport:
|
||||
"""Result returned by :class:`OntologyQualityGate`."""
|
||||
|
||||
passed: bool
|
||||
issues: List[QualityIssue] = field(default_factory=list)
|
||||
stats: Dict[str, int] = field(default_factory=dict)
|
||||
metrics: Dict[str, float] = field(default_factory=dict)
|
||||
thresholds: Dict[str, Optional[float]] = field(default_factory=dict)
|
||||
threshold_failures: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def error_count(self) -> int:
|
||||
"""Number of error and critical findings."""
|
||||
return sum(
|
||||
issue.severity in (QualitySeverity.ERROR, QualitySeverity.CRITICAL)
|
||||
for issue in self.issues
|
||||
)
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
"""Number of warning findings."""
|
||||
return sum(issue.severity == QualitySeverity.WARNING for issue in self.issues)
|
||||
|
||||
@property
|
||||
def info_count(self) -> int:
|
||||
"""Number of informational findings."""
|
||||
return sum(issue.severity == QualitySeverity.INFO for issue in self.issues)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return a JSON-friendly representation of the report."""
|
||||
return {
|
||||
"passed": self.passed,
|
||||
"issues": [issue.to_dict() for issue in self.issues],
|
||||
"stats": {
|
||||
**self.stats,
|
||||
"issues": len(self.issues),
|
||||
"errors": self.error_count,
|
||||
"warnings": self.warning_count,
|
||||
"infos": self.info_count,
|
||||
},
|
||||
"metrics": self.metrics,
|
||||
"thresholds": self.thresholds,
|
||||
"threshold_failures": self.threshold_failures,
|
||||
}
|
||||
|
||||
|
||||
class OntologyQualityGate:
|
||||
"""Run deterministic, CI-friendly ontology quality checks."""
|
||||
|
||||
DEFAULT_THRESHOLDS: Dict[str, Optional[float]] = {
|
||||
"min_coverage": 0.0,
|
||||
"max_errors": 0.0,
|
||||
"max_warnings": None,
|
||||
}
|
||||
_DATA_PROPERTY_TYPES = {"data", "datatype", "data_property", "literal"}
|
||||
_OBJECT_PROPERTY_TYPES = {"object", "object_property", "relationship"}
|
||||
_KNOWN_DATATYPES = {
|
||||
"string",
|
||||
"boolean",
|
||||
"decimal",
|
||||
"float",
|
||||
"double",
|
||||
"integer",
|
||||
"int",
|
||||
"long",
|
||||
"short",
|
||||
"byte",
|
||||
"date",
|
||||
"datetime",
|
||||
"datetimestamp",
|
||||
"time",
|
||||
"duration",
|
||||
"anyuri",
|
||||
}
|
||||
_BUILTIN_CLASSES = {
|
||||
"owl:thing",
|
||||
"rdfs:resource",
|
||||
"http://www.w3.org/2002/07/owl#thing",
|
||||
"http://www.w3.org/2000/01/rdf-schema#resource",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
validator: Optional[OntologyValidator] = None,
|
||||
evaluator: Optional[OntologyEvaluator] = None,
|
||||
thresholds: Optional[Mapping[str, Optional[float]]] = None,
|
||||
fail_on_warnings: bool = False,
|
||||
) -> None:
|
||||
self.validator = validator or OntologyValidator()
|
||||
self.evaluator = evaluator or OntologyEvaluator()
|
||||
self.thresholds = dict(self.DEFAULT_THRESHOLDS)
|
||||
if thresholds:
|
||||
self.thresholds.update(thresholds)
|
||||
self.fail_on_warnings = fail_on_warnings
|
||||
|
||||
def check(
|
||||
self,
|
||||
ontology: Any,
|
||||
graph_data: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
thresholds: Optional[Mapping[str, Optional[float]]] = None,
|
||||
fail_on_warnings: Optional[bool] = None,
|
||||
competency_questions: Optional[List[str]] = None,
|
||||
) -> OntologyQualityReport:
|
||||
"""Check an ontology and optionally its instance graph.
|
||||
|
||||
``graph_data`` is optional because an ontology can be checked before
|
||||
instances are available. When omitted, embedded ``entities`` and
|
||||
``relationships`` are checked when present.
|
||||
"""
|
||||
active_thresholds = dict(self.thresholds)
|
||||
if thresholds:
|
||||
active_thresholds.update(thresholds)
|
||||
min_coverage, max_errors, max_warnings = self._validate_thresholds(
|
||||
active_thresholds
|
||||
)
|
||||
should_fail_on_warnings = (
|
||||
self.fail_on_warnings if fail_on_warnings is None else fail_on_warnings
|
||||
)
|
||||
issues: List[QualityIssue] = []
|
||||
|
||||
if not isinstance(ontology, dict):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"INVALID_ONTOLOGY",
|
||||
"Ontology must be a dictionary.",
|
||||
QualitySeverity.CRITICAL,
|
||||
element_type="ontology",
|
||||
)
|
||||
return self._build_report(
|
||||
issues,
|
||||
classes=0,
|
||||
properties=0,
|
||||
entities=0,
|
||||
relationships=0,
|
||||
metrics={
|
||||
"coverage": 0.0,
|
||||
"class_coverage": 0.0,
|
||||
"property_coverage": 0.0,
|
||||
},
|
||||
thresholds=active_thresholds,
|
||||
min_coverage=min_coverage,
|
||||
max_errors=max_errors,
|
||||
max_warnings=max_warnings,
|
||||
fail_on_warnings=should_fail_on_warnings,
|
||||
)
|
||||
|
||||
validation = self.validator.validate(ontology)
|
||||
for message in getattr(validation, "errors", []) or []:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"VALIDATOR_ERROR",
|
||||
str(message),
|
||||
QualitySeverity.ERROR,
|
||||
element_type="ontology",
|
||||
)
|
||||
for message in getattr(validation, "warnings", []) or []:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"VALIDATOR_WARNING",
|
||||
str(message),
|
||||
QualitySeverity.WARNING,
|
||||
element_type="ontology",
|
||||
)
|
||||
|
||||
classes = self._read_collection(ontology, "classes", issues)
|
||||
properties = self._read_collection(ontology, "properties", issues)
|
||||
class_aliases, class_ids = self._index_elements(
|
||||
classes, "class", "MISSING_CLASS_ID", issues
|
||||
)
|
||||
referenced_classes: Set[str] = set()
|
||||
self._mark_hierarchy(classes, class_aliases, referenced_classes)
|
||||
|
||||
property_with_endpoints = 0
|
||||
for index, prop in enumerate(properties):
|
||||
if not isinstance(prop, dict):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"INVALID_PROPERTY",
|
||||
f"Property at index {index} must be a dictionary.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="property",
|
||||
details={"index": index},
|
||||
)
|
||||
continue
|
||||
|
||||
prop_id = self._identifier(prop)
|
||||
if prop_id is None:
|
||||
prop_id = f"property[{index}]"
|
||||
self._add_issue(
|
||||
issues,
|
||||
"MISSING_PROPERTY_ID",
|
||||
f"Property at index {index} has no name or URI.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="property",
|
||||
details={"index": index},
|
||||
)
|
||||
|
||||
raw_prop_type = prop.get("type")
|
||||
prop_type = (
|
||||
str(raw_prop_type).strip().lower() if raw_prop_type is not None else ""
|
||||
)
|
||||
if not prop_type:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"MISSING_PROPERTY_TYPE",
|
||||
f"Property '{prop_id}' has no type.",
|
||||
QualitySeverity.ERROR,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
)
|
||||
elif (
|
||||
prop_type not in self._DATA_PROPERTY_TYPES | self._OBJECT_PROPERTY_TYPES
|
||||
):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"UNKNOWN_PROPERTY_TYPE",
|
||||
f"Property '{prop_id}' has unknown type '{prop_type}'.",
|
||||
QualitySeverity.WARNING,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
)
|
||||
|
||||
domains = self._values(prop, "domain")
|
||||
ranges = self._values(prop, "range")
|
||||
if domains or ranges:
|
||||
property_with_endpoints += 1
|
||||
else:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"ORPHAN_PROPERTY",
|
||||
f"Property '{prop_id}' has no domain or range.",
|
||||
QualitySeverity.WARNING,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
)
|
||||
if not domains:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"MISSING_DOMAIN",
|
||||
f"Property '{prop_id}' has no domain.",
|
||||
QualitySeverity.WARNING,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
)
|
||||
for domain in domains:
|
||||
matched = self._match_class(domain, class_aliases)
|
||||
if matched:
|
||||
referenced_classes.add(matched)
|
||||
elif not self._is_builtin_class(domain):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"UNKNOWN_DOMAIN",
|
||||
f"Property '{prop_id}' references unknown domain '{domain}'.",
|
||||
QualitySeverity.ERROR,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
details={"domain": domain},
|
||||
)
|
||||
|
||||
if not ranges:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"MISSING_RANGE",
|
||||
f"Property '{prop_id}' has no range.",
|
||||
QualitySeverity.WARNING,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
)
|
||||
for range_value in ranges:
|
||||
matched = self._match_class(range_value, class_aliases)
|
||||
if matched:
|
||||
referenced_classes.add(matched)
|
||||
self._check_range(
|
||||
prop_id,
|
||||
prop_type,
|
||||
range_value,
|
||||
matched is not None,
|
||||
issues,
|
||||
)
|
||||
|
||||
graph = graph_data
|
||||
if graph is None and ("entities" in ontology or "relationships" in ontology):
|
||||
graph = ontology
|
||||
graph_entities, graph_relationships = self._read_graph(graph, issues)
|
||||
if self._has_valid_graph_shape(graph):
|
||||
self._check_graph(graph, issues)
|
||||
self._mark_graph_types(graph_entities, class_aliases, referenced_classes)
|
||||
|
||||
for class_id in class_ids:
|
||||
if class_id not in referenced_classes:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"ORPHAN_CLASS",
|
||||
f"Class '{class_id}' is not connected to a property, hierarchy, or graph entity type.",
|
||||
QualitySeverity.WARNING,
|
||||
element_id=class_id,
|
||||
element_type="class",
|
||||
)
|
||||
|
||||
class_coverage = (
|
||||
len(referenced_classes & set(class_ids)) / len(class_ids)
|
||||
if class_ids
|
||||
else 0.0
|
||||
)
|
||||
property_coverage = (
|
||||
property_with_endpoints / len(properties) if properties else 1.0
|
||||
)
|
||||
metrics: Dict[str, float] = {
|
||||
"coverage": (
|
||||
(class_coverage + property_coverage) / 2
|
||||
if classes or properties
|
||||
else 0.0
|
||||
),
|
||||
"class_coverage": class_coverage,
|
||||
"property_coverage": property_coverage,
|
||||
"validator_valid": 1.0 if getattr(validation, "valid", True) else 0.0,
|
||||
}
|
||||
if competency_questions is not None:
|
||||
evaluation_ontology = self._prepare_for_evaluation(
|
||||
ontology, classes, properties
|
||||
)
|
||||
evaluation = self._evaluate_competency_questions(
|
||||
evaluation_ontology, competency_questions=competency_questions
|
||||
)
|
||||
metrics["competency_question_coverage"] = evaluation.coverage_score
|
||||
metrics["completeness"] = evaluation.completeness_score
|
||||
|
||||
return self._build_report(
|
||||
issues,
|
||||
classes=len(classes),
|
||||
properties=len(properties),
|
||||
entities=len(graph_entities),
|
||||
relationships=len(graph_relationships),
|
||||
metrics=metrics,
|
||||
thresholds=active_thresholds,
|
||||
min_coverage=min_coverage,
|
||||
max_errors=max_errors,
|
||||
max_warnings=max_warnings,
|
||||
fail_on_warnings=should_fail_on_warnings,
|
||||
)
|
||||
|
||||
def _check_range(
|
||||
self,
|
||||
prop_id: str,
|
||||
prop_type: str,
|
||||
range_value: Any,
|
||||
is_class: bool,
|
||||
issues: List[QualityIssue],
|
||||
) -> None:
|
||||
if prop_type in self._DATA_PROPERTY_TYPES:
|
||||
if not self._is_known_datatype(range_value):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"INVALID_DATATYPE_RANGE",
|
||||
f"Data property '{prop_id}' has invalid range '{range_value}'.",
|
||||
QualitySeverity.ERROR,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
details={"range": range_value},
|
||||
)
|
||||
elif prop_type in self._OBJECT_PROPERTY_TYPES:
|
||||
if not is_class and not self._is_builtin_class(range_value):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"UNKNOWN_RANGE",
|
||||
f"Object property '{prop_id}' references unknown range '{range_value}'.",
|
||||
QualitySeverity.ERROR,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
details={"range": range_value},
|
||||
)
|
||||
elif (
|
||||
not is_class
|
||||
and not self._is_builtin_class(range_value)
|
||||
and not self._is_known_datatype(range_value)
|
||||
):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"UNKNOWN_RANGE",
|
||||
f"Property '{prop_id}' references unknown range '{range_value}'.",
|
||||
QualitySeverity.ERROR,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
details={"range": range_value},
|
||||
)
|
||||
|
||||
def _build_report(
|
||||
self,
|
||||
issues: List[QualityIssue],
|
||||
*,
|
||||
classes: int,
|
||||
properties: int,
|
||||
entities: int,
|
||||
relationships: int,
|
||||
metrics: Dict[str, float],
|
||||
thresholds: Dict[str, Optional[float]],
|
||||
min_coverage: float,
|
||||
max_errors: float,
|
||||
max_warnings: Optional[float],
|
||||
fail_on_warnings: bool,
|
||||
) -> OntologyQualityReport:
|
||||
failures: List[str] = []
|
||||
error_count = sum(
|
||||
issue.severity in (QualitySeverity.ERROR, QualitySeverity.CRITICAL)
|
||||
for issue in issues
|
||||
)
|
||||
warning_count = sum(
|
||||
issue.severity == QualitySeverity.WARNING for issue in issues
|
||||
)
|
||||
if error_count > max_errors:
|
||||
failures.append("max_errors")
|
||||
if max_warnings is not None and warning_count > max_warnings:
|
||||
failures.append("max_warnings")
|
||||
if fail_on_warnings and warning_count:
|
||||
failures.append("fail_on_warnings")
|
||||
if metrics.get("coverage", 0.0) < min_coverage:
|
||||
failures.append("min_coverage")
|
||||
return OntologyQualityReport(
|
||||
passed=not failures,
|
||||
issues=issues,
|
||||
stats={
|
||||
"classes": classes,
|
||||
"properties": properties,
|
||||
"entities": entities,
|
||||
"relationships": relationships,
|
||||
},
|
||||
metrics=metrics,
|
||||
thresholds=thresholds,
|
||||
threshold_failures=failures,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_thresholds(
|
||||
thresholds: Mapping[str, Optional[float]],
|
||||
) -> Tuple[float, float, Optional[float]]:
|
||||
min_coverage = float(thresholds.get("min_coverage", 0.0) or 0.0)
|
||||
max_errors = float(thresholds.get("max_errors", 0.0) or 0.0)
|
||||
warning_value = thresholds.get("max_warnings")
|
||||
max_warnings = None if warning_value is None else float(warning_value)
|
||||
if not all(
|
||||
math.isfinite(value)
|
||||
for value in (min_coverage, max_errors)
|
||||
if value is not None
|
||||
) or (max_warnings is not None and not math.isfinite(max_warnings)):
|
||||
raise ValueError("quality thresholds must be finite numbers")
|
||||
if not 0.0 <= min_coverage <= 1.0:
|
||||
raise ValueError("min_coverage must be between 0.0 and 1.0")
|
||||
if max_errors < 0 or (max_warnings is not None and max_warnings < 0):
|
||||
raise ValueError("error and warning thresholds cannot be negative")
|
||||
return min_coverage, max_errors, max_warnings
|
||||
|
||||
@classmethod
|
||||
def _prepare_for_evaluation(
|
||||
cls,
|
||||
ontology: Dict[str, Any],
|
||||
classes: Iterable[Any],
|
||||
properties: Iterable[Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Make a shallow, evaluator-safe view without changing caller data."""
|
||||
prepared = dict(ontology)
|
||||
prepared["classes"] = [
|
||||
cls._prepare_element(element)
|
||||
for element in classes
|
||||
if isinstance(element, dict)
|
||||
]
|
||||
prepared["properties"] = [
|
||||
cls._prepare_element(element)
|
||||
for element in properties
|
||||
if isinstance(element, dict)
|
||||
]
|
||||
return prepared
|
||||
|
||||
@classmethod
|
||||
def _prepare_element(cls, element: Dict[str, Any]) -> Dict[str, Any]:
|
||||
prepared = dict(element)
|
||||
identifier = cls._identifier(prepared)
|
||||
if identifier is not None and not str(prepared.get("name", "")).strip():
|
||||
prepared["name"] = identifier
|
||||
return prepared
|
||||
|
||||
def _evaluate_competency_questions(
|
||||
self, ontology: Dict[str, Any], competency_questions: List[str]
|
||||
) -> Any:
|
||||
"""Evaluate with an isolated question manager for repeatable checks."""
|
||||
evaluator = copy.copy(self.evaluator)
|
||||
manager = getattr(self.evaluator, "competency_questions_manager", None)
|
||||
if manager is not None and hasattr(manager, "questions"):
|
||||
isolated_manager = copy.copy(manager)
|
||||
isolated_manager.questions = []
|
||||
evaluator.competency_questions_manager = isolated_manager
|
||||
return evaluator.evaluate_ontology(
|
||||
ontology, competency_questions=competency_questions
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _read_collection(
|
||||
cls, ontology: Dict[str, Any], key: str, issues: List[QualityIssue]
|
||||
) -> List[Any]:
|
||||
if key not in ontology:
|
||||
cls._add_issue(
|
||||
issues,
|
||||
f"MISSING_{key.upper()}",
|
||||
f"Ontology has no {key} defined.",
|
||||
QualitySeverity.WARNING,
|
||||
element_type="ontology",
|
||||
)
|
||||
return []
|
||||
value = ontology[key]
|
||||
if not isinstance(value, list):
|
||||
cls._add_issue(
|
||||
issues,
|
||||
f"INVALID_{key.upper()}",
|
||||
f"Ontology '{key}' must be a list.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="ontology",
|
||||
)
|
||||
return []
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _index_elements(
|
||||
cls,
|
||||
elements: Iterable[Any],
|
||||
element_type: str,
|
||||
missing_code: str,
|
||||
issues: List[QualityIssue],
|
||||
) -> Tuple[Dict[str, str], List[str]]:
|
||||
aliases: Dict[str, str] = {}
|
||||
identifiers: List[str] = []
|
||||
for index, element in enumerate(elements):
|
||||
identifier = cls._identifier(element)
|
||||
if identifier is None:
|
||||
cls._add_issue(
|
||||
issues,
|
||||
missing_code,
|
||||
f"{element_type.title()} at index {index} has no name or URI.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type=element_type,
|
||||
details={"index": index},
|
||||
)
|
||||
continue
|
||||
identifiers.append(identifier)
|
||||
aliases_for_element: Set[str] = set()
|
||||
for candidate in cls._identifiers(element):
|
||||
aliases_for_element.update(cls._term_aliases(candidate))
|
||||
for alias in sorted(aliases_for_element):
|
||||
aliases.setdefault(alias, identifier)
|
||||
return aliases, identifiers
|
||||
|
||||
@classmethod
|
||||
def _mark_hierarchy(
|
||||
cls,
|
||||
classes: Iterable[Any],
|
||||
aliases: Mapping[str, str],
|
||||
referenced: Set[str],
|
||||
) -> None:
|
||||
for class_entry in classes:
|
||||
if not isinstance(class_entry, dict):
|
||||
continue
|
||||
identifier = cls._identifier(class_entry)
|
||||
for key in (
|
||||
"subClassOf",
|
||||
"subclassOf",
|
||||
"parent",
|
||||
"superclass",
|
||||
"superclasses",
|
||||
):
|
||||
parents = cls._values(class_entry, key)
|
||||
if parents and identifier:
|
||||
referenced.add(identifier)
|
||||
for parent in parents:
|
||||
matched = cls._match_class(parent, aliases)
|
||||
if matched:
|
||||
referenced.add(matched)
|
||||
|
||||
@classmethod
|
||||
def _mark_graph_types(
|
||||
cls,
|
||||
entities: Iterable[Any],
|
||||
aliases: Mapping[str, str],
|
||||
referenced: Set[str],
|
||||
) -> None:
|
||||
for entity in entities:
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
entity_type = entity.get("type") or entity.get("entity_type")
|
||||
matched = cls._match_class(entity_type, aliases)
|
||||
if matched:
|
||||
referenced.add(matched)
|
||||
|
||||
@classmethod
|
||||
def _check_graph(cls, graph: Dict[str, Any], issues: List[QualityIssue]) -> None:
|
||||
safe_graph = cls._prepare_graph_for_validation(graph, issues)
|
||||
result = GraphValidator().validate(safe_graph)
|
||||
code_map = {
|
||||
"DANGLING_EDGE": "UNRESOLVED_RELATIONSHIP_ENDPOINT",
|
||||
"ORPHAN_NODES": "ORPHAN_ENTITY",
|
||||
}
|
||||
severity_map = {
|
||||
"info": QualitySeverity.INFO,
|
||||
"warning": QualitySeverity.WARNING,
|
||||
"error": QualitySeverity.ERROR,
|
||||
"critical": QualitySeverity.CRITICAL,
|
||||
}
|
||||
for graph_issue in result.issues:
|
||||
severity = severity_map.get(
|
||||
graph_issue.severity.value, QualitySeverity.ERROR
|
||||
)
|
||||
details = dict(graph_issue.details or {})
|
||||
if graph_issue.code == "ORPHAN_NODES" and "ids" in details:
|
||||
details["ids"] = sorted(details["ids"], key=str)
|
||||
cls._add_issue(
|
||||
issues,
|
||||
code_map.get(graph_issue.code, graph_issue.code),
|
||||
graph_issue.message,
|
||||
severity,
|
||||
element_id=graph_issue.element_id,
|
||||
element_type=graph_issue.element_type,
|
||||
details=details,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _prepare_graph_for_validation(
|
||||
cls, graph: Dict[str, Any], issues: List[QualityIssue]
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Normalize supported graph aliases and isolate malformed members."""
|
||||
entities: List[Dict[str, Any]] = []
|
||||
raw_entities = graph.get("entities", [])
|
||||
raw_relationships = graph.get("relationships", [])
|
||||
for index, entity in enumerate(
|
||||
raw_entities if isinstance(raw_entities, list) else []
|
||||
):
|
||||
if not isinstance(entity, dict):
|
||||
cls._add_issue(
|
||||
issues,
|
||||
"INVALID_ENTITY",
|
||||
f"Entity at index {index} must be a dictionary.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="entity",
|
||||
details={"index": index},
|
||||
)
|
||||
continue
|
||||
normalized = dict(entity)
|
||||
if not normalized.get("name") and normalized.get("text") is not None:
|
||||
normalized["name"] = normalized["text"]
|
||||
entities.append(normalized)
|
||||
|
||||
relationships: List[Dict[str, Any]] = []
|
||||
for index, relationship in enumerate(
|
||||
raw_relationships if isinstance(raw_relationships, list) else []
|
||||
):
|
||||
if not isinstance(relationship, dict):
|
||||
cls._add_issue(
|
||||
issues,
|
||||
"INVALID_RELATIONSHIP",
|
||||
f"Relationship at index {index} must be a dictionary.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="relationship",
|
||||
details={"index": index},
|
||||
)
|
||||
continue
|
||||
relationships.append(dict(relationship))
|
||||
|
||||
return {"entities": entities, "relationships": relationships}
|
||||
|
||||
@classmethod
|
||||
def _read_graph(
|
||||
cls, graph: Optional[Dict[str, Any]], issues: List[QualityIssue]
|
||||
) -> Tuple[List[Any], List[Any]]:
|
||||
if graph is None:
|
||||
return [], []
|
||||
if not isinstance(graph, dict):
|
||||
cls._add_issue(
|
||||
issues,
|
||||
"INVALID_GRAPH",
|
||||
"Graph data must be a dictionary.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="graph",
|
||||
)
|
||||
return [], []
|
||||
entities = graph.get("entities", [])
|
||||
relationships = graph.get("relationships", [])
|
||||
if not isinstance(entities, list) or not isinstance(relationships, list):
|
||||
cls._add_issue(
|
||||
issues,
|
||||
"INVALID_GRAPH",
|
||||
"Graph entities and relationships must be lists.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="graph",
|
||||
)
|
||||
return [], []
|
||||
return entities, relationships
|
||||
|
||||
@staticmethod
|
||||
def _has_valid_graph_shape(graph: Optional[Dict[str, Any]]) -> bool:
|
||||
return bool(
|
||||
isinstance(graph, dict)
|
||||
and isinstance(graph.get("entities", []), list)
|
||||
and isinstance(graph.get("relationships", []), list)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _identifier(element: Any) -> Optional[str]:
|
||||
identifiers = OntologyQualityGate._identifiers(element)
|
||||
return identifiers[0] if identifiers else None
|
||||
|
||||
@staticmethod
|
||||
def _identifiers(element: Any) -> List[str]:
|
||||
if isinstance(element, dict):
|
||||
values = []
|
||||
for key in ("name", "uri", "id", "@id"):
|
||||
value = element.get(key)
|
||||
if value is not None and str(value).strip():
|
||||
values.append(str(value).strip())
|
||||
return values
|
||||
if isinstance(element, str) and element.strip():
|
||||
return [element.strip()]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _values(element: Dict[str, Any], key: str) -> List[Any]:
|
||||
value = element.get(key)
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
values = [item for item in value if item is not None and str(item).strip()]
|
||||
return sorted(values, key=str) if isinstance(value, set) else values
|
||||
return [value] if str(value).strip() else []
|
||||
|
||||
@classmethod
|
||||
def _term_aliases(cls, value: Any) -> Set[str]:
|
||||
if isinstance(value, dict):
|
||||
value = cls._identifier(value)
|
||||
if value is None:
|
||||
return set()
|
||||
text = str(value).strip().strip("<>")
|
||||
if not text:
|
||||
return set()
|
||||
aliases = {text, text.lower()}
|
||||
for separator in ("#", "/"):
|
||||
if separator in text:
|
||||
local = text.rstrip("/").rsplit(separator, 1)[-1]
|
||||
aliases.update({local, local.lower()})
|
||||
if ":" in text and not text.startswith(("http://", "https://")):
|
||||
local = text.rsplit(":", 1)[-1]
|
||||
aliases.update({local, local.lower()})
|
||||
return aliases
|
||||
|
||||
@classmethod
|
||||
def _match_class(cls, value: Any, aliases: Mapping[str, str]) -> Optional[str]:
|
||||
for alias in sorted(cls._term_aliases(value)):
|
||||
if alias in aliases:
|
||||
return aliases[alias]
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _is_builtin_class(cls, value: Any) -> bool:
|
||||
return any(alias in cls._BUILTIN_CLASSES for alias in cls._term_aliases(value))
|
||||
|
||||
@classmethod
|
||||
def _is_known_datatype(cls, value: Any) -> bool:
|
||||
return any(alias in cls._KNOWN_DATATYPES for alias in cls._term_aliases(value))
|
||||
|
||||
@staticmethod
|
||||
def _add_issue(
|
||||
issues: List[QualityIssue],
|
||||
code: str,
|
||||
message: str,
|
||||
severity: QualitySeverity,
|
||||
*,
|
||||
element_id: Optional[str] = None,
|
||||
element_type: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
issues.append(
|
||||
QualityIssue(
|
||||
code=code,
|
||||
message=message,
|
||||
severity=severity,
|
||||
element_id=element_id,
|
||||
element_type=element_type,
|
||||
details=details or {},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ontology_quality_check(
|
||||
ontology: Any,
|
||||
graph_data: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
thresholds: Optional[Mapping[str, Optional[float]]] = None,
|
||||
fail_on_warnings: bool = False,
|
||||
competency_questions: Optional[List[str]] = None,
|
||||
validator: Optional[OntologyValidator] = None,
|
||||
evaluator: Optional[OntologyEvaluator] = None,
|
||||
) -> OntologyQualityReport:
|
||||
"""Convenience wrapper around :class:`OntologyQualityGate`."""
|
||||
gate = OntologyQualityGate(
|
||||
validator=validator,
|
||||
evaluator=evaluator,
|
||||
thresholds=thresholds,
|
||||
fail_on_warnings=fail_on_warnings,
|
||||
)
|
||||
return gate.check(
|
||||
ontology,
|
||||
graph_data=graph_data,
|
||||
competency_questions=competency_questions,
|
||||
)
|
||||
+26
-5
@@ -5,6 +5,7 @@ This module provides the REST API server for the Semantica framework
|
||||
using FastAPI and uvicorn.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uvicorn
|
||||
@@ -24,8 +25,10 @@ from .utils.logging import setup_logging
|
||||
try:
|
||||
from .context.context_graph import ContextGraph
|
||||
from .explorer.session import GraphSession
|
||||
from .explorer.ws import ConnectionManager
|
||||
from .explorer.ws import ConnectionManager, install_graph_updates_websocket
|
||||
from .explorer.dependencies import anonymous_access_allowed, get_expected_api_key, require_auth
|
||||
from .explorer.markdown_resources import MarkdownResourceRegistry
|
||||
from .explorer.runtime import explorer_capabilities, install_mutation_bridge
|
||||
EXPLORER_AVAILABLE = True
|
||||
except ImportError:
|
||||
EXPLORER_AVAILABLE = False
|
||||
@@ -57,16 +60,24 @@ async def lifespan(app: FastAPI):
|
||||
graph = ContextGraph()
|
||||
app.state.session = GraphSession(graph)
|
||||
app.state.ws_manager = ConnectionManager()
|
||||
app.state.event_loop = asyncio.get_running_loop()
|
||||
app.state.agent_memory = None
|
||||
app.state.markdown_resources = MarkdownResourceRegistry(graph)
|
||||
install_mutation_bridge(app, app.state.session)
|
||||
logging.info("Database Session and WebSockets attached to app state.")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to initialize GraphSession: {e}")
|
||||
app.state.session = None
|
||||
app.state.ws_manager = None
|
||||
app.state.agent_memory = None
|
||||
app.state.markdown_resources = None
|
||||
else:
|
||||
app.state.session = None
|
||||
app.state.ws_manager = None
|
||||
app.state.agent_memory = None
|
||||
app.state.markdown_resources = None
|
||||
|
||||
yield
|
||||
yield
|
||||
|
||||
logging.info("Shutting down Semantica API...")
|
||||
if getattr(app.state, "session", None) and hasattr(app.state.session.graph, "close"):
|
||||
@@ -91,11 +102,14 @@ app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
|
||||
max_age=600,
|
||||
)
|
||||
|
||||
if EXPLORER_AVAILABLE:
|
||||
install_graph_updates_websocket(app, _cors_origins)
|
||||
|
||||
|
||||
# --- Security response headers -------------------------------------
|
||||
class _SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
@@ -138,7 +152,10 @@ async def root():
|
||||
return {
|
||||
"name": "Semantica API",
|
||||
"version": __version__,
|
||||
"status": "active"
|
||||
"status": "active",
|
||||
"capabilities": explorer_capabilities(
|
||||
getattr(app.state, "agent_memory", None)
|
||||
),
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
@@ -165,6 +182,8 @@ if EXPLORER_AVAILABLE:
|
||||
enrich,
|
||||
export_import,
|
||||
graph,
|
||||
markdown,
|
||||
memories,
|
||||
ontology,
|
||||
temporal,
|
||||
vocabulary,
|
||||
@@ -179,6 +198,8 @@ if EXPLORER_AVAILABLE:
|
||||
app.include_router(enrich.router, dependencies=_auth)
|
||||
app.include_router(export_import.router, dependencies=_auth)
|
||||
app.include_router(graph.router, dependencies=_auth)
|
||||
app.include_router(markdown.router, dependencies=_auth)
|
||||
app.include_router(memories.router, dependencies=_auth)
|
||||
app.include_router(ontology.router, dependencies=_auth)
|
||||
app.include_router(temporal.router, dependencies=_auth)
|
||||
app.include_router(vocabulary.router, dependencies=_auth)
|
||||
@@ -260,4 +281,4 @@ def main():
|
||||
uvicorn.run(app, host=host, port=8000)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -12,6 +12,10 @@ import pytest
|
||||
import yaml
|
||||
|
||||
from semantica.context.agent_memory import AgentMemory
|
||||
from semantica.context.markdown import (
|
||||
MarkdownRevisionConflictError,
|
||||
markdown_document_revision,
|
||||
)
|
||||
|
||||
_ERROR_PRIVILEGE_NOT_HELD = 1314
|
||||
|
||||
@@ -1008,3 +1012,144 @@ def test_markdown_import_rejects_non_regular_file(tmp_path):
|
||||
with patch("semantica.context.agent_memory.os.fstat", return_value=fake_stat):
|
||||
with pytest.raises(ValueError, match="not a regular file"):
|
||||
memory._read_markdown_file_content(real_file)
|
||||
|
||||
|
||||
def _editable_memory(vector_store=None):
|
||||
memory = AgentMemory(vector_store=vector_store)
|
||||
memory.store(
|
||||
"Original body",
|
||||
memory_id="mem-edit",
|
||||
timestamp=datetime.fromisoformat("2026-07-22T09:00:00+00:00"),
|
||||
metadata={
|
||||
"type": "note",
|
||||
"updated_at": "2026-07-22T10:00:00+00:00",
|
||||
"owner": "before",
|
||||
},
|
||||
)
|
||||
return memory
|
||||
|
||||
|
||||
def test_single_item_markdown_export_and_apply_preserve_identity():
|
||||
memory = _editable_memory()
|
||||
source = memory.export_item_markdown("mem-edit")
|
||||
edited = source.replace("owner: before", "owner: after").replace(
|
||||
"Original body", "Updated body"
|
||||
)
|
||||
|
||||
assert memory.apply_item_markdown("mem-edit", edited) is True
|
||||
item = memory.get("mem-edit")
|
||||
assert item["memory_id"] == "mem-edit"
|
||||
assert item["content"] == "Updated body"
|
||||
assert item["metadata"]["owner"] == "after"
|
||||
assert memory.export_item_markdown("mem-edit") == edited
|
||||
|
||||
|
||||
def test_single_item_markdown_rejects_identity_change_without_mutation():
|
||||
memory = _editable_memory()
|
||||
before = deepcopy(memory.get("mem-edit"))
|
||||
document = memory.export_item_markdown("mem-edit").replace(
|
||||
"id: mem-edit", "id: mem-other"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not match resource id"):
|
||||
memory.apply_item_markdown("mem-edit", document)
|
||||
|
||||
assert memory.get("mem-edit") == before
|
||||
assert memory.get("mem-other") is None
|
||||
|
||||
|
||||
def test_single_item_markdown_invalid_document_and_missing_item_are_safe():
|
||||
memory = _editable_memory()
|
||||
before = deepcopy(memory.get("mem-edit"))
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid Markdown frontmatter"):
|
||||
memory.apply_item_markdown("mem-edit", "---\nid: [\n---\n\nBroken")
|
||||
with pytest.raises(KeyError, match="missing"):
|
||||
memory.export_item_markdown("missing")
|
||||
|
||||
assert memory.get("mem-edit") == before
|
||||
|
||||
|
||||
def test_single_item_markdown_noop_skips_vector_sync():
|
||||
vector_store = TrackingVectorStore()
|
||||
memory = _editable_memory(vector_store=vector_store)
|
||||
vector_store.events.clear()
|
||||
source = memory.export_item_markdown("mem-edit")
|
||||
|
||||
assert memory.apply_item_markdown("mem-edit", source) is False
|
||||
assert vector_store.events == []
|
||||
|
||||
|
||||
def test_single_item_markdown_revision_check_is_atomic_with_apply():
|
||||
memory = _editable_memory()
|
||||
source = memory.export_item_markdown("mem-edit")
|
||||
expected_revision = markdown_document_revision(source)
|
||||
memory.update("mem-edit", content="Concurrent update")
|
||||
|
||||
with pytest.raises(MarkdownRevisionConflictError):
|
||||
memory.apply_item_markdown(
|
||||
"mem-edit",
|
||||
source.replace("Original body", "Stale edit"),
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
assert memory.get("mem-edit")["content"] == "Concurrent update"
|
||||
|
||||
|
||||
def test_apply_item_markdown_rolls_back_all_state_on_store_failure():
|
||||
"""apply_item_markdown fully restores AgentMemory state if _replace_memory_item fails.
|
||||
|
||||
The test injects a failure inside the store() call that executes AFTER
|
||||
delete_memory() completes, so that at least one real mutation has already
|
||||
happened before the exception is raised. This verifies the rollback path
|
||||
inside _replace_memory_item is exercised through apply_item_markdown, not
|
||||
just through import_data.
|
||||
"""
|
||||
memory = _editable_memory()
|
||||
original_item = deepcopy(memory.get("mem-edit"))
|
||||
original_stats = deepcopy(memory.stats)
|
||||
original_index = list(memory.memory_index)
|
||||
original_stm = [item.memory_id for item in memory.short_term_memory]
|
||||
original_vector_ids = deepcopy(memory._vector_ids)
|
||||
|
||||
source = memory.export_item_markdown("mem-edit")
|
||||
edited = source.replace("Original body", "Replaced body")
|
||||
|
||||
# Patch store() so it succeeds (internally called by _replace_memory_item),
|
||||
# but raises AFTER the new item is inserted into memory_items.
|
||||
real_store = memory.store
|
||||
call_count = 0
|
||||
|
||||
def fail_after_store(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
result = real_store(*args, **kwargs)
|
||||
# Raise on the store() call made by _replace_memory_item (not any earlier call)
|
||||
if call_count >= 1:
|
||||
raise RuntimeError("simulated store failure after insert")
|
||||
return result
|
||||
|
||||
with patch.object(memory, "store", side_effect=fail_after_store):
|
||||
with pytest.raises(RuntimeError, match="simulated store failure"):
|
||||
memory.apply_item_markdown("mem-edit", edited)
|
||||
|
||||
# All state must be identical to what it was before apply_item_markdown
|
||||
assert memory.get("mem-edit") == original_item, (
|
||||
"memory_items must be fully restored after rollback"
|
||||
)
|
||||
assert memory.stats == original_stats, (
|
||||
"stats must be fully restored after rollback"
|
||||
)
|
||||
assert list(memory.memory_index) == original_index, (
|
||||
"memory_index must be fully restored after rollback"
|
||||
)
|
||||
assert [item.memory_id for item in memory.short_term_memory] == original_stm, (
|
||||
"short_term_memory must be fully restored after rollback"
|
||||
)
|
||||
assert memory._vector_ids == original_vector_ids, (
|
||||
"vector_ids must be fully restored after rollback"
|
||||
)
|
||||
# The memory must still be readable and unchanged
|
||||
assert memory.exists("mem-edit")
|
||||
assert memory.get("mem-edit")["content"] == "Original body"
|
||||
assert memory.count() == 1
|
||||
|
||||
@@ -9,6 +9,10 @@ import yaml
|
||||
import semantica.context.context_graph as context_graph_module
|
||||
from semantica.change_management.managers import TemporalVersionManager
|
||||
from semantica.context.context_graph import ContextEdge, ContextGraph, ContextNode
|
||||
from semantica.context.markdown import (
|
||||
MarkdownRevisionConflictError,
|
||||
markdown_document_revision,
|
||||
)
|
||||
|
||||
|
||||
def _read_markdown(path: Path):
|
||||
@@ -711,3 +715,135 @@ def test_json_remains_default_and_unknown_format_is_rejected(tmp_path):
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported context graph"):
|
||||
graph.save_to_file(tmp_path / "graph", format="html")
|
||||
|
||||
|
||||
def _edited_node_document(graph, node_id, **frontmatter_updates):
|
||||
source = graph.export_node_markdown(node_id)
|
||||
frontmatter, body = graph._parse_markdown_document(source, f"node {node_id!r}")
|
||||
frontmatter.update(frontmatter_updates)
|
||||
return graph._render_markdown_document(
|
||||
frontmatter,
|
||||
body.replace("Keep evidence.", "Keep verified evidence."),
|
||||
f"node {node_id!r}",
|
||||
)
|
||||
|
||||
|
||||
def test_single_node_markdown_export_and_apply_update_complete_node_state():
|
||||
graph, _, _ = _sample_graph()
|
||||
events = []
|
||||
graph.mutation_callback = lambda *event: events.append(event)
|
||||
before_edges = list(graph.edges)
|
||||
document = _edited_node_document(
|
||||
graph,
|
||||
"policy/\u6771\u4eac",
|
||||
type="Decision",
|
||||
properties={"category": "retention", "reviewed": True},
|
||||
metadata={"source": "editor"},
|
||||
valid_from="2026-03-01T00:00:00+00:00",
|
||||
valid_until="2028-01-01T00:00:00+00:00",
|
||||
)
|
||||
|
||||
assert graph.apply_node_markdown("policy/\u6771\u4eac", document) is True
|
||||
node = graph.nodes["policy/\u6771\u4eac"]
|
||||
assert node.node_id == "policy/\u6771\u4eac"
|
||||
assert node.node_type == "Decision"
|
||||
assert node.content == "# Retention\n\nKeep verified evidence.\n---\n"
|
||||
assert node.properties == {"category": "retention", "reviewed": True}
|
||||
assert node.metadata == {"source": "editor"}
|
||||
assert node.valid_from == "2026-03-01T00:00:00+00:00"
|
||||
assert node.valid_until == "2028-01-01T00:00:00+00:00"
|
||||
assert "policy/\u6771\u4eac" not in graph.node_type_index.get("Policy", set())
|
||||
assert "policy/\u6771\u4eac" in graph.node_type_index["Decision"]
|
||||
assert "policy/\u6771\u4eac" in graph._decisions
|
||||
assert graph.edges == before_edges
|
||||
assert [event[0] for event in events] == ["UPDATE_NODE"]
|
||||
assert graph.export_node_markdown("policy/\u6771\u4eac") == document
|
||||
|
||||
|
||||
def test_single_node_markdown_transition_out_of_decision_clears_indexes():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
graph.add_node("decision-1", "Decision", "Choose")
|
||||
graph._rebuild_decision_indexes()
|
||||
source = graph.export_node_markdown("decision-1")
|
||||
document = source.replace("type: Decision", "type: Policy")
|
||||
|
||||
assert graph.apply_node_markdown("decision-1", document) is True
|
||||
assert "decision-1" not in graph._decisions
|
||||
|
||||
|
||||
def test_single_node_markdown_invalid_identity_and_yaml_do_not_mutate():
|
||||
graph, _, _ = _sample_graph()
|
||||
events = []
|
||||
graph.mutation_callback = lambda *event: events.append(event)
|
||||
before = _normalized_state(graph)
|
||||
source = graph.export_node_markdown("policy/\u6771\u4eac")
|
||||
|
||||
with pytest.raises(ValueError, match="does not match resource id"):
|
||||
graph.apply_node_markdown(
|
||||
"policy/\u6771\u4eac",
|
||||
source.replace("id: policy/", "id: changed/"),
|
||||
)
|
||||
with pytest.raises(ValueError, match="Invalid Markdown frontmatter"):
|
||||
graph.apply_node_markdown("policy/\u6771\u4eac", "---\nid: [\n---\n\nBroken")
|
||||
|
||||
assert _normalized_state(graph) == before
|
||||
assert events == []
|
||||
|
||||
|
||||
def test_single_node_markdown_index_failure_rolls_back_complete_graph_state():
|
||||
graph, _, _ = _sample_graph()
|
||||
graph._analytics_cache["cached"] = {"score": 1.0}
|
||||
events = []
|
||||
graph.mutation_callback = lambda *event: events.append(event)
|
||||
before = _normalized_state(graph)
|
||||
before_type_index = {
|
||||
node_type: set(node_ids)
|
||||
for node_type, node_ids in graph.node_type_index.items()
|
||||
}
|
||||
before_analytics_cache = dict(graph._analytics_cache)
|
||||
document = _edited_node_document(
|
||||
graph,
|
||||
"policy/\u6771\u4eac",
|
||||
type="Decision",
|
||||
properties={"confidence": {"invalid": True}},
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
graph.apply_node_markdown("policy/\u6771\u4eac", document)
|
||||
|
||||
assert _normalized_state(graph) == before
|
||||
assert {
|
||||
node_type: set(node_ids)
|
||||
for node_type, node_ids in graph.node_type_index.items()
|
||||
} == before_type_index
|
||||
assert graph._analytics_cache == before_analytics_cache
|
||||
assert not hasattr(graph, "_decisions")
|
||||
assert events == []
|
||||
|
||||
|
||||
def test_single_node_markdown_noop_and_missing_node_are_safe():
|
||||
graph, _, _ = _sample_graph()
|
||||
events = []
|
||||
graph.mutation_callback = lambda *event: events.append(event)
|
||||
source = graph.export_node_markdown("policy/\u6771\u4eac")
|
||||
|
||||
assert graph.apply_node_markdown("policy/\u6771\u4eac", source) is False
|
||||
with pytest.raises(KeyError, match="missing"):
|
||||
graph.export_node_markdown("missing")
|
||||
assert events == []
|
||||
|
||||
|
||||
def test_single_node_markdown_revision_check_is_atomic_with_apply():
|
||||
graph, _, _ = _sample_graph()
|
||||
source = graph.export_node_markdown("policy/\u6771\u4eac")
|
||||
expected_revision = markdown_document_revision(source)
|
||||
graph.add_node_attribute("policy/\u6771\u4eac", {"concurrent": True})
|
||||
|
||||
with pytest.raises(MarkdownRevisionConflictError):
|
||||
graph.apply_node_markdown(
|
||||
"policy/\u6771\u4eac",
|
||||
source.replace("Keep evidence.", "Stale edit."),
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
assert graph.nodes["policy/\u6771\u4eac"].properties["concurrent"] is True
|
||||
|
||||
@@ -247,6 +247,18 @@ class TestGraphNodes:
|
||||
assert payload["id"] == "python"
|
||||
assert payload["properties"]["content"] == "Python programming language"
|
||||
|
||||
def test_get_node_by_query_preserves_path_ids(self):
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
graph.add_node("folder/node", node_type="note", content="Nested ID")
|
||||
with TestClient(create_app(session=GraphSession(graph))) as test_client:
|
||||
response = test_client.get(
|
||||
"/api/graph/node",
|
||||
params={"node_id": "folder/node"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == "folder/node"
|
||||
|
||||
def test_get_neighbors(self, client):
|
||||
response = client.get("/api/graph/node/python/neighbors?depth=2")
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -60,6 +60,22 @@ def test_write_route_also_refuses_when_auth_not_configured(client, monkeypatch):
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
def test_markdown_write_route_requires_api_key(client, monkeypatch):
|
||||
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
|
||||
monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key")
|
||||
|
||||
response = client.put(
|
||||
"/api/markdown/context-node/python",
|
||||
headers={"X-API-Key": "wrong-key"},
|
||||
json={
|
||||
"markdown": "---\nid: python\ntype: language\n---\n\nChanged",
|
||||
"expected_revision": "sha256:" + ("0" * 64),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configured key: wrong/missing key rejected, correct key accepted.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from starlette.testclient import TestClient # noqa: E402
|
||||
|
||||
from semantica.context.context_graph import ContextGraph # noqa: E402
|
||||
from semantica.explorer.runtime import install_mutation_bridge # noqa: E402
|
||||
from semantica.explorer.session import GraphSession # noqa: E402
|
||||
|
||||
|
||||
def test_mutation_bridge_supports_multiple_apps_for_one_graph(monkeypatch):
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
first_session = GraphSession(graph)
|
||||
second_session = GraphSession(graph)
|
||||
received = []
|
||||
graph.mutation_callback = lambda *event: received.append(("original", event))
|
||||
|
||||
monkeypatch.setattr(
|
||||
first_session,
|
||||
"handle_graph_mutation",
|
||||
lambda *event: received.append(("first", event)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
second_session,
|
||||
"handle_graph_mutation",
|
||||
lambda *event: received.append(("second", event)),
|
||||
)
|
||||
|
||||
first_app = FastAPI()
|
||||
first_app.state.event_loop = None
|
||||
first_app.state.ws_manager = None
|
||||
second_app = FastAPI()
|
||||
second_app.state.event_loop = None
|
||||
second_app.state.ws_manager = None
|
||||
|
||||
install_mutation_bridge(first_app, first_session)
|
||||
install_mutation_bridge(second_app, second_session)
|
||||
graph.mutation_callback("UPDATE_NODE", "node-1", {"content": "Updated"})
|
||||
|
||||
assert [receiver for receiver, _ in received] == ["second", "first", "original"]
|
||||
|
||||
|
||||
def test_mutation_bridge_is_idempotent_for_one_app(monkeypatch):
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
session = GraphSession(graph)
|
||||
received = []
|
||||
monkeypatch.setattr(
|
||||
session,
|
||||
"handle_graph_mutation",
|
||||
lambda *event: received.append(event),
|
||||
)
|
||||
app = FastAPI()
|
||||
app.state.event_loop = None
|
||||
app.state.ws_manager = None
|
||||
|
||||
install_mutation_bridge(app, session)
|
||||
install_mutation_bridge(app, session)
|
||||
graph.mutation_callback("UPDATE_NODE", "node-1", {"content": "Updated"})
|
||||
|
||||
assert len(received) == 1
|
||||
|
||||
|
||||
def test_mutation_bridge_reinstalls_for_new_session_on_same_app(monkeypatch):
|
||||
first_session = GraphSession(ContextGraph(advanced_analytics=False))
|
||||
second_session = GraphSession(ContextGraph(advanced_analytics=False))
|
||||
received = []
|
||||
monkeypatch.setattr(
|
||||
first_session,
|
||||
"handle_graph_mutation",
|
||||
lambda *event: received.append(("first", event)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
second_session,
|
||||
"handle_graph_mutation",
|
||||
lambda *event: received.append(("second", event)),
|
||||
)
|
||||
app = FastAPI()
|
||||
app.state.event_loop = None
|
||||
app.state.ws_manager = None
|
||||
|
||||
install_mutation_bridge(app, first_session)
|
||||
install_mutation_bridge(app, second_session)
|
||||
second_session.graph.mutation_callback(
|
||||
"UPDATE_NODE",
|
||||
"node-2",
|
||||
{"content": "Updated"},
|
||||
)
|
||||
|
||||
assert [receiver for receiver, _ in received] == ["second"]
|
||||
|
||||
|
||||
def test_legacy_server_mounts_editable_markdown_routes(monkeypatch):
|
||||
monkeypatch.setenv("SEMANTICA_ALLOW_ANONYMOUS", "true")
|
||||
|
||||
from semantica import server
|
||||
|
||||
paths = {route.path for route in server.app.routes}
|
||||
assert "/api/markdown/{kind}/{resource_id:path}" in paths
|
||||
assert "/api/memories" in paths
|
||||
assert "/ws/graph-updates" in paths
|
||||
|
||||
with TestClient(server.app) as client:
|
||||
with client.websocket_connect("/ws/graph-updates") as websocket:
|
||||
acknowledgement = websocket.receive_json()
|
||||
assert acknowledgement["event"] == "connection_ack"
|
||||
assert acknowledgement["data"] == {"connected": True}
|
||||
assert acknowledgement["timestamp"]
|
||||
info = client.get("/api/info")
|
||||
memories = client.get("/api/memories")
|
||||
server.app.state.session.graph.add_node(
|
||||
"server-node",
|
||||
"Note",
|
||||
"Original server content",
|
||||
)
|
||||
current = client.get("/api/markdown/context-node/server-node").json()
|
||||
saved = client.put(
|
||||
"/api/markdown/context-node/server-node",
|
||||
json={
|
||||
"markdown": current["source"].replace(
|
||||
"Original server content",
|
||||
"Updated server content",
|
||||
),
|
||||
"expected_revision": current["revision"],
|
||||
},
|
||||
)
|
||||
|
||||
assert info.json()["capabilities"]["agent_memory"] is False
|
||||
assert memories.status_code == 503
|
||||
assert memories.json()["detail"] == (
|
||||
"AgentMemory is not configured for this Explorer instance."
|
||||
)
|
||||
assert saved.status_code == 200
|
||||
assert saved.json()["body"] == "Updated server content"
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Integration tests for Explorer Markdown and memory selection routes."""
|
||||
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from semantica.context.agent_memory import AgentMemory
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
from starlette.testclient import TestClient # noqa: E402
|
||||
|
||||
from semantica.explorer.app import create_app # noqa: E402
|
||||
from semantica.explorer.session import GraphSession # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def markdown_client():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
graph.add_node("editable-node", "Note", "Original node")
|
||||
events = []
|
||||
graph.mutation_callback = lambda *event: events.append(event)
|
||||
memory = AgentMemory()
|
||||
memory.store(
|
||||
"Original memory",
|
||||
memory_id="editable-memory",
|
||||
metadata={
|
||||
"type": "note",
|
||||
"updated_at": "2026-07-22T10:00:00+00:00",
|
||||
},
|
||||
timestamp=datetime.fromisoformat("2026-07-22T09:00:00+00:00"),
|
||||
)
|
||||
app = create_app(session=GraphSession(graph), agent_memory=memory)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client, graph, memory, events
|
||||
|
||||
|
||||
def test_gets_context_node_and_agent_memory(markdown_client):
|
||||
client, _, _, _ = markdown_client
|
||||
|
||||
info = client.get("/api/info")
|
||||
node = client.get("/api/markdown/context-node/editable-node")
|
||||
memory = client.get("/api/markdown/agent-memory/editable-memory")
|
||||
|
||||
assert info.json()["capabilities"]["agent_memory"] is True
|
||||
assert node.status_code == 200
|
||||
assert node.json()["body"] == "Original node"
|
||||
assert node.json()["resource"] == {
|
||||
"kind": "context-node",
|
||||
"id": "editable-node",
|
||||
}
|
||||
assert node.json()["source"].startswith("---\n")
|
||||
assert node.json()["revision"].startswith("sha256:")
|
||||
assert node.json()["editable"] is True
|
||||
assert memory.status_code == 200
|
||||
assert memory.json()["body"] == "Original memory"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "resource_id", "original", "updated"),
|
||||
[
|
||||
("context-node", "editable-node", "Original node", "Updated node"),
|
||||
("agent-memory", "editable-memory", "Original memory", "Updated memory"),
|
||||
],
|
||||
)
|
||||
def test_put_updates_exact_resource(
|
||||
markdown_client, kind, resource_id, original, updated
|
||||
):
|
||||
client, graph, memory, _ = markdown_client
|
||||
current = client.get(f"/api/markdown/{kind}/{resource_id}").json()
|
||||
|
||||
response = client.put(
|
||||
f"/api/markdown/{kind}/{resource_id}",
|
||||
json={
|
||||
"markdown": current["source"].replace(original, updated),
|
||||
"expected_revision": current["revision"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["changed"] is True
|
||||
assert response.json()["body"] == updated
|
||||
assert client.get(f"/api/markdown/{kind}/{resource_id}").json()["body"] == updated
|
||||
if kind == "context-node":
|
||||
assert graph.nodes[resource_id].content == updated
|
||||
assert memory.get("editable-memory")["content"] == "Original memory"
|
||||
else:
|
||||
assert memory.get(resource_id)["content"] == updated
|
||||
assert graph.nodes["editable-node"].content == "Original node"
|
||||
|
||||
|
||||
def test_validation_and_identity_errors_preserve_resource(markdown_client):
|
||||
client, graph, _, events = markdown_client
|
||||
current = client.get("/api/markdown/context-node/editable-node").json()
|
||||
|
||||
invalid = client.put(
|
||||
"/api/markdown/context-node/editable-node",
|
||||
json={
|
||||
"markdown": "---\nid: [\n---\n\nBroken",
|
||||
"expected_revision": current["revision"],
|
||||
},
|
||||
)
|
||||
mismatch = client.put(
|
||||
"/api/markdown/context-node/editable-node",
|
||||
json={
|
||||
"markdown": current["source"].replace(
|
||||
"id: editable-node", "id: different-node"
|
||||
),
|
||||
"expected_revision": current["revision"],
|
||||
},
|
||||
)
|
||||
|
||||
assert invalid.status_code == 422
|
||||
assert invalid.json()["detail"]["code"] == "invalid_markdown_frontmatter"
|
||||
assert "invalid YAML" in invalid.json()["detail"]["message"]
|
||||
assert mismatch.status_code == 422
|
||||
assert mismatch.json()["detail"] == {
|
||||
"code": "resource_identity_mismatch",
|
||||
"message": (
|
||||
"Frontmatter id 'different-node' does not match resource id "
|
||||
"'editable-node'."
|
||||
),
|
||||
"field": "id",
|
||||
}
|
||||
assert graph.nodes["editable-node"].content == "Original node"
|
||||
assert events == []
|
||||
|
||||
|
||||
def test_stale_revision_returns_conflict(markdown_client):
|
||||
client, _, _, _ = markdown_client
|
||||
current = client.get("/api/markdown/context-node/editable-node").json()
|
||||
first = client.put(
|
||||
"/api/markdown/context-node/editable-node",
|
||||
json={
|
||||
"markdown": current["source"].replace("Original node", "First update"),
|
||||
"expected_revision": current["revision"],
|
||||
},
|
||||
)
|
||||
stale = client.put(
|
||||
"/api/markdown/context-node/editable-node",
|
||||
json={
|
||||
"markdown": current["source"].replace("Original node", "Stale update"),
|
||||
"expected_revision": current["revision"],
|
||||
},
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["detail"]["code"] == "markdown_revision_conflict"
|
||||
assert stale.json()["detail"]["current_revision"] == first.json()["revision"]
|
||||
|
||||
|
||||
def test_missing_and_failed_apply_are_structured(markdown_client, monkeypatch):
|
||||
client, graph, _, _ = markdown_client
|
||||
missing = client.get("/api/markdown/context-node/missing")
|
||||
current = client.get("/api/markdown/context-node/editable-node").json()
|
||||
|
||||
def fail(_resource_id, _document):
|
||||
raise RuntimeError("private storage failure")
|
||||
|
||||
monkeypatch.setattr(graph, "apply_node_markdown", fail)
|
||||
failed = client.put(
|
||||
"/api/markdown/context-node/editable-node",
|
||||
json={
|
||||
"markdown": current["source"].replace("Original node", "Will fail"),
|
||||
"expected_revision": current["revision"],
|
||||
},
|
||||
)
|
||||
|
||||
assert missing.status_code == 404
|
||||
assert missing.json()["detail"]["code"] == "markdown_resource_not_found"
|
||||
assert failed.status_code == 500
|
||||
assert failed.json()["detail"] == {
|
||||
"code": "markdown_save_failed",
|
||||
"message": "The edit could not be applied. The existing item was not changed.",
|
||||
}
|
||||
assert "private storage failure" not in failed.text
|
||||
|
||||
|
||||
def test_lists_memories_for_selection(markdown_client):
|
||||
client, _, _, _ = markdown_client
|
||||
|
||||
response = client.get("/api/memories?skip=0&limit=100")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [
|
||||
{
|
||||
"id": "editable-memory",
|
||||
"type": "note",
|
||||
"excerpt": "Original memory",
|
||||
"updated_at": "2026-07-22T10:00:00+00:00",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"skip": 0,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
|
||||
def test_memory_listing_is_atomic_with_concurrent_mutation(
|
||||
markdown_client, monkeypatch
|
||||
):
|
||||
client, _, memory, _ = markdown_client
|
||||
snapshot_ready = threading.Event()
|
||||
mutation_attempted = threading.Event()
|
||||
mutation_finished = threading.Event()
|
||||
|
||||
def pause_after_key_snapshot(*, offset=0, limit=100, **_filters):
|
||||
memory_ids = list(memory.memory_items.keys())[offset : offset + limit]
|
||||
snapshot_ready.set()
|
||||
assert mutation_attempted.wait(timeout=1)
|
||||
mutation_finished.wait(timeout=0.25)
|
||||
|
||||
records = []
|
||||
for memory_id in memory_ids:
|
||||
# Match AgentMemory.list()'s second dictionary lookup after its
|
||||
# key snapshot; this raises if a concurrent writer is not excluded.
|
||||
memory.memory_items[memory_id]
|
||||
record = memory.get_memory(memory_id)
|
||||
if record is not None:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
monkeypatch.setattr(memory, "list", pause_after_key_snapshot)
|
||||
|
||||
def delete_memory() -> None:
|
||||
assert snapshot_ready.wait(timeout=1)
|
||||
mutation_attempted.set()
|
||||
memory.delete_memory("editable-memory")
|
||||
mutation_finished.set()
|
||||
|
||||
writer = threading.Thread(target=delete_memory)
|
||||
writer.start()
|
||||
try:
|
||||
response = client.get("/api/memories?skip=0&limit=100")
|
||||
finally:
|
||||
writer.join(timeout=2)
|
||||
|
||||
assert not writer.is_alive()
|
||||
assert response.status_code == 200
|
||||
assert [item["id"] for item in response.json()["items"]] == ["editable-memory"]
|
||||
assert response.json()["total"] == 1
|
||||
|
||||
|
||||
def test_default_app_does_not_expose_agent_memory():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
with TestClient(create_app(session=GraphSession(graph))) as client:
|
||||
info = client.get("/api/info")
|
||||
memories = client.get("/api/memories")
|
||||
|
||||
assert info.json()["capabilities"]["agent_memory"] is False
|
||||
assert memories.status_code == 503
|
||||
assert memories.json()["detail"] == (
|
||||
"AgentMemory is not configured for this Explorer instance."
|
||||
)
|
||||
|
||||
|
||||
def test_slash_and_unicode_node_id_round_trip():
|
||||
"""GET and PUT work for node IDs containing '/' and Unicode characters.
|
||||
|
||||
Tests that:
|
||||
- encodeURIComponent-style %2F encoding is transparent to the route
|
||||
- the returned resource.id is the original unencoded string
|
||||
- a successful PUT persists to the correct node
|
||||
- the node ID is never rewritten by the apply path
|
||||
"""
|
||||
node_id = "policy/\u6771\u4eac" # "policy/東京"
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
graph.add_node(node_id, "Policy", "Original slash body")
|
||||
app = create_app(session=GraphSession(graph))
|
||||
|
||||
with TestClient(app) as client:
|
||||
# GET via percent-encoded path (what encodeURIComponent produces)
|
||||
import urllib.parse
|
||||
encoded_id = urllib.parse.quote(node_id, safe="")
|
||||
get_response = client.get(f"/api/markdown/context-node/{encoded_id}")
|
||||
|
||||
assert get_response.status_code == 200, (
|
||||
f"GET returned {get_response.status_code}: {get_response.text}"
|
||||
)
|
||||
doc = get_response.json()
|
||||
assert doc["resource"]["id"] == node_id, (
|
||||
f"resource.id should be {node_id!r}, got {doc['resource']['id']!r}"
|
||||
)
|
||||
assert doc["resource"]["kind"] == "context-node"
|
||||
assert doc["body"] == "Original slash body"
|
||||
assert doc["revision"].startswith("sha256:")
|
||||
|
||||
# PUT an edit back via the same percent-encoded URL
|
||||
updated_markdown = doc["source"].replace("Original slash body", "Updated slash body")
|
||||
put_response = client.put(
|
||||
f"/api/markdown/context-node/{encoded_id}",
|
||||
json={
|
||||
"markdown": updated_markdown,
|
||||
"expected_revision": doc["revision"],
|
||||
},
|
||||
)
|
||||
|
||||
assert put_response.status_code == 200, (
|
||||
f"PUT returned {put_response.status_code}: {put_response.text}"
|
||||
)
|
||||
result = put_response.json()
|
||||
assert result["changed"] is True
|
||||
assert result["body"] == "Updated slash body"
|
||||
assert result["resource"]["id"] == node_id, (
|
||||
f"PUT response resource.id should be {node_id!r}, got {result['resource']['id']!r}"
|
||||
)
|
||||
|
||||
# Verify the live graph node was updated
|
||||
assert graph.nodes[node_id].content == "Updated slash body"
|
||||
|
||||
# Also verify GET via literal slash URL works identically
|
||||
get_literal = client.get(f"/api/markdown/context-node/{node_id}")
|
||||
assert get_literal.status_code == 200
|
||||
assert get_literal.json()["resource"]["id"] == node_id
|
||||
assert get_literal.json()["body"] == "Updated slash body"
|
||||
@@ -0,0 +1,115 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from semantica.context.agent_memory import AgentMemory
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
from semantica.explorer.markdown_resources import (
|
||||
MarkdownResourceKind,
|
||||
MarkdownResourceNotFound,
|
||||
MarkdownResourceRef,
|
||||
MarkdownResourceRegistry,
|
||||
MarkdownRevisionConflict,
|
||||
MarkdownSaveFailed,
|
||||
document_revision,
|
||||
)
|
||||
|
||||
|
||||
def _resources():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
graph.add_node("node-1", "Note", "Original node")
|
||||
graph.add_node("node-2", "Note", "Second node")
|
||||
memory = AgentMemory()
|
||||
memory.store(
|
||||
"Original memory",
|
||||
memory_id="mem-1",
|
||||
timestamp=datetime.fromisoformat("2026-07-22T09:00:00+00:00"),
|
||||
metadata={
|
||||
"type": "note",
|
||||
"updated_at": "2026-07-22T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
return graph, memory, MarkdownResourceRegistry(graph, memory)
|
||||
|
||||
|
||||
def test_registry_routes_context_nodes_and_returns_canonical_revision():
|
||||
graph, _, resources = _resources()
|
||||
ref = MarkdownResourceRef(MarkdownResourceKind.CONTEXT_NODE, "node-1")
|
||||
|
||||
document = resources.read(ref)
|
||||
|
||||
assert document.body == "Original node"
|
||||
assert document.revision == document_revision(document.source)
|
||||
edited = document.source.replace("Original node", "Updated node")
|
||||
result = resources.apply(ref, edited, document.revision)
|
||||
assert result.changed is True
|
||||
assert result.body == "Updated node"
|
||||
assert graph.nodes["node-1"].content == "Updated node"
|
||||
|
||||
|
||||
def test_registry_routes_agent_memory_without_touching_context_graph():
|
||||
graph, memory, resources = _resources()
|
||||
ref = MarkdownResourceRef(MarkdownResourceKind.AGENT_MEMORY, "mem-1")
|
||||
before_nodes = dict(graph.nodes)
|
||||
document = resources.read(ref)
|
||||
|
||||
result = resources.apply(
|
||||
ref,
|
||||
document.source.replace("Original memory", "Updated memory"),
|
||||
document.revision,
|
||||
)
|
||||
|
||||
assert result.body == "Updated memory"
|
||||
assert memory.get("mem-1")["content"] == "Updated memory"
|
||||
assert graph.nodes == before_nodes
|
||||
|
||||
|
||||
def test_registry_rejects_stale_revision_and_preserves_current_state():
|
||||
graph, _, resources = _resources()
|
||||
ref = MarkdownResourceRef(MarkdownResourceKind.CONTEXT_NODE, "node-1")
|
||||
first = resources.read(ref)
|
||||
resources.apply(
|
||||
ref,
|
||||
first.source.replace("Original node", "First update"),
|
||||
first.revision,
|
||||
)
|
||||
|
||||
with pytest.raises(MarkdownRevisionConflict) as error:
|
||||
resources.apply(
|
||||
ref,
|
||||
first.source.replace("Original node", "Stale update"),
|
||||
first.revision,
|
||||
)
|
||||
|
||||
assert error.value.current_revision == resources.read(ref).revision
|
||||
assert graph.nodes["node-1"].content == "First update"
|
||||
|
||||
|
||||
def test_registry_missing_adapter_and_resources_are_structured():
|
||||
graph, _, _ = _resources()
|
||||
resources = MarkdownResourceRegistry(graph)
|
||||
|
||||
with pytest.raises(MarkdownResourceNotFound, match="not available"):
|
||||
resources.read(MarkdownResourceRef(MarkdownResourceKind.AGENT_MEMORY, "mem-1"))
|
||||
with pytest.raises(MarkdownResourceNotFound, match="missing"):
|
||||
resources.read(
|
||||
MarkdownResourceRef(MarkdownResourceKind.CONTEXT_NODE, "missing")
|
||||
)
|
||||
|
||||
|
||||
def test_registry_does_not_expose_adapter_failure_details(monkeypatch):
|
||||
graph, _, resources = _resources()
|
||||
ref = MarkdownResourceRef(MarkdownResourceKind.CONTEXT_NODE, "node-1")
|
||||
document = resources.read(ref)
|
||||
|
||||
def fail(_resource_id, _source):
|
||||
raise RuntimeError("private backend failure")
|
||||
|
||||
monkeypatch.setattr(graph, "apply_node_markdown", fail)
|
||||
with pytest.raises(MarkdownSaveFailed) as error:
|
||||
resources.apply(ref, document.source + "changed", document.revision)
|
||||
|
||||
assert error.value.message == (
|
||||
"The edit could not be applied. The existing item was not changed."
|
||||
)
|
||||
assert "private backend failure" not in error.value.message
|
||||
@@ -12,7 +12,11 @@ from semantica.context.context_graph import ContextGraph
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
from semantica.explorer.app import create_app # noqa: E402
|
||||
from semantica.explorer.routes.ontology import OntologyEntry # noqa: E402
|
||||
from semantica.explorer.routes.ontology import ( # noqa: E402
|
||||
OntologyEntry,
|
||||
_convert_ontology_to_graph,
|
||||
_node_belongs_to_ontology,
|
||||
)
|
||||
from semantica.explorer.session import GraphSession # noqa: E402
|
||||
|
||||
from starlette.testclient import TestClient # noqa: E402
|
||||
@@ -131,6 +135,181 @@ def test_health_returns_dimensions_and_issues(client):
|
||||
assert isinstance(payload["issues"], list)
|
||||
|
||||
|
||||
def test_ontology_graph_returns_editable_schema_nodes_and_edges(client):
|
||||
response = client.get(
|
||||
"/api/ontology/graph",
|
||||
params={"uri": "http://example.org/onto-a"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
node_ids = {node["id"] for node in payload["nodes"]}
|
||||
assert "http://example.org/onto-a" in node_ids
|
||||
assert "http://example.org/onto-a#Person" in node_ids
|
||||
assert "http://example.org/onto-a#name" in node_ids
|
||||
assert any(
|
||||
edge["source"] == "http://example.org/onto-a#name"
|
||||
and edge["target"] == "http://example.org/onto-a#Person"
|
||||
and edge["type"] == "rdfs:domain"
|
||||
for edge in payload["edges"]
|
||||
)
|
||||
|
||||
|
||||
def test_ontology_graph_rejects_unregistered_namespace(client):
|
||||
response = client.get(
|
||||
"/api/ontology/graph",
|
||||
params={"uri": "http://example.org"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_ontology_graph_excludes_separately_registered_nested_ontology(client):
|
||||
graph = client.app.state.session.graph
|
||||
nested = "http://example.org/onto-a/nested"
|
||||
nested_class = f"{nested}#PrivateClass"
|
||||
graph.add_node(nested, node_type="owl:Ontology", content="Nested Ontology")
|
||||
graph.add_node(nested_class, node_type="owl:Class", content="Private Class")
|
||||
|
||||
response = client.get(
|
||||
"/api/ontology/graph",
|
||||
params={"uri": "http://example.org/onto-a"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
node_ids = {node["id"] for node in response.json()["nodes"]}
|
||||
assert nested not in node_ids
|
||||
assert nested_class not in node_ids
|
||||
|
||||
|
||||
def test_ontology_graph_prefers_explicit_ownership_over_uri_namespace(client):
|
||||
graph = client.app.state.session.graph
|
||||
explicit_member = "http://unrelated.example/Person"
|
||||
graph.add_node(
|
||||
explicit_member,
|
||||
node_type="owl:Class",
|
||||
content="Explicit Member",
|
||||
scheme_uri="http://example.org/onto-a",
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/ontology/graph",
|
||||
params={"uri": "http://example.org/onto-a"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert explicit_member in {node["id"] for node in response.json()["nodes"]}
|
||||
|
||||
|
||||
def test_ontology_graph_excludes_inward_edges_from_other_ontologies(client):
|
||||
graph = client.app.state.session.graph
|
||||
foreign_prop = "http://example.org/onto-b#recordOf"
|
||||
graph.add_node(
|
||||
foreign_prop,
|
||||
node_type="owl:ObjectProperty",
|
||||
content="record of",
|
||||
scheme_uri="http://example.org/onto-b",
|
||||
)
|
||||
# onto-b's property points its domain at onto-a's class: an inward
|
||||
# reference that must not pull the foreign property into onto-a's graph.
|
||||
graph.add_edge(foreign_prop, "http://example.org/onto-a#Person", edge_type="rdfs:domain")
|
||||
|
||||
response = client.get(
|
||||
"/api/ontology/graph",
|
||||
params={"uri": "http://example.org/onto-a"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert foreign_prop not in {node["id"] for node in payload["nodes"]}
|
||||
assert all(edge["source"] != foreign_prop for edge in payload["edges"])
|
||||
|
||||
|
||||
def test_ontology_graph_excludes_unregistered_nested_namespace(client):
|
||||
graph = client.app.state.session.graph
|
||||
nested_class = "http://example.org/onto-a/vocab#Term"
|
||||
graph.add_node(nested_class, node_type="owl:Class", content="Nested Term")
|
||||
|
||||
response = client.get(
|
||||
"/api/ontology/graph",
|
||||
params={"uri": "http://example.org/onto-a"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert nested_class not in {node["id"] for node in response.json()["nodes"]}
|
||||
|
||||
|
||||
def test_node_belongs_to_ontology_nested_namespace_matrix():
|
||||
parent = "http://example.org/onto-a"
|
||||
child = "http://example.org/onto-a/nested"
|
||||
|
||||
def node(node_id):
|
||||
return {"id": node_id, "properties": {}}
|
||||
|
||||
assert _node_belongs_to_ontology(node(f"{parent}#Person"), parent, {parent})
|
||||
assert _node_belongs_to_ontology(node(f"{parent}/Person"), parent, {parent})
|
||||
# An unregistered nested namespace is not absorbed into the parent,
|
||||
# whether fragment-based or path-based
|
||||
assert not _node_belongs_to_ontology(node(f"{child}#Term"), parent, {parent})
|
||||
assert not _node_belongs_to_ontology(node(f"{child}/Term"), parent, {parent})
|
||||
# Once registered, the nested namespace owns its nodes
|
||||
assert not _node_belongs_to_ontology(node(f"{child}#Term"), parent, {parent, child})
|
||||
assert _node_belongs_to_ontology(node(f"{child}#Term"), child, {parent, child})
|
||||
assert _node_belongs_to_ontology(node(f"{child}/Term"), child, {parent, child})
|
||||
|
||||
|
||||
def test_load_fallback_import_without_declaration_is_editable(client):
|
||||
turtle = """
|
||||
@prefix ex: <http://data.example.org/people#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
ex:Employee a rdfs:Class ;
|
||||
rdfs:label "Employee" .
|
||||
ex:manager a rdf:Property ;
|
||||
rdfs:label "manager" .
|
||||
"""
|
||||
with patch(
|
||||
"semantica.ingest.ontology_ingestor.OntologyIngestor.ingest_ontology",
|
||||
side_effect=RuntimeError("force fallback parser"),
|
||||
):
|
||||
loaded = client.post(
|
||||
"/api/ontology/load",
|
||||
json={"content": turtle, "format": "turtle"},
|
||||
)
|
||||
assert loaded.status_code == 200
|
||||
uri = loaded.json()["uri"]
|
||||
assert uri.startswith("urn:semantica:onto:")
|
||||
|
||||
response = client.get("/api/ontology/graph", params={"uri": uri})
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
node_ids = {node["id"] for node in payload["nodes"]}
|
||||
assert uri in node_ids
|
||||
assert "http://data.example.org/people#Employee" in node_ids
|
||||
|
||||
|
||||
def test_ontology_graph_ignores_unrelated_data_when_enforcing_size_limit(client):
|
||||
graph = client.app.state.session.graph
|
||||
for index in range(5_001):
|
||||
graph.add_node(
|
||||
f"urn:unrelated:{index}",
|
||||
node_type="owl:Class",
|
||||
content="Unrelated",
|
||||
scheme_uri="http://example.org/onto-b",
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/ontology/graph",
|
||||
params={"uri": "http://example.org/onto-a"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "http://example.org/onto-a#Person" in {
|
||||
node["id"] for node in response.json()["nodes"]
|
||||
}
|
||||
|
||||
|
||||
def test_shacl_generate_and_shapes(client):
|
||||
response = client.post(
|
||||
"/api/ontology/shacl/generate",
|
||||
@@ -696,6 +875,29 @@ def test_ontology_load_does_not_swallow_422_from_ingestor_success_path(client):
|
||||
fallback_parse.assert_not_called()
|
||||
|
||||
|
||||
def test_convert_ontology_uses_standard_property_types_and_scheme_uri():
|
||||
ontology_uri = "http://example.org/onto"
|
||||
nodes, _ = _convert_ontology_to_graph(
|
||||
{
|
||||
"uri": ontology_uri,
|
||||
"name": "Example Ontology",
|
||||
"classes": [
|
||||
{"uri": f"{ontology_uri}#Person", "name": "Person"},
|
||||
],
|
||||
"properties": [
|
||||
{"uri": f"{ontology_uri}#name", "name": "name", "type": "data"},
|
||||
{"uri": f"{ontology_uri}#knows", "name": "knows", "type": "object"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
by_id = {node["id"]: node for node in nodes}
|
||||
assert by_id[f"{ontology_uri}#Person"]["properties"]["scheme_uri"] == ontology_uri
|
||||
assert by_id[f"{ontology_uri}#name"]["type"] == "owl:DatatypeProperty"
|
||||
assert by_id[f"{ontology_uri}#knows"]["type"] == "owl:ObjectProperty"
|
||||
assert by_id[f"{ontology_uri}#name"]["properties"]["scheme_uri"] == ontology_uri
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# refresh_ontology — single combined add_nodes_and_edges() coverage (#775)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -789,5 +991,3 @@ def test_refresh_ontology_missing_source_url_returns_422(client):
|
||||
response = client.post(f"/api/ontology/{encoded_uri}/refresh")
|
||||
assert response.status_code == 422
|
||||
assert "source url" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Framework/control entity keys must not be inferred as datatype properties.
|
||||
|
||||
The _CONTROL_FIELDS skip set mirrors exactly what the framework writes to a
|
||||
merged entity's top level (see MergeStrategyManager._merge_entities): no extra
|
||||
guesses, so business attributes that merely share a common name (e.g. source)
|
||||
keep getting inferred.
|
||||
"""
|
||||
|
||||
from semantica.deduplication.merge_strategy import MergeStrategyManager
|
||||
from semantica.ontology.property_generator import PropertyGenerator
|
||||
|
||||
|
||||
def _merged_entity():
|
||||
"""Run a real merge so the entity carries the framework's actual top-level keys."""
|
||||
manager = MergeStrategyManager(default_strategy="keep_most_complete")
|
||||
result = manager.merge_entities(
|
||||
[
|
||||
{"id": "b1", "name": "Hangzhou Branch", "type": "ORG", "employee_count": 120},
|
||||
{"id": "b2", "name": "Hangzhou Branch", "type": "ORG"},
|
||||
]
|
||||
)
|
||||
return result.merged_entity
|
||||
|
||||
|
||||
def test_framework_fields_not_inferred_as_data_properties():
|
||||
entity = _merged_entity()
|
||||
classes = [{"name": "Organization", "metadata": {"inferred_from": "ORG"}}]
|
||||
|
||||
properties = PropertyGenerator().infer_properties([entity], [], classes)
|
||||
|
||||
names = {p["name"] for p in properties}
|
||||
for framed in (
|
||||
"properties",
|
||||
"relationships",
|
||||
"metadata",
|
||||
"merged_from",
|
||||
"merge_strategy",
|
||||
):
|
||||
assert framed not in names, f"framework field {framed} leaked as a property"
|
||||
|
||||
|
||||
def test_business_attributes_still_inferred():
|
||||
entity = _merged_entity()
|
||||
classes = [{"name": "Organization", "metadata": {"inferred_from": "ORG"}}]
|
||||
|
||||
properties = PropertyGenerator().infer_properties([entity], [], classes)
|
||||
|
||||
names = {p["name"] for p in properties}
|
||||
assert "name" in names
|
||||
assert "metadata" not in names
|
||||
|
||||
|
||||
def test_source_field_still_inferred_as_business_attribute():
|
||||
"""A top-level 'source' is a business attribute, not a framework field."""
|
||||
entity = {
|
||||
"id": "b1",
|
||||
"name": "Hangzhou Branch",
|
||||
"type": "ORG",
|
||||
"source": "doc-42",
|
||||
}
|
||||
classes = [{"name": "Organization", "metadata": {"inferred_from": "ORG"}}]
|
||||
|
||||
properties = PropertyGenerator().infer_properties([entity], [], classes)
|
||||
|
||||
names = {p["name"] for p in properties}
|
||||
assert "source" in names
|
||||
|
||||
|
||||
def test_unmerged_graphbuilder_entities_infer_business_attributes():
|
||||
"""Flat entities from GraphBuilder (merge_entities=False) must not lose business
|
||||
attributes through _CONTROL_FIELDS: name and domain-specific fields must be
|
||||
inferred, and none of the framework keys should appear in the output."""
|
||||
from semantica.kg.graph_builder import GraphBuilder
|
||||
|
||||
builder = GraphBuilder(merge_entities=False, resolve_conflicts=False)
|
||||
graph = builder.build(
|
||||
{
|
||||
"entities": [
|
||||
{"id": "c1", "name": "Chengdu Plant", "type": "ORG", "headcount": 300},
|
||||
{"id": "c2", "name": "Wuhan Plant", "type": "ORG", "headcount": 450},
|
||||
],
|
||||
"relationships": [],
|
||||
}
|
||||
)
|
||||
entities = graph["entities"]
|
||||
classes = [{"name": "Organization", "metadata": {"inferred_from": "ORG"}}]
|
||||
|
||||
properties = PropertyGenerator().infer_properties(entities, [], classes)
|
||||
|
||||
names = {p["name"] for p in properties}
|
||||
assert "name" in names, "name must be inferred from flat GraphBuilder entities"
|
||||
assert "headcount" in names, "domain business attribute must be inferred"
|
||||
for framed in ("properties", "relationships", "metadata", "merged_from", "merge_strategy"):
|
||||
assert framed not in names, f"framework field {framed!r} must not appear"
|
||||
@@ -0,0 +1,308 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from semantica.ontology import (
|
||||
OntologyEngine,
|
||||
OntologyQualityGate,
|
||||
QualitySeverity,
|
||||
ontology_quality_check,
|
||||
)
|
||||
|
||||
|
||||
def _ontology():
|
||||
return {
|
||||
"classes": [
|
||||
{"name": "Person", "uri": "https://example.org/Person"},
|
||||
{"name": "Company", "uri": "https://example.org/Company"},
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "worksFor",
|
||||
"type": "object",
|
||||
"domain": ["Person"],
|
||||
"range": ["Company"],
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"type": "data",
|
||||
"domain": ["Person"],
|
||||
"range": "string",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_quality_gate_reports_a_healthy_ontology():
|
||||
report = ontology_quality_check(_ontology())
|
||||
|
||||
assert report.passed
|
||||
assert report.metrics["coverage"] == 1.0
|
||||
assert report.error_count == 0
|
||||
assert report.to_dict()["stats"]["properties"] == 2
|
||||
|
||||
|
||||
def test_quality_gate_finds_schema_and_endpoint_problems():
|
||||
ontology = {
|
||||
"classes": [{"name": "Person"}, {"name": "Unused"}],
|
||||
"properties": [
|
||||
{
|
||||
"name": "worksFor",
|
||||
"type": "object",
|
||||
"domain": ["Person"],
|
||||
"range": ["MissingCompany"],
|
||||
},
|
||||
{"name": "unattached", "type": "data"},
|
||||
],
|
||||
}
|
||||
graph = {
|
||||
"entities": [{"id": "p1", "type": "Person"}],
|
||||
"relationships": [
|
||||
{"source_id": "p1", "target_id": "missing", "type": "worksFor"}
|
||||
],
|
||||
}
|
||||
|
||||
report = OntologyQualityGate().check(ontology, graph_data=graph)
|
||||
codes = {issue.code for issue in report.issues}
|
||||
|
||||
assert not report.passed
|
||||
assert "UNKNOWN_RANGE" in codes
|
||||
assert "UNRESOLVED_RELATIONSHIP_ENDPOINT" in codes
|
||||
assert "ORPHAN_CLASS" in codes
|
||||
assert "MISSING_RANGE" in codes
|
||||
assert any(issue.severity == QualitySeverity.WARNING for issue in report.issues)
|
||||
|
||||
|
||||
def test_quality_gate_supports_thresholds_and_legacy_endpoint_keys():
|
||||
ontology = {
|
||||
"classes": [{"name": "Person"}],
|
||||
"properties": [
|
||||
{
|
||||
"name": "name",
|
||||
"type": "data",
|
||||
"domain": "Person",
|
||||
"range": "xsd:string",
|
||||
}
|
||||
],
|
||||
}
|
||||
graph = {
|
||||
"entities": [{"entity_id": "p1", "type": "Person", "name": "Alice"}],
|
||||
"relationships": [{"source": "p1", "target": "p1", "type": "knows"}],
|
||||
}
|
||||
|
||||
report = ontology_quality_check(
|
||||
ontology,
|
||||
graph_data=graph,
|
||||
thresholds={"min_coverage": 1.0},
|
||||
)
|
||||
|
||||
assert report.passed
|
||||
assert report.threshold_failures == []
|
||||
assert report.stats["relationships"] == 1
|
||||
|
||||
|
||||
def test_quality_gate_can_fail_on_warnings():
|
||||
report = ontology_quality_check(
|
||||
{"classes": [{"name": "Person"}], "properties": []},
|
||||
fail_on_warnings=True,
|
||||
)
|
||||
|
||||
assert not report.passed
|
||||
assert "fail_on_warnings" in report.threshold_failures
|
||||
|
||||
|
||||
def test_engine_exposes_quality_check():
|
||||
report = OntologyEngine().quality_check(_ontology())
|
||||
|
||||
assert report.passed
|
||||
|
||||
|
||||
def test_quality_gate_accepts_canonical_context_graph_entities_without_mutation():
|
||||
graph = {
|
||||
"entities": [
|
||||
{"id": "p1", "text": "Alice", "type": "Person"},
|
||||
{"id": "c1", "text": "Acme", "type": "Company"},
|
||||
],
|
||||
"relationships": [{"source_id": "p1", "target_id": "c1", "type": "worksFor"}],
|
||||
}
|
||||
|
||||
report = OntologyQualityGate().check(_ontology(), graph_data=graph)
|
||||
|
||||
assert report.passed
|
||||
assert "name" not in graph["entities"][0]
|
||||
|
||||
|
||||
def test_quality_gate_returns_structured_findings_for_malformed_graph_members():
|
||||
report = OntologyQualityGate().check(
|
||||
_ontology(), graph_data={"entities": [None], "relationships": [None]}
|
||||
)
|
||||
codes = {issue.code for issue in report.issues}
|
||||
|
||||
assert not report.passed
|
||||
assert {"INVALID_ENTITY", "INVALID_RELATIONSHIP"} <= codes
|
||||
|
||||
|
||||
def test_quality_gate_returns_structured_finding_for_invalid_graph_containers():
|
||||
report = OntologyQualityGate().check(
|
||||
_ontology(), graph_data={"entities": None, "relationships": []}
|
||||
)
|
||||
|
||||
assert not report.passed
|
||||
assert any(issue.code == "INVALID_GRAPH" for issue in report.issues)
|
||||
|
||||
|
||||
def test_quality_gate_does_not_undercount_identical_dangling_edges():
|
||||
graph = {
|
||||
"entities": [{"id": "p1", "name": "Alice", "type": "Person"}],
|
||||
"relationships": [
|
||||
{"source": "p1", "target": "missing", "type": "knows"},
|
||||
{"source": "p1", "target": "missing", "type": "knows"},
|
||||
],
|
||||
}
|
||||
|
||||
report = OntologyQualityGate().check(
|
||||
_ontology(), graph_data=graph, thresholds={"max_errors": 1}
|
||||
)
|
||||
|
||||
endpoint_errors = [
|
||||
issue
|
||||
for issue in report.issues
|
||||
if issue.code == "UNRESOLVED_RELATIONSHIP_ENDPOINT"
|
||||
]
|
||||
assert len(endpoint_errors) == 2
|
||||
assert not report.passed
|
||||
assert "max_errors" in report.threshold_failures
|
||||
|
||||
|
||||
def test_quality_gate_indexes_all_class_identifier_aliases():
|
||||
ontology = {
|
||||
"classes": [{"name": "Person", "uri": "https://example.org/PersonType"}],
|
||||
"properties": [
|
||||
{
|
||||
"name": "name",
|
||||
"type": "data",
|
||||
"domain": "https://example.org/PersonType",
|
||||
"range": "string",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
report = OntologyQualityGate().check(ontology)
|
||||
codes = {issue.code for issue in report.issues}
|
||||
|
||||
assert "UNKNOWN_DOMAIN" not in codes
|
||||
|
||||
|
||||
def test_quality_gate_recognizes_subclass_of_hierarchies():
|
||||
ontology = {
|
||||
"classes": [
|
||||
{"name": "Person"},
|
||||
{"name": "Employee", "subclassOf": "Person"},
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "name",
|
||||
"type": "data",
|
||||
"domain": "Employee",
|
||||
"range": "string",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
report = OntologyQualityGate().check(ontology)
|
||||
|
||||
assert not any(issue.code == "ORPHAN_CLASS" for issue in report.issues)
|
||||
|
||||
|
||||
def test_quality_gate_keeps_competency_evaluation_safe_for_malformed_members():
|
||||
ontology = {
|
||||
"classes": [None, {"uri": "https://example.org/Person"}],
|
||||
"properties": [
|
||||
None,
|
||||
{
|
||||
"uri": "https://example.org/name",
|
||||
"type": "data",
|
||||
"domain": "Person",
|
||||
"range": "string",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
report = OntologyQualityGate().check(
|
||||
ontology, competency_questions=["What is a person's name?"]
|
||||
)
|
||||
|
||||
assert isinstance(report.metrics["competency_question_coverage"], float)
|
||||
|
||||
|
||||
def test_quality_gate_reports_validator_warnings_and_can_fail_on_them():
|
||||
class WarningValidator:
|
||||
def validate(self, ontology):
|
||||
return SimpleNamespace(valid=True, errors=[], warnings=["review me"])
|
||||
|
||||
report = OntologyQualityGate(
|
||||
validator=WarningValidator(), fail_on_warnings=True
|
||||
).check(_ontology())
|
||||
|
||||
assert report.warning_count == 1
|
||||
assert "VALIDATOR_WARNING" in {issue.code for issue in report.issues}
|
||||
assert "fail_on_warnings" in report.threshold_failures
|
||||
|
||||
|
||||
def test_quality_gate_isolates_competency_questions_between_checks():
|
||||
engine = OntologyEngine()
|
||||
|
||||
first = engine.quality_check(_ontology(), competency_questions=["Who is a Person?"])
|
||||
second = engine.quality_check(
|
||||
_ontology(), competency_questions=["What is a location?"]
|
||||
)
|
||||
|
||||
assert first.metrics["competency_question_coverage"] == 1.0
|
||||
assert second.metrics["competency_question_coverage"] == 0.0
|
||||
|
||||
|
||||
def test_quality_gate_treats_null_property_type_as_missing():
|
||||
ontology = {
|
||||
"classes": [{"name": "Person"}],
|
||||
"properties": [
|
||||
{
|
||||
"name": "value",
|
||||
"type": None,
|
||||
"domain": "Person",
|
||||
"range": "string",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
report = OntologyQualityGate().check(ontology)
|
||||
|
||||
assert "MISSING_PROPERTY_TYPE" in {issue.code for issue in report.issues}
|
||||
assert report.error_count >= 1
|
||||
|
||||
|
||||
def test_quality_gate_normalizes_orphan_ids_for_deterministic_reports():
|
||||
graph = {
|
||||
"entities": [
|
||||
{"id": "p2", "name": "Bob", "type": "Person"},
|
||||
{"id": "p1", "name": "Alice", "type": "Person"},
|
||||
],
|
||||
"relationships": [],
|
||||
}
|
||||
|
||||
report = OntologyQualityGate().check(_ontology(), graph_data=graph)
|
||||
orphan = next(issue for issue in report.issues if issue.code == "ORPHAN_ENTITY")
|
||||
|
||||
assert orphan.details["ids"] == ["p1", "p2"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"thresholds",
|
||||
[
|
||||
{"min_coverage": float("nan")},
|
||||
{"max_errors": float("inf")},
|
||||
{"max_warnings": float("-inf")},
|
||||
],
|
||||
)
|
||||
def test_quality_gate_rejects_non_finite_thresholds(thresholds):
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
OntologyQualityGate().check(_ontology(), thresholds=thresholds)
|
||||
Reference in New Issue
Block a user