mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-04 04:01:07 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd53b14e5d | ||
|
|
2a70f5fcde |
@@ -1461,22 +1461,6 @@ 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
|
||||
@@ -1485,7 +1469,7 @@ updates the supplied runtime object; it does not add disk persistence.
|
||||
|
||||
- **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 Explorer can validate and apply Markdown edits to individual graph nodes and AgentMemory items supplied by the hosting application
|
||||
- **`ContextGraph` gains deterministic, human-editable Markdown round-trip persistence** alongside the existing JSON API, and the Explorer graph inspector gains a read-only Markdown content viewer
|
||||
- **`reasoning` gains a structured Action layer**: rule-driven `Assert`/`Retract`/`Call`/`EmitEvent` actions with optional provenance, turning the reasoner into a production-rule system
|
||||
- **`run_shacl_validation` is now a public, documented API**, and a dozen ontology/RDF export correctness fixes land: OWL property/class export, SHACL target-namespace resolution, one canonical confidence datatype across all four RDF formats, reachable OWL-Time reification, JSON-LD default-graph and content-derived document identity, and full metadata passthrough on every RDF serializer
|
||||
- **Security**: Agno's `AgnoKnowledgeGraph.load_urls()` and OpenClaw's MCP tool now route outbound requests through the shared SSRF guard
|
||||
|
||||
@@ -195,7 +195,7 @@ apt29_intel = context.retrieve(
|
||||
```python
|
||||
from semantica.llms import LiteLLM
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
result = context.query_with_reasoning(
|
||||
"What are APT29's known TTPs against healthcare infrastructure, "
|
||||
@@ -281,7 +281,7 @@ context.store(
|
||||
link_entities=True,
|
||||
)
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
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.",
|
||||
@@ -351,7 +351,7 @@ Parent: wmiprvse.exe
|
||||
Sigma match: T1053.005 Scheduled Task/Job
|
||||
"""
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
triage = soc_context.query_with_reasoning(
|
||||
"Triage this SIEM alert and identify the correct response runbook:\n{}".format(alert_text),
|
||||
llm_provider=llm,
|
||||
@@ -425,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-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
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),
|
||||
@@ -495,7 +495,7 @@ compliance_context.store(
|
||||
extract_relationships=True,
|
||||
)
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
answer = compliance_context.query_with_reasoning(
|
||||
"Under Basel III CRE20, what are the RWA calculation requirements for "
|
||||
"commercial real estate exposures with LTV > 80%? "
|
||||
|
||||
@@ -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-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.
|
||||
`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.
|
||||
|
||||
```python
|
||||
from semantica.llms import LiteLLM
|
||||
|
||||
# Anthropic Claude — highest accuracy for complex reasoning
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
# 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-sonnet-4-5-20250929-v1:0")
|
||||
llm = LiteLLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2: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-5",
|
||||
"prod": "anthropic/claude-sonnet-4-20250514",
|
||||
"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-5")
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
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-5")
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
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-5")
|
||||
report_llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
# 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-5")
|
||||
claude = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
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-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
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-5")
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
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-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
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-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
# --- 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-5")
|
||||
llm_gen = LLMOntologyGenerator(provider="anthropic", model="claude-sonnet-4-20250514")
|
||||
ontology = llm_gen.generate_ontology_from_text(
|
||||
"\n\n".join(r.text[:8000] for r in regs) # token-safe excerpt per document
|
||||
)
|
||||
|
||||
@@ -100,15 +100,14 @@ ner = NamedEntityRecognizer(
|
||||
methods=["llm", "ml", "pattern"],
|
||||
confidence_threshold=0.75,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
)
|
||||
entities = ner.extract_entities(report)
|
||||
|
||||
for e in entities:
|
||||
print("[{:>5.2f}] {:15s} {}".format(e.confidence, e.label, e.text))
|
||||
|
||||
# Illustrative output — exact labels and scores depend on the method and model.
|
||||
# Abbreviated:
|
||||
# Expected output (abbreviated):
|
||||
# [ 0.94] THREAT_ACTOR GAMMA-7
|
||||
# [ 0.91] THREAT_ACTOR DELTA-3
|
||||
# [ 0.97] MALWARE HAMMERTOSS
|
||||
@@ -263,18 +262,16 @@ from semantica.semantic_extract import TripletExtractor
|
||||
tri = TripletExtractor(
|
||||
method="llm",
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
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 what it produces
|
||||
# uses them to constrain and validate 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)))
|
||||
|
||||
@@ -323,7 +320,7 @@ def ingest_intel_report(
|
||||
methods=[method, "pattern"],
|
||||
confidence_threshold=0.70,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
)
|
||||
entities = ner.extract_entities(text)
|
||||
classified = ner.classify_entities(entities)
|
||||
@@ -338,7 +335,7 @@ def ingest_intel_report(
|
||||
relation_types=["deployed", "targets", "exploits", "operates_from", "provided_to"],
|
||||
confidence_threshold=0.65,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
)
|
||||
relations = rel.extract_relations(text, entities)
|
||||
|
||||
@@ -350,10 +347,9 @@ def ingest_intel_report(
|
||||
tri = TripletExtractor(
|
||||
method=method,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
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)
|
||||
@@ -381,7 +377,6 @@ 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),
|
||||
@@ -407,7 +402,7 @@ for text, doc_id in reports:
|
||||
summary["relations"],
|
||||
summary["events"],
|
||||
summary["triplets_valid"],
|
||||
summary["triplets_total"],
|
||||
len(summary["rdf_turtle"]),
|
||||
))
|
||||
```
|
||||
|
||||
@@ -426,7 +421,7 @@ ner = NamedEntityRecognizer(
|
||||
methods=["llm", "pattern"],
|
||||
confidence_threshold=0.75,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
)
|
||||
entities = ner.extract_entities(fintel_text)
|
||||
grouped = ner.classify_entities(entities)
|
||||
@@ -443,14 +438,14 @@ rel = RelationExtractor(
|
||||
relation_types=["operates_from", "deployed", "targets", "exploits"],
|
||||
confidence_threshold=0.70,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
)
|
||||
relations = rel.extract_relations(fintel_text, entities)
|
||||
|
||||
tri = TripletExtractor(
|
||||
method="llm",
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
include_temporal=True,
|
||||
include_provenance=True,
|
||||
)
|
||||
@@ -549,14 +544,14 @@ rel = RelationExtractor(
|
||||
relation_types=["treats", "causes_adverse_event", "has_efficacy", "evaluated_in"],
|
||||
confidence_threshold=0.65,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
)
|
||||
relations = rel.extract_relations(paper, entities)
|
||||
|
||||
tri = TripletExtractor(
|
||||
method="llm",
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
triplet_types=["treats", "has_efficacy", "causes_adverse_event"],
|
||||
include_temporal=True,
|
||||
include_provenance=True,
|
||||
@@ -600,7 +595,7 @@ ner = NamedEntityRecognizer(
|
||||
methods=["llm", "ml", "pattern"],
|
||||
confidence_threshold=0.70,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
)
|
||||
entities = ner.extract_entities(credit_memo)
|
||||
grouped = ner.classify_entities(entities)
|
||||
@@ -617,14 +612,14 @@ rel = RelationExtractor(
|
||||
relation_types=["guaranteed_by", "secured_by", "classified_as", "exposed_to"],
|
||||
confidence_threshold=0.65,
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
)
|
||||
relations = rel.extract_relations(credit_memo, entities)
|
||||
|
||||
tri = TripletExtractor(
|
||||
method="llm",
|
||||
provider="anthropic",
|
||||
llm_model="claude-sonnet-5",
|
||||
llm_model="claude-sonnet-4-6",
|
||||
include_temporal=True,
|
||||
include_provenance=True,
|
||||
)
|
||||
|
||||
@@ -129,7 +129,7 @@ from semantica.llms import Groq, OpenAI, LiteLLM, HuggingFaceLLM
|
||||
from semantica.llms import LiteLLM
|
||||
|
||||
llm = LiteLLM(
|
||||
model="anthropic/claude-sonnet-5",
|
||||
model="anthropic/claude-sonnet-4-20250514",
|
||||
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-5", api_key=os.getenv("ANTHROPIC_API_KEY"))
|
||||
"smart": LiteLLM(model="anthropic/claude-sonnet-4-20250514", 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-7", api_key=os.getenv("ANTHROPIC_API_KEY"))
|
||||
llm = LiteLLM(model="anthropic/claude-opus-4-5", 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-sonnet-4-5-20250929-v1:0")
|
||||
llm = LiteLLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2: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-7", api_key=os.getenv("ANTHROPIC_API_KEY")),
|
||||
"Anthropic": LiteLLM(model="anthropic/claude-opus-4-5", 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-sonnet-4-5-20250929-v1:0"),
|
||||
"Bedrock": LiteLLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2: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-5")` | Highest reasoning capability |
|
||||
| **Complex Analysis** | `LiteLLM("anthropic/claude-sonnet-4-20250514")` | Highest reasoning capability |
|
||||
| **High Volume/Cost** | `LiteLLM("deepseek/deepseek-chat")` | Lowest cost per token |
|
||||
|
||||
### Error Handling
|
||||
|
||||
@@ -22,7 +22,6 @@ 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 |
|
||||
@@ -82,38 +81,9 @@ 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:
|
||||
|
||||
Generated
-653
@@ -33,9 +33,7 @@
|
||||
"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",
|
||||
@@ -45,34 +43,12 @@
|
||||
"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",
|
||||
@@ -364,121 +340,6 @@
|
||||
"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",
|
||||
@@ -1612,63 +1473,6 @@
|
||||
"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",
|
||||
@@ -1810,18 +1614,6 @@
|
||||
"@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",
|
||||
@@ -1873,13 +1665,6 @@
|
||||
"@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",
|
||||
@@ -2225,16 +2010,6 @@
|
||||
"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",
|
||||
@@ -2252,42 +2027,6 @@
|
||||
"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",
|
||||
@@ -2520,20 +2259,6 @@
|
||||
"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",
|
||||
@@ -2645,20 +2370,6 @@
|
||||
"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",
|
||||
@@ -2676,13 +2387,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -2745,14 +2449,6 @@
|
||||
"@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",
|
||||
@@ -2770,19 +2466,6 @@
|
||||
"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",
|
||||
@@ -3375,19 +3058,6 @@
|
||||
"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",
|
||||
@@ -3398,47 +3068,6 @@
|
||||
"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",
|
||||
@@ -3544,13 +3173,6 @@
|
||||
"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",
|
||||
@@ -3564,46 +3186,6 @@
|
||||
"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",
|
||||
@@ -3739,17 +3321,6 @@
|
||||
"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",
|
||||
@@ -4712,13 +4283,6 @@
|
||||
"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",
|
||||
@@ -4818,19 +4382,6 @@
|
||||
"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",
|
||||
@@ -4954,30 +4505,6 @@
|
||||
"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",
|
||||
@@ -5290,33 +4817,6 @@
|
||||
"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",
|
||||
@@ -5424,13 +4924,6 @@
|
||||
"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",
|
||||
@@ -5448,52 +4941,6 @@
|
||||
"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",
|
||||
@@ -5917,67 +5364,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -6004,45 +5390,6 @@
|
||||
"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,6 @@
|
||||
"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/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"
|
||||
@@ -40,9 +39,7 @@
|
||||
"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",
|
||||
@@ -52,7 +49,6 @@
|
||||
"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",
|
||||
|
||||
+14
-45
@@ -17,13 +17,10 @@ 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 })));
|
||||
@@ -36,6 +33,7 @@ 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';
|
||||
@@ -1793,37 +1791,6 @@ export default function App() {
|
||||
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 = () => {
|
||||
@@ -1856,15 +1823,18 @@ export default function App() {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Explore"
|
||||
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'}
|
||||
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
|
||||
kicker={exploreView === 'graph' ? 'Graph Studio' : 'Vocabulary Browser'}
|
||||
compact
|
||||
tabs={
|
||||
<ExploreWorkspaceTabs
|
||||
activeView={exploreView}
|
||||
agentMemoryAvailable={agentMemoryAvailable}
|
||||
onSelect={switchExploreView}
|
||||
/>
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ErrorBoundary key={`explore-${exploreView}`}>
|
||||
@@ -1873,9 +1843,8 @@ export default function App() {
|
||||
<GraphWorkspace
|
||||
externalFocusNodeId={graphFocusRequest?.nodeId}
|
||||
externalFocusToken={graphFocusRequest?.token}
|
||||
onDirtyChange={setExploreDraftDirty}
|
||||
/>
|
||||
) : exploreView === 'memories' ? <MemoryWorkspace onDirtyChange={setExploreDraftDirty} /> : <VocabularyWorkspace />}
|
||||
) : <VocabularyWorkspace />}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</WorkspaceShell>
|
||||
@@ -2020,13 +1989,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={() => switchWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
|
||||
<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>
|
||||
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
className="nav-button"
|
||||
data-active={activeWorkspace === id}
|
||||
onClick={() => switchWorkspace(id)}
|
||||
onClick={() => setActiveWorkspace(id)}
|
||||
title={hint}
|
||||
>
|
||||
<Icon size={20} />
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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,7 +15,6 @@ export type RegistryEntryOp =
|
||||
| "export"
|
||||
| "merge"
|
||||
| "add-node"
|
||||
| "update-node"
|
||||
| "add-edge"
|
||||
| "delete"
|
||||
| "infer"
|
||||
|
||||
@@ -16,7 +16,6 @@ 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)" },
|
||||
@@ -24,7 +23,7 @@ const OP_META: Record<
|
||||
};
|
||||
|
||||
const ALL_OPS: (RegistryEntryOp | "all")[] = [
|
||||
"all", "import", "export", "merge", "add-node", "update-node", "add-edge", "infer", "delete", "vocab-import",
|
||||
"all", "import", "export", "merge", "add-node", "add-edge", "infer", "delete", "vocab-import",
|
||||
];
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -45,8 +44,6 @@ 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;
|
||||
@@ -307,8 +304,6 @@ export function GraphInspectorPanel({
|
||||
pathResult,
|
||||
onDownloadProvenance,
|
||||
onFocusNode,
|
||||
onMarkdownApplied,
|
||||
onMarkdownDirtyChange,
|
||||
}: GraphInspectorPanelProps) {
|
||||
if (!nodeId) {
|
||||
return (
|
||||
@@ -419,18 +414,19 @@ export function GraphInspectorPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<section style={sectionStyle}>
|
||||
|
||||
@@ -44,12 +44,6 @@ 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,
|
||||
@@ -1247,10 +1241,9 @@ function collectPluginOverlays(
|
||||
interface GraphWorkspaceProps {
|
||||
externalFocusNodeId?: string;
|
||||
externalFocusToken?: number;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirtyChange }: GraphWorkspaceProps = {}) {
|
||||
export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: GraphWorkspaceProps = {}) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||
const [focusedNodeId, setFocusedNodeId] = useState("");
|
||||
const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState("");
|
||||
@@ -1258,12 +1251,6 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
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[]>([]);
|
||||
@@ -1641,17 +1628,8 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
: 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) {
|
||||
@@ -1704,7 +1682,6 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
));
|
||||
setViewMode("full");
|
||||
}, [
|
||||
confirmDiscardMarkdownDraft,
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
focusedNodeId,
|
||||
@@ -1715,11 +1692,9 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
lastGroupedSelectedNodeId,
|
||||
resolveNodeIdForFocusedMode,
|
||||
selectedNodeId,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const focusNode = useCallback((nodeId: string) => {
|
||||
if (nodeId !== selectedNodeId && !confirmDiscardMarkdownDraft()) return;
|
||||
if (!nodeId) {
|
||||
setSelectedNodeId("");
|
||||
setSelectedEdgeId("");
|
||||
@@ -1745,18 +1720,14 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
setFocusedNodeId(nextSelectedNodeId);
|
||||
setIsLayoutRunning(false);
|
||||
}
|
||||
}, [confirmDiscardMarkdownDraft, selectedNodeId, viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes
|
||||
}, [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;
|
||||
|
||||
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.
|
||||
@@ -1766,13 +1737,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
window.setTimeout(() => {
|
||||
sceneRef.current?.focusNode(externalFocusNodeId);
|
||||
}, 0);
|
||||
}, [
|
||||
confirmDiscardMarkdownDraft,
|
||||
externalFocusNodeId,
|
||||
externalFocusToken,
|
||||
graphReady,
|
||||
selectedNodeId,
|
||||
]);
|
||||
}, [externalFocusNodeId, externalFocusToken, graphReady]);
|
||||
|
||||
const handleEdgeSelect = useCallback((edgeId: string) => {
|
||||
setSelectedEdgeId(edgeId);
|
||||
@@ -1878,37 +1843,6 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
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`);
|
||||
@@ -1939,25 +1873,6 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
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([
|
||||
@@ -1984,7 +1899,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, [markdownRefreshGuard]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedNeighborhoodNodeIds([]);
|
||||
@@ -2257,29 +2172,28 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
}, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]);
|
||||
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
|
||||
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
|
||||
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 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 displayState = useMemo(
|
||||
() => (
|
||||
viewMode === "grouped"
|
||||
@@ -3381,8 +3295,6 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
pathResult={pathResult}
|
||||
onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
|
||||
onFocusNode={focusNode}
|
||||
onMarkdownApplied={handleMarkdownApplied}
|
||||
onMarkdownDirtyChange={handleMarkdownDirtyChange}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -1,243 +1,125 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { useState, useRef, useEffect, useMemo, type CSSProperties } from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
Check,
|
||||
Code2,
|
||||
Copy,
|
||||
Eye,
|
||||
ExternalLink,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } 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) setCopied(false);
|
||||
if (copied) {
|
||||
// Clear the stale indicator synchronously so the new node's copy button
|
||||
// never shows "Copied" from the previous selection.
|
||||
setCopied(false);
|
||||
}
|
||||
}
|
||||
|
||||
const copyTimeoutRef = useRef<number | undefined>(undefined);
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Clean up any outstanding timeout on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
if (copyTimeoutRef.current) {
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 rawContent = typeof content === "string" ? content : "";
|
||||
const hasContent = rawContent.trim().length > 0;
|
||||
|
||||
// react-markdown runs the whole remark pipeline synchronously inside its own
|
||||
// render, so without this memo every unrelated re-render of this component --
|
||||
// clicking Copy, toggling Preview/Source -- re-parses the entire document.
|
||||
// Measured at ~364ms per re-render for a 1000-row GFM table (issue #1118).
|
||||
// Keyed on rawContent so a genuine node change still re-parses exactly once.
|
||||
const renderedMarkdown = useMemo(
|
||||
() => (
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{previewContent}
|
||||
{rawContent}
|
||||
</ReactMarkdown>
|
||||
),
|
||||
[previewContent],
|
||||
[rawContent],
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!hasContent) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawContent);
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
if (copyTimeoutRef.current) {
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
setCopied(true);
|
||||
copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500);
|
||||
copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// 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");
|
||||
// Clipboard write unavailable
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} style={viewerContainerStyle}>
|
||||
<div style={viewerHeaderStyle}>
|
||||
<div style={{ display: "flex", gap: 4 }} role="tablist" aria-label="Markdown view">
|
||||
<div style={{ display: "flex", gap: 4 }} role="tablist">
|
||||
<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 }} aria-hidden="true" />
|
||||
<Eye size={12} style={{ marginRight: 5 }} />
|
||||
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 }} aria-hidden="true" />
|
||||
<Code2 size={12} style={{ marginRight: 5 }} />
|
||||
Source
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{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={viewerBodyStyle}>
|
||||
{!hasContent ? (
|
||||
<div style={emptyTextStyle}>No content available for this node.</div>
|
||||
) : activeMode === "source" ? (
|
||||
<pre style={sourcePreStyle}>
|
||||
@@ -356,8 +238,6 @@ 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}`,
|
||||
@@ -395,56 +275,12 @@ 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,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -42,9 +42,6 @@ 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") {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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 typeof globalThis & { React: typeof React }).React = React;
|
||||
(globalThis as any).React = React;
|
||||
|
||||
import { MarkdownContentViewer } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
|
||||
import { isSafeUrl } from "../src/workspaces/GraphWorkspace/markdownUrlSafety.ts";
|
||||
@@ -60,29 +60,6 @@ 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" }));
|
||||
|
||||
@@ -1,639 +0,0 @@
|
||||
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)");
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -65,11 +65,9 @@ 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
|
||||
|
||||
@@ -80,12 +78,6 @@ 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):
|
||||
@@ -166,17 +158,6 @@ 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.
|
||||
@@ -216,7 +197,6 @@ 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")
|
||||
@@ -269,7 +249,6 @@ 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.
|
||||
@@ -319,7 +298,6 @@ class AgentMemory:
|
||||
|
||||
self.logger.info(f"Loaded agent memory from {path}")
|
||||
|
||||
@_with_memory_lock
|
||||
def store(
|
||||
self,
|
||||
content: str,
|
||||
@@ -596,7 +574,6 @@ class AgentMemory:
|
||||
"relationships": memory_item.relationships,
|
||||
}
|
||||
|
||||
@_with_memory_lock
|
||||
def delete_memory(self, memory_id: str, *, skip_vector: bool = False) -> bool:
|
||||
"""
|
||||
Delete memory item.
|
||||
@@ -648,7 +625,6 @@ 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.
|
||||
|
||||
@@ -1160,7 +1136,6 @@ class AgentMemory:
|
||||
"""
|
||||
return self.get_memory(memory_id)
|
||||
|
||||
@_with_memory_lock
|
||||
def update(
|
||||
self,
|
||||
memory_id: str,
|
||||
@@ -1441,13 +1416,6 @@ 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]]:
|
||||
@@ -1663,88 +1631,6 @@ 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,
|
||||
@@ -1793,7 +1679,6 @@ 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,12 +131,6 @@ 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):
|
||||
@@ -1173,157 +1167,6 @@ 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:
|
||||
@@ -5104,42 +4947,32 @@ 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 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.
|
||||
Called after ``add_node_attribute`` mutates a decision node so that
|
||||
``_decisions`` and the derived indexes stay consistent without
|
||||
requiring a full rebuild of all decisions.
|
||||
"""
|
||||
node = self.nodes.get(node_id)
|
||||
if node is None:
|
||||
return
|
||||
if (getattr(node, "node_type", None) or "").lower() != "decision":
|
||||
return
|
||||
|
||||
if not hasattr(self, "_decisions"):
|
||||
# Indexes don't exist yet — a full rebuild is safer.
|
||||
self._rebuild_decision_indexes()
|
||||
return
|
||||
|
||||
# Remove stale index entries before deciding whether the current node
|
||||
# still belongs in the decision indexes.
|
||||
old = self._decisions.pop(node_id, None)
|
||||
# Remove stale index entries for this decision ID.
|
||||
old = self._decisions.get(node_id)
|
||||
if old:
|
||||
old_cat = old.get("category", "")
|
||||
if old_cat:
|
||||
if old_cat and node_id in self._decision_index.get(old_cat, set()):
|
||||
self._decision_index[old_cat].discard(node_id)
|
||||
for ent in old.get("entities", []):
|
||||
self._entity_index[ent].discard(node_id)
|
||||
self._temporal_index = [
|
||||
(nid, ts) for nid, ts in self._temporal_index if nid != node_id
|
||||
]
|
||||
|
||||
if node is None:
|
||||
return
|
||||
if (getattr(node, "node_type", None) or "").lower() != "decision":
|
||||
return
|
||||
self._temporal_index = [
|
||||
(nid, ts) for nid, ts in self._temporal_index if nid != node_id
|
||||
]
|
||||
|
||||
# Rebuild the entry for this node and re-insert index entries.
|
||||
meta: Dict[str, Any] = {}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""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}"
|
||||
+74
-30
@@ -8,19 +8,16 @@ from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
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, require_auth
|
||||
from .markdown_resources import MarkdownResourceRegistry
|
||||
from .runtime import explorer_capabilities, install_mutation_bridge
|
||||
from .dependencies import anonymous_access_allowed, get_expected_api_key, is_valid_api_key, require_auth
|
||||
from .session import GraphSession
|
||||
from .ws import ConnectionManager, install_graph_updates_websocket
|
||||
from .ws import ConnectionManager
|
||||
|
||||
|
||||
def _read_int_env(name: str, default: int) -> int:
|
||||
@@ -56,23 +53,37 @@ 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:
|
||||
@@ -84,7 +95,6 @@ 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):
|
||||
@@ -107,9 +117,7 @@ def create_app(
|
||||
app.state.event_loop = asyncio.get_running_loop()
|
||||
app.state.ws_manager = ConnectionManager()
|
||||
app.state.session = active_session
|
||||
app.state.agent_memory = agent_memory
|
||||
app.state.markdown_resources = markdown_resources
|
||||
install_mutation_bridge(app, active_session)
|
||||
_install_mutation_bridge(app, active_session)
|
||||
yield
|
||||
|
||||
app = FastAPI(
|
||||
@@ -131,7 +139,7 @@ def create_app(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings["allowed_origins"],
|
||||
allow_credentials=_allow_credentials,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
|
||||
max_age=600,
|
||||
)
|
||||
@@ -162,8 +170,6 @@ 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
|
||||
@@ -177,15 +183,54 @@ 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)
|
||||
|
||||
install_graph_updates_websocket(app, settings["allowed_origins"])
|
||||
_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)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
@@ -224,7 +269,6 @@ 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,8 +14,6 @@ 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)
|
||||
@@ -82,25 +80,3 @@ 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
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
"""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,13 +87,6 @@ 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)
|
||||
|
||||
@@ -128,20 +121,15 @@ 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),
|
||||
):
|
||||
return await _get_node_or_404(node_id, 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)
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/neighbors", response_model=list[NeighborResponse])
|
||||
@@ -453,7 +441,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:
|
||||
@@ -463,7 +451,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:
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,41 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
"""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,39 +428,3 @@ 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,53 +9,9 @@ import asyncio
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Sequence, Set
|
||||
from typing import Any, Dict, Set
|
||||
|
||||
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)
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
@@ -107,7 +63,8 @@ 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,10 +301,7 @@ class GraphValidator:
|
||||
code="ORPHAN_NODES",
|
||||
message=f"Found {len(isolates)} orphan nodes (no relationships).",
|
||||
severity=ValidationSeverity.WARNING,
|
||||
details={
|
||||
"count": len(isolates),
|
||||
"ids": sorted(isolates, key=str)[:10],
|
||||
} # Limit output deterministically
|
||||
details={"count": len(isolates), "ids": isolates[:10]} # Limit output
|
||||
))
|
||||
|
||||
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-5")
|
||||
>>> llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
>>> 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-5", "groq/llama-3.1-8b-instant")
|
||||
Model format: "provider/model-name" (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-20250514", "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-5")
|
||||
>>> llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
>>> 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-5",
|
||||
Examples: "openai/gpt-4o", "anthropic/claude-sonnet-4-20250514",
|
||||
"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,13 +162,6 @@ 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
|
||||
@@ -201,11 +194,6 @@ __all__ = [
|
||||
"SHACLValidationReport",
|
||||
"SHACLViolation",
|
||||
"run_shacl_validation",
|
||||
"OntologyQualityGate",
|
||||
"OntologyQualityReport",
|
||||
"QualityIssue",
|
||||
"QualitySeverity",
|
||||
"ontology_quality_check",
|
||||
# OWL/RDF generation
|
||||
"OWLGenerator",
|
||||
# Requirements and competency questions
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
|
||||
@@ -26,9 +25,6 @@ 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")
|
||||
|
||||
@@ -597,15 +593,6 @@ 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)
|
||||
|
||||
|
||||
@@ -1,855 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
+5
-26
@@ -5,7 +5,6 @@ This module provides the REST API server for the Semantica framework
|
||||
using FastAPI and uvicorn.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uvicorn
|
||||
@@ -25,10 +24,8 @@ from .utils.logging import setup_logging
|
||||
try:
|
||||
from .context.context_graph import ContextGraph
|
||||
from .explorer.session import GraphSession
|
||||
from .explorer.ws import ConnectionManager, install_graph_updates_websocket
|
||||
from .explorer.ws import ConnectionManager
|
||||
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
|
||||
@@ -60,24 +57,16 @@ 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"):
|
||||
@@ -102,14 +91,11 @@ app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_methods=["GET", "POST", "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):
|
||||
@@ -152,10 +138,7 @@ async def root():
|
||||
return {
|
||||
"name": "Semantica API",
|
||||
"version": __version__,
|
||||
"status": "active",
|
||||
"capabilities": explorer_capabilities(
|
||||
getattr(app.state, "agent_memory", None)
|
||||
),
|
||||
"status": "active"
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
@@ -182,8 +165,6 @@ if EXPLORER_AVAILABLE:
|
||||
enrich,
|
||||
export_import,
|
||||
graph,
|
||||
markdown,
|
||||
memories,
|
||||
ontology,
|
||||
temporal,
|
||||
vocabulary,
|
||||
@@ -198,8 +179,6 @@ 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)
|
||||
@@ -281,4 +260,4 @@ def main():
|
||||
uvicorn.run(app, host=host, port=8000)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
@@ -12,10 +12,6 @@ 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
|
||||
|
||||
@@ -1012,144 +1008,3 @@ 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,10 +9,6 @@ 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):
|
||||
@@ -715,135 +711,3 @@ 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,18 +247,6 @@ 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,22 +60,6 @@ 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
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"
|
||||
@@ -1,319 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,115 +0,0 @@
|
||||
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
|
||||
@@ -1,308 +0,0 @@
|
||||
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