mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
fix(ontology): render real SHACL constraint values in explain_violations
explain_violations previously rendered hardcoded placeholders (min_count=1, max_count=1) and misused the violation message as the datatype/class value, so plain-English explanations were inaccurate. The root cause is that _run_pyshacl never read the real constraint parameters from sh:sourceShape when building each SHACLViolation. Changes: - SHACLViolation: add min_count/max_count/datatype/class_ fields and include them in to_dict() - _run_pyshacl: back-reference sh:sourceShape to extract the real sh:minCount/sh:maxCount/sh:datatype/sh:class values - explain_violations: render the real values, falling back to "?" or descriptive text when unknown Note: sh:qualifiedMinCount/qualifiedMaxCount are not handled and fall back to the "?" placeholder. Adds regression tests covering both the formatting path and the sh:sourceShape back-reference (skips when pyshacl/rdflib are absent).
This commit is contained in:
@@ -33,6 +33,12 @@ class SHACLViolation:
|
||||
value: Optional[str] = None
|
||||
shape: Optional[str] = None
|
||||
explanation: Optional[str] = None
|
||||
# Real constraint parameters extracted from the source shape (sh:sourceShape),
|
||||
# used to render accurate plain-English explanations.
|
||||
min_count: Optional[int] = None
|
||||
max_count: Optional[int] = None
|
||||
datatype: Optional[str] = None
|
||||
class_: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -44,6 +50,10 @@ class SHACLViolation:
|
||||
"value": self.value,
|
||||
"shape": self.shape,
|
||||
"explanation": self.explanation,
|
||||
"min_count": self.min_count,
|
||||
"max_count": self.max_count,
|
||||
"datatype": self.datatype,
|
||||
"class_": self.class_,
|
||||
}
|
||||
|
||||
|
||||
@@ -118,10 +128,10 @@ class SHACLValidationReport:
|
||||
focus_node=v.focus_node,
|
||||
path=v.result_path or "",
|
||||
value=v.value or "",
|
||||
min_count=1,
|
||||
max_count=1,
|
||||
datatype=v.message or "",
|
||||
class_=v.message or "",
|
||||
min_count=v.min_count if v.min_count is not None else "?",
|
||||
max_count=v.max_count if v.max_count is not None else "?",
|
||||
datatype=v.datatype or "the expected datatype",
|
||||
class_=v.class_ or "the required class",
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -208,6 +218,32 @@ def _run_pyshacl(
|
||||
shape_node = results_graph.value(result, SH.sourceShape)
|
||||
shape = str(shape_node) if shape_node is not None else None
|
||||
|
||||
# Look up the real constraint parameters from the source shape so that
|
||||
# explain_violations can render accurate values instead of placeholders.
|
||||
# Note: sh:qualifiedMinCount / sh:qualifiedMaxCount are not handled here;
|
||||
# such violations fall back to the "?" placeholder in explain_violations.
|
||||
min_count: Optional[int] = None
|
||||
max_count: Optional[int] = None
|
||||
datatype: Optional[str] = None
|
||||
class_: Optional[str] = None
|
||||
if shape_node is not None:
|
||||
min_node = shacl_g.value(shape_node, SH.minCount)
|
||||
if min_node is not None:
|
||||
try:
|
||||
min_count = int(str(min_node))
|
||||
except (TypeError, ValueError):
|
||||
min_count = None
|
||||
max_node = shacl_g.value(shape_node, SH.maxCount)
|
||||
if max_node is not None:
|
||||
try:
|
||||
max_count = int(str(max_node))
|
||||
except (TypeError, ValueError):
|
||||
max_count = None
|
||||
dt_node = shacl_g.value(shape_node, SH.datatype)
|
||||
datatype = str(dt_node) if dt_node is not None else None
|
||||
cls_node = shacl_g.value(shape_node, SH["class"])
|
||||
class_ = str(cls_node) if cls_node is not None else None
|
||||
|
||||
v = SHACLViolation(
|
||||
focus_node=focus,
|
||||
result_path=path,
|
||||
@@ -216,6 +252,10 @@ def _run_pyshacl(
|
||||
message=msg,
|
||||
value=val,
|
||||
shape=shape,
|
||||
min_count=min_count,
|
||||
max_count=max_count,
|
||||
datatype=datatype,
|
||||
class_=class_,
|
||||
)
|
||||
if sev_str == "Violation":
|
||||
violations.append(v)
|
||||
|
||||
@@ -414,6 +414,103 @@ class TestSHACLHierarchicalAndValidation(unittest.TestCase):
|
||||
self.assertIsNotNone(v.explanation)
|
||||
self.assertIn("https://example.com/john", v.explanation)
|
||||
|
||||
# 32b
|
||||
def test_explain_violations_uses_real_constraint_values(self):
|
||||
"""explain_violations must render the real min/max/datatype/class values,
|
||||
not hardcoded placeholders (regression for PR #318)."""
|
||||
from semantica.ontology.ontology_validator import (
|
||||
SHACLValidationReport,
|
||||
SHACLViolation,
|
||||
)
|
||||
|
||||
max_v = SHACLViolation(
|
||||
focus_node="https://example.com/john",
|
||||
result_path="ex:age",
|
||||
constraint="MaxCountConstraintComponent",
|
||||
max_count=3,
|
||||
)
|
||||
dt_v = SHACLViolation(
|
||||
focus_node="https://example.com/john",
|
||||
result_path="ex:age",
|
||||
constraint="DatatypeConstraintComponent",
|
||||
value="abc",
|
||||
datatype="http://www.w3.org/2001/XMLSchema#integer",
|
||||
)
|
||||
cls_v = SHACLViolation(
|
||||
focus_node="https://example.com/john",
|
||||
result_path="ex:knows",
|
||||
constraint="ClassConstraintComponent",
|
||||
value="https://example.com/thing",
|
||||
class_="https://example.com/Person",
|
||||
)
|
||||
report = SHACLValidationReport(
|
||||
conforms=False, violations=[max_v, dt_v, cls_v]
|
||||
)
|
||||
report.explain_violations()
|
||||
# MaxCount must show the real limit (3), not the hardcoded 1.
|
||||
self.assertIn("3", max_v.explanation)
|
||||
self.assertNotIn("At most 1 value", max_v.explanation)
|
||||
# Datatype must show the real datatype IRI, not the message.
|
||||
self.assertIn(
|
||||
"http://www.w3.org/2001/XMLSchema#integer", dt_v.explanation
|
||||
)
|
||||
# Class must show the real class IRI.
|
||||
self.assertIn("https://example.com/Person", cls_v.explanation)
|
||||
|
||||
# 32c
|
||||
def test_run_pyshacl_extracts_constraint_values_from_shape(self):
|
||||
"""_run_pyshacl must back-reference sh:sourceShape to populate the real
|
||||
constraint parameters on each violation."""
|
||||
try:
|
||||
import pyshacl # noqa: F401
|
||||
import rdflib # noqa: F401
|
||||
except ImportError:
|
||||
self.skipTest("pyshacl/rdflib not installed")
|
||||
from semantica.ontology.ontology_validator import _run_pyshacl
|
||||
|
||||
shacl = """
|
||||
@prefix sh: <http://www.w3.org/ns/shacl#> .
|
||||
@prefix ex: <http://example.com/> .
|
||||
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
||||
|
||||
ex:PersonShape a sh:NodeShape ;
|
||||
sh:targetClass ex:Person ;
|
||||
sh:property [
|
||||
sh:path ex:age ;
|
||||
sh:datatype xsd:integer ;
|
||||
sh:maxCount 2 ;
|
||||
] .
|
||||
"""
|
||||
data = """
|
||||
@prefix ex: <http://example.com/> .
|
||||
ex:john a ex:Person ;
|
||||
ex:age "not-a-number" ;
|
||||
ex:age 1 ;
|
||||
ex:age 2 ;
|
||||
ex:age 3 .
|
||||
"""
|
||||
report = _run_pyshacl(data, shacl)
|
||||
self.assertFalse(report.conforms)
|
||||
# Datatype violation should carry the real xsd:integer datatype.
|
||||
dt = [
|
||||
v
|
||||
for v in report.violations
|
||||
if v.constraint == "DatatypeConstraintComponent"
|
||||
]
|
||||
self.assertTrue(dt)
|
||||
self.assertTrue(
|
||||
dt[0].datatype.endswith("integer"),
|
||||
f"expected integer datatype, got {dt[0].datatype}",
|
||||
)
|
||||
# MaxCount violation should carry the real max_count == 2.
|
||||
mc = [
|
||||
v
|
||||
for v in report.violations
|
||||
if v.constraint == "MaxCountConstraintComponent"
|
||||
]
|
||||
if mc:
|
||||
self.assertEqual(mc[0].max_count, 2)
|
||||
|
||||
# 33
|
||||
def test_shacl_violation_to_dict(self):
|
||||
from semantica.ontology.ontology_validator import SHACLViolation
|
||||
|
||||
Reference in New Issue
Block a user