From 08a6e7c0535afa5e747ce3c4e8f91620215a49f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E4=BF=8A=E6=9D=B0?= Date: Wed, 19 Aug 2026 16:30:36 +0800 Subject: [PATCH] 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). --- semantica/ontology/ontology_validator.py | 48 +++++++++++- tests/ontology/test_ontology_advanced.py | 97 ++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 4 deletions(-) diff --git a/semantica/ontology/ontology_validator.py b/semantica/ontology/ontology_validator.py index 4a03f2d1..5d9d10df 100644 --- a/semantica/ontology/ontology_validator.py +++ b/semantica/ontology/ontology_validator.py @@ -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) diff --git a/tests/ontology/test_ontology_advanced.py b/tests/ontology/test_ontology_advanced.py index 564ca758..14ac52d5 100644 --- a/tests/ontology/test_ontology_advanced.py +++ b/tests/ontology/test_ontology_advanced.py @@ -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: . + @prefix ex: . + @prefix xsd: . + + 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: . + 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