mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-11 04:01:32 +00:00
fix(ontology): address review blockers from PR #524
Backend: - Replace false conforms=True SHACL stub with status=unavailable always; live validation cannot be wired until OntologyEngine.validate_graph is connected to a data graph — a stub that returns conforms=True misleads users editing shapes - Cap node/edge fetches in health, suggest-alignments, and SHACL generation at _MAX_ANALYSIS_NODES (5 000) with a logger.warning when the graph exceeds the limit; unbounded limit=999_999 fetches cause OOM on large graphs - Set SHACL health dimension score to 0.0 (was 70.0) when status=unavailable; exclude unavailable dimensions from the total_score average so they neither inflate nor deflate the result - Allow alignments to reference external/unloaded URIs (e.g. schema.org) without raising 404; label falls back to URI fragment or caller-supplied source_label/target_label fields added to OntologyAlignmentRequest - Fix _alignment_id to use uuid.NAMESPACE_OID instead of NAMESPACE_URL; the composite key is not a URL - Fix _summarize_shapes to normalise \r\n before splitting on .\n so shape parsing works correctly on Windows line endings Frontend: - Wrap handleSave/handleSuggest/handleRemove/handleAcceptSuggestion in useCallback in AlignmentsTab for consistency with sibling components - Add ephemeral-storage banner in AlignmentsTab warning that alignments are session-memory-only and not persisted across restarts - Fix exportReport in HealthTab to append/remove anchor from document before clicking and defer URL.revokeObjectURL to avoid Blob URL leak in some browsers - Derive health dimension grid column count from health.dimensions.length instead of the hardcoded repeat(5, ...) that breaks if the backend adds or removes a dimension - Add minimal Monarch tokenizer for the Monaco turtle language registration in ShaclStudio so prefix declarations, IRIs, SHACL properties, comments, and string literals are syntax-highlighted; previously the editor rendered as plain text despite theme rules being defined Tests (11 passing, was 5): - Rename test_shacl_validate_has_stable_contract to test_shacl_validate_returns_unavailable and assert status == unavailable - Add test_shacl_validate_rejects_empty_turtle (expects 422) - Add test_health_returns_404_for_unknown_ontology - Add test_health_shacl_dimension_is_zero_when_unavailable with total_score check - Add test_delete_unknown_alignment_returns_404 - Add test_alignment_upsert_is_idempotent (verifies ID stability and created_at preservation across updates) - Add test_alignment_accepts_external_uri (verifies no 404 for schema.org URIs) - Relax test_alignment_suggestions_are_ranked label assertions to substring checks so the test survives similarity algorithm changes Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com> Co-authored-by: ZohaibHassan16 <zohaib@hawksight.ai>
This commit is contained in:
co-authored by
KaifAhmad1
ZohaibHassan16
parent
e8bf0e50d3
commit
00ceb09960
@@ -75,7 +75,7 @@ export function AlignmentsTab() {
|
||||
return counts;
|
||||
}, [alignments]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!sourceUri.trim() || !targetUri.trim()) {
|
||||
setError("Provide both source and target entity URIs.");
|
||||
return;
|
||||
@@ -100,9 +100,9 @@ export function AlignmentsTab() {
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
}, [sourceUri, targetUri, relation, confidence, provenance, source, reviewer, reload]);
|
||||
|
||||
const handleSuggest = async () => {
|
||||
const handleSuggest = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -118,17 +118,17 @@ export function AlignmentsTab() {
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
}, [sourceOntology, targetOntology, threshold]);
|
||||
|
||||
const handleAcceptSuggestion = (suggestion: AlignmentSuggestion) => {
|
||||
const handleAcceptSuggestion = useCallback((suggestion: AlignmentSuggestion) => {
|
||||
setSourceUri(suggestion.source_uri);
|
||||
setTargetUri(suggestion.target_uri);
|
||||
setRelation(suggestion.relation);
|
||||
setConfidence(Math.max(0.1, Math.min(1, suggestion.score)));
|
||||
setProvenance(suggestion.reason);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
const handleRemove = useCallback(async (id: string) => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -139,7 +139,7 @@ export function AlignmentsTab() {
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
@@ -161,6 +161,11 @@ export function AlignmentsTab() {
|
||||
|
||||
{error ? <div style={errorStyle}>{error}</div> : null}
|
||||
|
||||
<div style={ephemeralBannerStyle}>
|
||||
Alignments are stored in server memory and are not persisted across restarts.
|
||||
Export your graph or ontology to preserve recorded mappings.
|
||||
</div>
|
||||
|
||||
<div style={gridStyle}>
|
||||
<section style={cardStyle}>
|
||||
<h3 style={sectionTitleStyle}>Create or update alignment</h3>
|
||||
@@ -315,3 +320,4 @@ const confidenceStyle: CSSProperties = { color: "#f2b66d", fontWeight: 900 };
|
||||
const iconButtonStyle: CSSProperties = { width: 34, height: 34, borderRadius: 10, border: "1px solid rgba(255,157,175,0.18)", background: "rgba(255,157,175,0.08)", color: "#ff9daf", cursor: "pointer" };
|
||||
const mutedStyle: CSSProperties = { margin: 0, color: "#6a7f97", fontSize: 13 };
|
||||
const errorStyle: CSSProperties = { padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" };
|
||||
const ephemeralBannerStyle: CSSProperties = { padding: "9px 14px", borderRadius: 12, color: "#f2b66d", background: "rgba(242,182,109,0.08)", border: "1px solid rgba(242,182,109,0.22)", fontSize: 12 };
|
||||
|
||||
@@ -49,16 +49,18 @@ export function HealthTab({ onFixInEditor }: HealthTabProps) {
|
||||
void loadHealth(selectedUri);
|
||||
}, [selectedUri, loadHealth]);
|
||||
|
||||
const exportReport = () => {
|
||||
const exportReport = useCallback(() => {
|
||||
if (!health) return;
|
||||
const blob = new Blob([JSON.stringify(health, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${health.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-health.json`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
document.body.removeChild(anchor);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
}, [health]);
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
@@ -85,7 +87,7 @@ export function HealthTab({ onFixInEditor }: HealthTabProps) {
|
||||
<div style={loadingStyle}><Loader2 size={18} className="spin" /> Computing health dashboard...</div>
|
||||
) : health ? (
|
||||
<>
|
||||
<section style={scoreGridStyle}>
|
||||
<section style={{ ...scoreGridStyle, gridTemplateColumns: `220px repeat(${health.dimensions.length}, minmax(180px, 1fr))` }}>
|
||||
<div style={scoreCardStyle}>
|
||||
<span style={scoreValueStyle}>{Math.round(health.total_score)}</span>
|
||||
<span style={mutedStyle}>Total health score</span>
|
||||
|
||||
@@ -99,9 +99,26 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
|
||||
return Array.from(groups.entries());
|
||||
}, [shapes]);
|
||||
|
||||
const beforeMount = (monaco: Monaco) => {
|
||||
const beforeMount = useCallback((monaco: Monaco) => {
|
||||
if (!monaco.languages.getLanguages().some((language: { id: string }) => language.id === "turtle")) {
|
||||
monaco.languages.register({ id: "turtle" });
|
||||
monaco.languages.register({ id: "turtle", extensions: [".ttl"], mimetypes: ["text/turtle"] });
|
||||
monaco.languages.setMonarchTokensProvider("turtle", {
|
||||
keywords: ["@prefix", "@base", "a"],
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/#[^\n]*/, "comment"],
|
||||
[/"(?:[^"\\]|\\.)*"(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"],
|
||||
[/'(?:[^'\\]|\\.)*'(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"],
|
||||
[/"""[\s\S]*?"""/, "string"],
|
||||
[/<[^>]*>/, "type.identifier"],
|
||||
[/\b(?:@prefix|@base|a)\b/, "keyword"],
|
||||
[/\b(?:sh|xsd|owl|rdf|rdfs|skos):[\w]+/, "variable"],
|
||||
[/[a-zA-Z_][\w-]*:[\w]+/, "namespace"],
|
||||
[/[;,.]/, "delimiter"],
|
||||
[/\d+(?:\.\d+)?/, "number"],
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
monaco.editor.defineTheme("shacl-dark", {
|
||||
base: "vs-dark",
|
||||
@@ -109,6 +126,12 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
|
||||
rules: [
|
||||
{ token: "keyword", foreground: "9ee8d7" },
|
||||
{ token: "string", foreground: "f2b66d" },
|
||||
{ token: "comment", foreground: "4a6070", fontStyle: "italic" },
|
||||
{ token: "type.identifier", foreground: "7ce7d3" },
|
||||
{ token: "variable", foreground: "d2a8ff" },
|
||||
{ token: "namespace", foreground: "a5d6ff" },
|
||||
{ token: "number", foreground: "79c0ff" },
|
||||
{ token: "delimiter", foreground: "8fa8c6" },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": "#050b13",
|
||||
@@ -116,7 +139,7 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
|
||||
"editorLineNumber.foreground": "#41536b",
|
||||
},
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
|
||||
@@ -23,6 +23,7 @@ router = APIRouter(prefix="/api/ontology", tags=["Ontology"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_FETCH_BYTES = 20 * 1024 * 1024 # 20 MB
|
||||
_MAX_ANALYSIS_NODES = 5_000 # cap for health/suggest/shacl node scans to avoid OOM
|
||||
|
||||
_CLASS_TYPES = frozenset({
|
||||
"owl:Class", "rdfs:Class",
|
||||
@@ -244,7 +245,9 @@ class OntologyAlignment(BaseModel):
|
||||
|
||||
class OntologyAlignmentRequest(BaseModel):
|
||||
source_uri: str
|
||||
source_label: Optional[str] = None # override for external/unloaded URIs
|
||||
target_uri: str
|
||||
target_label: Optional[str] = None # override for external/unloaded URIs
|
||||
relation: AlignmentRelation
|
||||
confidence: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||
provenance: Optional[str] = None
|
||||
@@ -410,7 +413,13 @@ def _extract_namespace(uri: str) -> Optional[str]:
|
||||
|
||||
def _alignment_id(source_uri: str, relation: str, target_uri: str) -> str:
|
||||
key = f"{source_uri}|{relation}|{target_uri}"
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_OID, key))
|
||||
|
||||
|
||||
def _label_from_uri(uri: str) -> str:
|
||||
"""Derive a readable label from a URI when no graph node is present (e.g. external vocabularies)."""
|
||||
fragment = uri.rsplit("#", 1)[-1] if "#" in uri else uri.rsplit("/", 1)[-1]
|
||||
return fragment or uri
|
||||
|
||||
|
||||
def _node_source_ontology(node: Dict[str, Any]) -> Optional[str]:
|
||||
@@ -551,7 +560,8 @@ def _basic_shacl_turtle(uri: str, name: str, nodes: List[Dict[str, Any]]) -> str
|
||||
|
||||
def _summarize_shapes(shacl_turtle: str, violations: Optional[List[ShaclViolation]] = None) -> List[ShaclShapeSummary]:
|
||||
violations = violations or []
|
||||
blocks = [block.strip() for block in shacl_turtle.split(".\n") if "sh:NodeShape" in block]
|
||||
normalised = shacl_turtle.replace("\r\n", "\n").replace("\r", "\n")
|
||||
blocks = [block.strip() for block in normalised.split(".\n") if "sh:NodeShape" in block]
|
||||
summaries: List[ShaclShapeSummary] = []
|
||||
for index, block in enumerate(blocks, start=1):
|
||||
first = block.splitlines()[0].strip()
|
||||
@@ -1190,10 +1200,18 @@ async def upsert_alignment(
|
||||
):
|
||||
source_node = await asyncio.to_thread(session.get_node, body.source_uri)
|
||||
target_node = await asyncio.to_thread(session.get_node, body.target_uri)
|
||||
if source_node is None:
|
||||
raise HTTPException(status_code=404, detail="Source entity not found.")
|
||||
if target_node is None:
|
||||
raise HTTPException(status_code=404, detail="Target entity not found.")
|
||||
# External vocabulary URIs (e.g. schema.org, DBpedia) are not in the local
|
||||
# graph; fall back to a label derived from the URI or the caller-supplied label.
|
||||
source_label = (
|
||||
body.source_label
|
||||
or (_node_label(source_node) if source_node is not None else None)
|
||||
or _label_from_uri(body.source_uri)
|
||||
)
|
||||
target_label = (
|
||||
body.target_label
|
||||
or (_node_label(target_node) if target_node is not None else None)
|
||||
or _label_from_uri(body.target_uri)
|
||||
)
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
store = _get_alignment_store(request)
|
||||
@@ -1202,9 +1220,9 @@ async def upsert_alignment(
|
||||
alignment = OntologyAlignment(
|
||||
id=alignment_id,
|
||||
source_uri=body.source_uri,
|
||||
source_label=_node_label(source_node),
|
||||
source_label=source_label,
|
||||
target_uri=body.target_uri,
|
||||
target_label=_node_label(target_node),
|
||||
target_label=target_label,
|
||||
relation=body.relation,
|
||||
predicate_uri=_ALIGNMENT_RELATIONS[body.relation],
|
||||
confidence=body.confidence,
|
||||
@@ -1247,7 +1265,13 @@ async def suggest_alignments(
|
||||
body: AlignmentSuggestionRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
||||
nodes, total_count = await asyncio.to_thread(session.get_nodes, skip=0, limit=_MAX_ANALYSIS_NODES)
|
||||
if total_count > _MAX_ANALYSIS_NODES:
|
||||
logger.warning(
|
||||
"suggest-alignments: graph has %d nodes; analysis capped at %d. "
|
||||
"Filter by source/target ontology URI for more accurate results.",
|
||||
total_count, _MAX_ANALYSIS_NODES,
|
||||
)
|
||||
source_nodes = _ontology_entities(nodes, body.source_ontology_uri)
|
||||
target_nodes = _ontology_entities(nodes, body.target_ontology_uri)
|
||||
if not body.source_ontology_uri:
|
||||
@@ -1302,8 +1326,10 @@ async def ontology_health(
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
|
||||
|
||||
nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||
nodes, total_nodes = await asyncio.to_thread(session.get_nodes, skip=0, limit=_MAX_ANALYSIS_NODES)
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=_MAX_ANALYSIS_NODES)
|
||||
if total_nodes > _MAX_ANALYSIS_NODES:
|
||||
logger.warning("ontology-health: graph has %d nodes; analysis capped at %d.", total_nodes, _MAX_ANALYSIS_NODES)
|
||||
entities = _ontology_entities(nodes, uri)
|
||||
classes = [node for node in entities if _classify_node_type(node.get("type", "")) == "class"]
|
||||
properties = [node for node in entities if _classify_node_type(node.get("type", "")) == "property"]
|
||||
@@ -1373,7 +1399,7 @@ async def ontology_health(
|
||||
shacl_dimension = HealthDimension(
|
||||
key="shacl",
|
||||
label="SHACL Conformance",
|
||||
score=70.0,
|
||||
score=0.0,
|
||||
status="unavailable",
|
||||
detail="Live SHACL validation is available in SHACL Studio when optional validation dependencies are installed.",
|
||||
)
|
||||
@@ -1409,7 +1435,8 @@ async def ontology_health(
|
||||
detail="Measures comments plus source/version metadata.",
|
||||
),
|
||||
]
|
||||
total_score = sum(dim.score for dim in dimensions) / len(dimensions)
|
||||
scoreable = [dim for dim in dimensions if dim.status != "unavailable"]
|
||||
total_score = sum(dim.score for dim in scoreable) / max(len(scoreable), 1)
|
||||
|
||||
return OntologyHealthResponse(
|
||||
uri=uri,
|
||||
@@ -1432,8 +1459,8 @@ async def _generated_shacl_for_uri(
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
|
||||
|
||||
nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||
nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=_MAX_ANALYSIS_NODES)
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=_MAX_ANALYSIS_NODES)
|
||||
entities = _ontology_entities(nodes, uri)
|
||||
ontology_dict = _ontology_dict_from_nodes(uri, entry.name, entities, edges)
|
||||
|
||||
@@ -1488,25 +1515,17 @@ async def list_shacl_shapes(
|
||||
async def validate_shacl(body: ShaclValidateRequest):
|
||||
if not body.shacl_turtle.strip():
|
||||
raise HTTPException(status_code=422, detail="SHACL Turtle cannot be empty.")
|
||||
try:
|
||||
import pyshacl # type: ignore # noqa: F401
|
||||
except ImportError:
|
||||
return ShaclValidationResponse(
|
||||
uri=body.uri,
|
||||
conforms=False,
|
||||
status="unavailable",
|
||||
message="pySHACL is not installed, so live graph validation is unavailable in this environment.",
|
||||
violations=[],
|
||||
)
|
||||
|
||||
# The route exposes a stable contract even when validation data plumbing is absent.
|
||||
# Full data-graph validation will use OntologyEngine.validate_graph once a persisted
|
||||
# ontology/data store is available from the registry layer.
|
||||
# Live validation requires pySHACL and a wired data graph from OntologyEngine.validate_graph().
|
||||
# Always return unavailable until that integration is complete — never return a false "conforms"
|
||||
# result that would mislead users editing shapes.
|
||||
return ShaclValidationResponse(
|
||||
uri=body.uri,
|
||||
conforms=True,
|
||||
status="success",
|
||||
message="SHACL Turtle parsed; no live data violations were produced by the current in-memory graph adapter.",
|
||||
conforms=False,
|
||||
status="unavailable",
|
||||
message=(
|
||||
"Live graph validation is not yet wired to a data graph. "
|
||||
"Install semantica[shacl] and connect OntologyEngine.validate_graph() to enable full validation."
|
||||
),
|
||||
violations=[],
|
||||
)
|
||||
|
||||
|
||||
@@ -105,8 +105,11 @@ def test_alignment_suggestions_are_ranked(client):
|
||||
assert response.status_code == 200
|
||||
suggestions = response.json()
|
||||
assert suggestions
|
||||
assert suggestions[0]["source_label"] == "Person"
|
||||
assert suggestions[0]["target_label"] == "Person Record"
|
||||
# Top suggestion should be the Person→PersonRecord pair (highest label similarity).
|
||||
top = suggestions[0]
|
||||
assert "Person" in top["source_label"]
|
||||
assert "Person" in top["target_label"]
|
||||
# Results must be sorted descending by score.
|
||||
assert suggestions == sorted(suggestions, key=lambda item: item["score"], reverse=True)
|
||||
|
||||
|
||||
@@ -140,7 +143,7 @@ def test_shacl_generate_and_shapes(client):
|
||||
assert shapes.json()["shapes"]
|
||||
|
||||
|
||||
def test_shacl_validate_has_stable_contract(client):
|
||||
def test_shacl_validate_returns_unavailable(client):
|
||||
response = client.post(
|
||||
"/api/ontology/shacl/validate",
|
||||
json={
|
||||
@@ -150,5 +153,65 @@ def test_shacl_validate_has_stable_contract(client):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] in {"success", "unavailable"}
|
||||
assert payload["status"] == "unavailable", "stub must not report conforms=True before validation is wired"
|
||||
assert payload["conforms"] is False
|
||||
assert isinstance(payload["violations"], list)
|
||||
|
||||
|
||||
def test_shacl_validate_rejects_empty_turtle(client):
|
||||
response = client.post(
|
||||
"/api/ontology/shacl/validate",
|
||||
json={"uri": "http://example.org/onto-a", "shacl_turtle": " "},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_health_returns_404_for_unknown_ontology(client):
|
||||
response = client.get("/api/ontology/health?uri=http%3A%2F%2Fnot-loaded.example%2Fonto")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_health_shacl_dimension_is_zero_when_unavailable(client):
|
||||
payload = client.get("/api/ontology/health?uri=http%3A%2F%2Fexample.org%2Fonto-a").json()
|
||||
shacl_dim = next(d for d in payload["dimensions"] if d["key"] == "shacl")
|
||||
assert shacl_dim["status"] == "unavailable"
|
||||
assert shacl_dim["score"] == 0.0
|
||||
# Total score must NOT include the unavailable dimension in its average.
|
||||
scoreable = [d for d in payload["dimensions"] if d["status"] != "unavailable"]
|
||||
expected_total = round(sum(d["score"] for d in scoreable) / len(scoreable), 1)
|
||||
assert payload["total_score"] == expected_total
|
||||
|
||||
|
||||
def test_delete_unknown_alignment_returns_404(client):
|
||||
response = client.delete("/api/ontology/alignments?id=does-not-exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_alignment_upsert_is_idempotent(client):
|
||||
payload = {
|
||||
"source_uri": "http://example.org/onto-a#Person",
|
||||
"target_uri": "http://example.org/onto-b#PersonRecord",
|
||||
"relation": "owl:equivalentClass",
|
||||
"confidence": 0.80,
|
||||
}
|
||||
first = client.post("/api/ontology/alignments", json=payload).json()
|
||||
updated_payload = {**payload, "confidence": 0.95}
|
||||
second = client.post("/api/ontology/alignments", json=updated_payload).json()
|
||||
assert first["id"] == second["id"], "upsert must reuse the same deterministic ID"
|
||||
assert second["confidence"] == 0.95
|
||||
assert second["created_at"] == first["created_at"], "created_at must not change on update"
|
||||
listed = client.get("/api/ontology/alignments").json()
|
||||
assert len(listed) == 1
|
||||
|
||||
|
||||
def test_alignment_accepts_external_uri(client):
|
||||
payload = {
|
||||
"source_uri": "http://example.org/onto-a#Person",
|
||||
"target_uri": "http://schema.org/Person", # not in local graph
|
||||
"relation": "owl:equivalentClass",
|
||||
"confidence": 0.75,
|
||||
}
|
||||
response = client.post("/api/ontology/alignments", json=payload)
|
||||
assert response.status_code == 200
|
||||
alignment = response.json()
|
||||
assert alignment["target_label"] == "Person" # derived from URI fragment
|
||||
|
||||
Reference in New Issue
Block a user