File size: 6,467 Bytes
921d377 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 | """
Individual QA checks over a manifest dict.
Each check is a small pure function that returns a list of
``QAIssue`` dicts. Adding a new check = appending a callable to
``all_checks()``. No global state.
Checks defined here:
C1 graph_has_entry_node There must be at least one scene/node we can start from.
C2 graph_no_dangling_edges Every edge points at a real node.
C3 nodes_have_narration Non-ending nodes ought to have narration text.
C4 every_action_reachable Every action's required_level must be
attainable given the experience's xp_award totals.
C5 every_node_has_outbound Non-ending nodes have β₯1 outbound edge (dead-end check).
C6 personalization_rules_valid Each rule's condition + action keys
pass validate_rule.
C7 mature_content_consent experience_mode='mature_gated' must require consent.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List
from ..personalize.rules import validate_rule
QAIssue = Dict[str, Any]
QACheck = Callable[[Dict[str, Any]], List[QAIssue]]
def _issue(code: str, severity: str, detail: str, **extra: Any) -> QAIssue:
out: QAIssue = {"code": code, "severity": severity, "detail": detail}
if extra:
out.update(extra)
return out
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Individual checks
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def graph_has_entry_node(manifest: Dict[str, Any]) -> List[QAIssue]:
nodes = manifest.get("nodes") or []
if not nodes:
return [_issue("no_nodes", "error", "experience has no nodes")]
scenes = [n for n in nodes if n.get("kind") == "scene"]
if not scenes:
return [_issue("no_entry_scene", "error", "no node of kind='scene' to use as entry")]
return []
def graph_no_dangling_edges(manifest: Dict[str, Any]) -> List[QAIssue]:
nodes = {n["id"] for n in (manifest.get("nodes") or [])}
out: List[QAIssue] = []
for e in manifest.get("edges") or []:
if e.get("from_node_id") not in nodes:
out.append(_issue(
"edge_dangling_from", "error",
f"edge {e.get('id')} references missing from_node {e.get('from_node_id')}",
edge_id=e.get("id"),
))
if e.get("to_node_id") not in nodes:
out.append(_issue(
"edge_dangling_to", "error",
f"edge {e.get('id')} references missing to_node {e.get('to_node_id')}",
edge_id=e.get("id"),
))
return out
def nodes_have_narration(manifest: Dict[str, Any]) -> List[QAIssue]:
out: List[QAIssue] = []
for n in manifest.get("nodes") or []:
if n.get("kind") == "ending":
continue
if not (n.get("narration") or "").strip():
out.append(_issue(
"narration_empty", "warning",
f"node {n.get('id')} has no narration",
node_id=n.get("id"),
))
return out
def every_action_reachable(manifest: Dict[str, Any]) -> List[QAIssue]:
"""Sum of xp_award across all actions must be β₯ each action's
required_level when the scheme is xp_level. Otherwise the
catalog has actions a viewer can never unlock through normal
play (a soft warning, not an error β sometimes the level comes
from external progression)."""
actions = manifest.get("actions") or []
total_xp = sum(int(a.get("xp_award") or 0) for a in actions if a.get("required_scheme") == "xp_level")
out: List[QAIssue] = []
for a in actions:
if a.get("required_scheme") != "xp_level":
continue
# required_level is a coarse proxy β level 5 needs ~_xp_threshold(5) XP.
# Use a simple linear approximation 15 * (level-1)^1.5 β 15 * (level-1) * 1.5
lvl = int(a.get("required_level") or 1)
threshold = max(0, int(15 * (lvl - 1) ** 1.5))
if threshold > total_xp:
out.append(_issue(
"action_unreachable", "warning",
f"action {a.get('id')} requires level {lvl} (~{threshold} XP) "
f"but max attainable in this experience is ~{total_xp} XP",
action_id=a.get("id"),
))
return out
def every_node_has_outbound(manifest: Dict[str, Any]) -> List[QAIssue]:
out: List[QAIssue] = []
edges = manifest.get("edges") or []
by_from = {}
for e in edges:
by_from.setdefault(e.get("from_node_id"), []).append(e)
for n in manifest.get("nodes") or []:
if n.get("kind") == "ending":
continue
if not by_from.get(n.get("id")):
out.append(_issue(
"node_dead_end", "warning",
f"non-ending node {n.get('id')} has no outbound edges",
node_id=n.get("id"),
))
return out
def personalization_rules_valid(manifest: Dict[str, Any]) -> List[QAIssue]:
out: List[QAIssue] = []
for r in manifest.get("rules") or []:
problems = validate_rule(r.get("condition") or {}, r.get("action") or {})
for p in problems:
out.append(_issue(
"rule_invalid", "error",
f"rule {r.get('id')} ({r.get('name')}): {p}",
rule_id=r.get("id"),
))
return out
def mature_content_consent(manifest: Dict[str, Any]) -> List[QAIssue]:
exp = manifest.get("experience") or {}
if exp.get("experience_mode") != "mature_gated":
return []
profile_id = (exp.get("policy_profile_id") or "").lower()
if "mature" not in profile_id:
return [_issue(
"mature_profile_mismatch", "error",
"mature_gated mode must use a mature-* policy profile",
profile_id=profile_id,
)]
return []
def all_checks() -> List[QACheck]:
"""Return the registry of checks in execution order."""
return [
graph_has_entry_node,
graph_no_dangling_edges,
nodes_have_narration,
every_action_reachable,
every_node_has_outbound,
personalization_rules_valid,
mature_content_consent,
]
|