diff --git a/tests/test_graph_kpi_snapshot.py b/tests/test_graph_kpi_snapshot.py index 5bd1a43..8b23356 100644 --- a/tests/test_graph_kpi_snapshot.py +++ b/tests/test_graph_kpi_snapshot.py @@ -125,10 +125,46 @@ def test_build_graph_kpi_api_payload_is_numeric_except_snapshot_metadata( assert payload["snapshot_date"] == "2026-06-29" assert payload["calculated_at"] == "2026-06-29T10:00:00Z" assert payload["snapshot_origin"] == "worai_graph_kpis" - assert payload["schema_compliance"]["urls_checked"] == 2 - assert payload["schema_compliance"]["errors"] == 2 + assert payload["all_total_entities"] == 5 + assert payload["all_total_typed_entities"] == 5 + assert payload["all_total_triples"] == 13 + assert payload["all_total_properties"] == 8 + assert payload["all_unique_properties_count"] == 3 + assert payload["all_rdf_type_triples_count"] == 5 + assert payload["all_internal_nodes_count"] == 5 + assert payload["all_internal_edges_count"] == 2 + assert payload["all_edge_predicate_counts"]["schema:brand"] == 2 + assert payload["all_unique_urls_count"] == 2 + assert payload["all_orphans_count"] == 2 + assert payload["all_broken_links_count"] == 1 + assert payload["all_isolated_graphs_count"] == 4 + assert payload["all_largest_component_nodes_count"] == 2 + assert payload["all_duplicates_count"] == 1 + assert payload["all_duplicate_extra_entities_count"] == 1 + assert payload["rich_snippets_candidate_count"] == 3 + assert payload["rich_snippets_by_type"]["Product"] == 3 + assert payload["schema_compliance_errors"] == 2 + assert payload["schema_compliance_urls_checked"] == 2 + assert payload["schema_compliance_urls_with_errors"] == 1 + assert payload["graph_health_score"] == 43 + assert payload["all_entity_types"]["Product"] == 3 + assert payload["all_properties_by_predicate"]["schema:url"] == 3 assert "orphan_entity_examples" not in payload assert "broken_internal_edge_examples" not in payload + assert "total_entities" not in payload + assert "total_triples" not in payload + assert "internal_edges" not in payload + assert "unique_urls_within_website_scope" not in payload + assert "broken_internal_edges" not in payload + assert "duplicate_url_groups" not in payload + assert "schema_compliance" not in payload + assert "entity_type_counts" not in payload + assert "property_counts" not in payload + assert "rich_snippet_candidate_entities" not in payload + assert "public_total_entities" not in payload + assert "private_total_entities" not in payload + assert "public_internal_edges_count" not in payload + assert "private_internal_edges_count" not in payload def assert_numeric_kpis(value, path: str = "") -> None: if path in {"snapshot_date", "calculated_at", "snapshot_origin"}: @@ -144,6 +180,83 @@ def assert_numeric_kpis(value, path: str = "") -> None: assert_numeric_kpis(payload) +def test_graph_health_score_is_normalized_by_metric_denominators() -> None: + small_payload = build_graph_kpi_api_payload( + { + "totals": { + "total_entity_count": 100, + "total_property_count": 300, + "total_triples": 1_000, + "unique_urls_within_website_scope": 10, + }, + "edges": {"total_internal_edges": 20}, + "integrity": { + "broken_internal_edge_count": 2, + "duplicate_url_group_count": 1, + }, + "schema_compliance": { + "urls_checked": 10, + "errors": 1, + "warnings": 2, + "google_merchant_eligible": 8, + "google_merchant_not_eligible": 2, + }, + }, + snapshot_date="2026-06-29", + ) + large_payload = build_graph_kpi_api_payload( + { + "totals": { + "total_entity_count": 1_000, + "total_property_count": 3_000, + "total_triples": 10_000, + "unique_urls_within_website_scope": 100, + }, + "edges": {"total_internal_edges": 200}, + "integrity": { + "broken_internal_edge_count": 20, + "duplicate_url_group_count": 10, + }, + "schema_compliance": { + "urls_checked": 100, + "errors": 10, + "warnings": 20, + "google_merchant_eligible": 80, + "google_merchant_not_eligible": 20, + }, + }, + snapshot_date="2026-06-29", + ) + + assert small_payload["graph_health_score"] == large_payload["graph_health_score"] + assert small_payload["graph_health_score"] == 94 + + +def test_graph_health_score_uses_safe_defaults_for_missing_denominators() -> None: + payload = build_graph_kpi_api_payload( + { + "totals": { + "total_entity_count": 10, + "total_property_count": 20, + "total_triples": 30, + }, + "edges": {"total_internal_edges": 0}, + "integrity": { + "broken_internal_edge_count": 5, + "duplicate_url_group_count": 5, + }, + "schema_compliance": { + "urls_checked": 0, + "errors": 5, + "warnings": 5, + }, + }, + snapshot_date="2026-06-29", + ) + + assert payload["graph_health_score"] == 100 + + def test_build_graph_kpi_api_payload_rejects_non_numeric_kpi_values() -> None: with pytest.raises(ValueError): build_graph_kpi_api_payload( diff --git a/wordlift_sdk/graph/audit/_kpi_snapshot.py b/wordlift_sdk/graph/audit/_kpi_snapshot.py index 3c2a9d4..f982b5d 100644 --- a/wordlift_sdk/graph/audit/_kpi_snapshot.py +++ b/wordlift_sdk/graph/audit/_kpi_snapshot.py @@ -111,50 +111,124 @@ def build_graph_kpi_api_payload( topology = snapshot.get("topology") or {} compliance = snapshot.get("schema_compliance") or {} + all_total_entities = totals.get("total_entity_count", 0) + all_total_typed_entities = totals.get("total_typed_entity_count", 0) + all_total_properties = totals.get("total_property_count", 0) + all_total_triples = totals.get("total_triples", 0) + all_unique_properties_count = totals.get("unique_property_count", 0) + all_rdf_type_triples_count = totals.get("rdf_type_triples", 0) + all_internal_edges_count = edges.get("total_internal_edges", 0) + all_unique_urls_count = totals.get("unique_urls_within_website_scope", 0) + all_broken_links_count = integrity.get("broken_internal_edge_count", 0) + all_duplicates_count = integrity.get("duplicate_url_group_count", 0) + all_duplicate_extra_entities_count = integrity.get( + "duplicate_extra_entity_count", 0 + ) + schema_compliance_errors = compliance.get("errors", 0) + schema_compliance_warnings = compliance.get("warnings", 0) + schema_compliance_urls_checked = compliance.get("urls_checked", 0) + rich_snippets_valid_count = compliance.get("google_merchant_eligible", 0) + rich_snippets_invalid_count = compliance.get("google_merchant_not_eligible", 0) + rich_snippet_candidate_entities = ( + snapshot.get("rich_snippet_candidate_entities") or {} + ) + payload: dict[str, Any] = { "snapshot_date": snapshot_date_value, "calculated_at": calculated_at_value, "snapshot_origin": snapshot_origin, - "total_triples": totals.get("total_triples", 0), - "total_entities": totals.get("total_entity_count", 0), - "total_typed_entities": totals.get("total_typed_entity_count", 0), - "total_properties": totals.get("total_property_count", 0), - "unique_properties": totals.get("unique_property_count", 0), - "rdf_type_triples": totals.get("rdf_type_triples", 0), - "unique_urls_within_website_scope": totals.get( - "unique_urls_within_website_scope", 0 + "all_total_entities": all_total_entities, + "all_total_typed_entities": all_total_typed_entities, + "all_total_properties": all_total_properties, + "all_total_triples": all_total_triples, + "all_unique_properties_count": all_unique_properties_count, + "all_rdf_type_triples_count": all_rdf_type_triples_count, + "all_internal_nodes_count": connectivity.get("internal_node_count", 0), + "all_internal_edges_count": all_internal_edges_count, + "all_external_edges_count": 0, + "all_edges_count": all_internal_edges_count, + "all_edge_predicate_counts": edges.get("edge_predicate_counts") or {}, + "all_edge_node_ratio": edges.get("edge_to_node_ratio", 0), + "all_unique_urls_count": all_unique_urls_count, + "all_orphans_count": connectivity.get("orphan_entity_count", 0), + "all_broken_links_count": all_broken_links_count, + "all_isolated_graphs_count": topology.get("isolated_graph_count", 0), + "all_largest_component_nodes_count": topology.get( + "largest_component_node_count", 0 ), - "entity_type_counts": snapshot.get("entity_type_counts") or {}, - "property_counts": snapshot.get("property_counts") or {}, - "rich_snippet_candidate_entities": snapshot.get( - "rich_snippet_candidate_entities" - ) - or {}, - "internal_edges": edges.get("total_internal_edges", 0), - "edge_predicate_counts": edges.get("edge_predicate_counts") or {}, - "edge_to_node_ratio": edges.get("edge_to_node_ratio", 0), - "internal_nodes": connectivity.get("internal_node_count", 0), - "orphan_entities": connectivity.get("orphan_entity_count", 0), - "broken_internal_edges": integrity.get("broken_internal_edge_count", 0), - "duplicate_url_groups": integrity.get("duplicate_url_group_count", 0), - "duplicate_extra_entities": integrity.get("duplicate_extra_entity_count", 0), - "isolated_graph_components": topology.get("isolated_graph_count", 0), - "largest_component_nodes": topology.get("largest_component_node_count", 0), - "schema_compliance": { - "urls_checked": compliance.get("urls_checked", 0), - "urls_with_errors": compliance.get("urls_with_errors", 0), - "urls_with_warnings": compliance.get("urls_with_warnings", 0), - "errors": compliance.get("errors", 0), - "warnings": compliance.get("warnings", 0), - "google_merchant_eligible": compliance.get("google_merchant_eligible", 0), - "google_merchant_not_eligible": compliance.get( - "google_merchant_not_eligible", 0 - ), - }, + "all_duplicates_count": all_duplicates_count, + "all_duplicate_extra_entities_count": all_duplicate_extra_entities_count, + "rich_snippets_candidate_count": rich_snippet_candidate_entities.get( + "total", 0 + ), + "rich_snippets_valid_count": rich_snippets_valid_count, + "rich_snippets_invalid_count": rich_snippets_invalid_count, + "rich_snippets_by_type": rich_snippet_candidate_entities.get("by_type", {}), + "schema_compliance_errors": schema_compliance_errors, + "schema_compliance_warnings": schema_compliance_warnings, + "schema_compliance_urls_checked": schema_compliance_urls_checked, + "schema_compliance_urls_with_errors": compliance.get("urls_with_errors", 0), + "schema_compliance_urls_with_warnings": compliance.get("urls_with_warnings", 0), + "all_entity_types": snapshot.get("entity_type_counts") or {}, + "all_properties_by_predicate": snapshot.get("property_counts") or {}, } + payload["graph_health_score"] = _calculate_graph_health_score(payload) return _numeric_only(payload) +def _calculate_graph_health_score(metrics: dict[str, Any]) -> int: + score_value = ( + 100 + - _safe_rate( + metrics.get("schema_compliance_errors"), + metrics.get("schema_compliance_urls_checked"), + ) + * 35 + - _safe_rate( + metrics.get("schema_compliance_warnings"), + metrics.get("schema_compliance_urls_checked"), + ) + * 10 + - _safe_rate( + metrics.get("all_broken_links_count"), + metrics.get("all_internal_edges_count"), + ) + * 25 + - _safe_rate( + metrics.get("all_duplicates_count"), + metrics.get("all_unique_urls_count"), + ) + * 20 + + _rich_snippet_valid_rate(metrics) * 5 + ) + score = int(score_value + 0.5) + return max(0, min(100, score)) + + +def _rich_snippet_valid_rate(metrics: dict[str, Any]) -> float: + valid = _to_float(metrics.get("rich_snippets_valid_count")) + invalid = _to_float(metrics.get("rich_snippets_invalid_count")) + total = valid + invalid + if total <= 0: + return 0 + return valid / total + + +def _safe_rate(numerator: Any, denominator: Any) -> float: + denominator_value = _to_float(denominator) + if denominator_value <= 0: + return 0 + return _to_float(numerator) / denominator_value + + +def _to_float(value: Any) -> float: + if isinstance(value, bool): + return 0 + if isinstance(value, (int, float)): + return float(value) + return 0 + + def schema_term(uri: Any) -> str | None: value = str(uri) if value.startswith(SCHEMA):