build_config_from_excel.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. """Excel -> runtime JSON converter (V5-M4), byte-equal via governed generation.
  2. Business edits the config Excel; this regenerates the runtime JSON. To guarantee
  3. byte-equality (so `policy_bundle_hash` and runtime behaviour never drift on a
  4. no-op regen), the converter uses the current JSON as a STRUCTURAL TEMPLATE
  5. (preserving key order) and overlays Excel cell values onto the matching base
  6. leaves, coercing each cell to the base value's type. Only paths that already
  7. exist in the base are overlaid — structurally divergent / non-value Excel
  8. columns are skipped, and non-sheet-backed JSON sections pass through unchanged.
  9. M4 adds governed runtime sheets. Rows from those sheets enter runtime JSON only
  10. when `approval_status=approved` and `runtime_enabled=true`; other rows are kept
  11. as Excel ledger/governance data and are intentionally excluded.
  12. python scripts/build_config_from_excel.py --check # byte-equal? exit 1 on drift
  13. python scripts/build_config_from_excel.py --write # regenerate JSON from Excel
  14. This handles VALUE edits (thresholds, scores, gate values, flags). Structural
  15. changes (adding/removing rows) need a base update — out of V2-M1 scope.
  16. """
  17. from __future__ import annotations
  18. import argparse
  19. import copy
  20. import json
  21. import sys
  22. from pathlib import Path
  23. from typing import Any
  24. ROOT = Path(__file__).resolve().parents[1]
  25. if str(ROOT) not in sys.path:
  26. sys.path.insert(0, str(ROOT))
  27. from openpyxl import load_workbook
  28. from content_agent.integrations import config_store
  29. from scripts.check_config_json_canonical import canonical_dumps
  30. RULE_PACK_XLSX = ROOT / "tech_documents/规则包映射/规则包映射配置表.xlsx"
  31. WALK_XLSX = ROOT / "tech_documents/游走策略/游走策略配置表.xlsx"
  32. RULE_PACK_JSON = ROOT / "product_documents/规则包/douyin_rule_packs.v1.json"
  33. WALK_JSON = ROOT / "product_documents/抖音游走策略/douyin_walk_strategy.v1.json"
  34. SCORE_THRESHOLDS_JSON = ROOT / "tech_documents/数据接口与来源/score_thresholds.json"
  35. WALK_POLICY_JSON = ROOT / "tech_documents/数据接口与来源/walk_policy.json"
  36. PROFILE_DIR = ROOT / "tech_documents/数据接口与来源/platform_profiles"
  37. SENTINEL = "备注" # row 2 of every sheet; skipped
  38. SKIP_COLUMN = "注释" # human annotation column with no JSON home
  39. APPROVAL_STATUSES = {"draft", "waiting_for_human_reivew", "approved", "rejected", "deprecated"}
  40. RUNTIME_MODES = {"generate_runtime"}
  41. THRESHOLD_KEYS = {
  42. "pool_total",
  43. "pool_query",
  44. "review_total",
  45. "review_query",
  46. "walk_query",
  47. "walk_platform",
  48. "walk_total",
  49. }
  50. # A SheetSpec maps one Excel sheet onto a JSON section by overlay.
  51. # top-level: base[section] is a list of objects indexed by id_json (== row[id_excel]).
  52. # nested: base["rule_packs"][*][child_path...] grouped by parent_fk (rule_pack_id).
  53. class SheetSpec:
  54. def __init__(
  55. self,
  56. sheet,
  57. *,
  58. section=None,
  59. id_excel=None,
  60. id_json=None,
  61. renames=None,
  62. parent_fk=None,
  63. child_path=None,
  64. mode="overlay_existing",
  65. ):
  66. self.sheet = sheet
  67. self.section = section # top-level JSON key (for nested: "rule_packs")
  68. self.id_excel = id_excel
  69. self.id_json = id_json or id_excel
  70. self.renames = renames or {} # excel column -> dotted base path
  71. self.parent_fk = parent_fk # excel column holding rule_pack_id (nested only)
  72. self.child_path = child_path # dotted path within a rule_pack (nested only)
  73. self.mode = mode
  74. _WHEN = {"field_path": "when.field", "operator": "when.op", "expected_value": "when.value"}
  75. RULE_PACK_SPECS = [
  76. SheetSpec("rule_pack_dispatch", section="rule_pack_dispatch", id_excel="dispatch_id"),
  77. SheetSpec("decision_reason_codes", section="decision_reason_codes", id_excel="decision_reason_code"),
  78. SheetSpec("effect_status_mapping", section="effect_status_mapping", id_excel="mapping_id"),
  79. SheetSpec("query_effect_aggregation", section="query_effect_aggregation", id_excel="aggregation_id"),
  80. SheetSpec("hard_gate_rules", section="rule_packs", child_path="hard_gates", parent_fk="rule_pack_id",
  81. id_excel="gate_id", renames={"gate_label": "label", **_WHEN}),
  82. SheetSpec("scorecard_dimensions", section="rule_packs", child_path="scorecard.dimensions", parent_fk="rule_pack_id",
  83. id_excel="dimension_key", id_json="key", renames={"dimension_label": "label"}),
  84. SheetSpec("scorecard_scoring_rules", section="rule_packs", child_path="scorecard.scoring_rules", parent_fk="rule_pack_id",
  85. id_excel="scoring_rule_id"),
  86. SheetSpec("threshold_actions", section="rule_packs", child_path="thresholds", parent_fk="rule_pack_id",
  87. id_excel="decision_reason_code"),
  88. ]
  89. # Walk sheets mirror JSON 1:1 (identity columns; only 注释 skipped).
  90. # V3 清理: 13 段收窄到 3 个仍被消费的段(其余 10 段已被 walk_graph+walk_policy 取代,
  91. # JSON 段与对应 Excel sheet 一并删除)。
  92. _WALK = {
  93. "walk_edge_catalog": "edge_id",
  94. "walk_rule_pack_binding": "binding_id",
  95. "v4_walk_gate": "gate_id",
  96. "walk_fact_contract": "runtime_file",
  97. }
  98. WALK_SPECS = [SheetSpec(sheet, section=sheet, id_excel=idc) for sheet, idc in _WALK.items()]
  99. GOVERNED_RULE_PACK_SHEETS = {
  100. "score_weight_profiles": "generate_runtime",
  101. "score_threshold_profiles": "generate_runtime",
  102. }
  103. GOVERNED_WALK_SHEETS = {
  104. "platform_edge_matrix": "generate_runtime",
  105. "walk_policy_budget_matrix": "generate_runtime",
  106. }
  107. def _read_rows(ws) -> list[dict[str, Any]]:
  108. headers = [c.value for c in ws[1]]
  109. rows = []
  110. for excel_row in ws.iter_rows(min_row=2, values_only=True):
  111. if excel_row and str(excel_row[0] or "").startswith(SENTINEL):
  112. continue # sentinel comment row
  113. rows.append({h: v for h, v in zip(headers, excel_row) if h is not None})
  114. return rows
  115. def _sheet_rows(wb, sheet: str) -> list[dict[str, Any]]:
  116. if sheet not in wb.sheetnames:
  117. return []
  118. return _read_rows(wb[sheet])
  119. def _parse_bool(value: Any) -> bool:
  120. if isinstance(value, bool):
  121. return value
  122. return str(value or "").strip().lower() in {"true", "1", "yes", "y"}
  123. def _split_list(value: Any) -> list[str]:
  124. if value is None or value == "":
  125. return []
  126. if isinstance(value, list):
  127. return [str(item) for item in value if str(item)]
  128. text = str(value).strip()
  129. if not text:
  130. return []
  131. if text.startswith("["):
  132. try:
  133. parsed = json.loads(text)
  134. except json.JSONDecodeError:
  135. parsed = []
  136. if isinstance(parsed, list):
  137. return [str(item) for item in parsed if str(item)]
  138. return [part.strip() for part in text.split(",") if part.strip()]
  139. def _require_valid_governance(row: dict[str, Any], sheet: str) -> None:
  140. status = str(row.get("approval_status") or "").strip()
  141. if status not in APPROVAL_STATUSES:
  142. raise ValueError(f"{sheet} invalid approval_status: {status!r}")
  143. if status == "approved" and _parse_bool(row.get("runtime_enabled")):
  144. if not row.get("owner"):
  145. raise ValueError(f"{sheet} approved runtime row missing owner")
  146. if not row.get("last_reviewed_at"):
  147. raise ValueError(f"{sheet} approved runtime row missing last_reviewed_at")
  148. def _runtime_row_enabled(row: dict[str, Any], sheet: str) -> bool:
  149. _require_valid_governance(row, sheet)
  150. return str(row.get("approval_status") or "").strip() == "approved" and _parse_bool(row.get("runtime_enabled"))
  151. def _load_workbook(path: Path, *, read_only: bool = True):
  152. return load_workbook(path, data_only=True, read_only=read_only)
  153. def _coerce(cell: Any, base_value: Any) -> Any:
  154. if isinstance(base_value, bool):
  155. return str(cell).strip().lower() in {"true", "1", "yes"} if not isinstance(cell, bool) else cell
  156. if isinstance(base_value, int):
  157. return base_value if cell is None or cell == "" else int(round(float(cell)))
  158. if isinstance(base_value, float):
  159. return base_value if cell is None or cell == "" else float(cell)
  160. if isinstance(base_value, (list, dict)):
  161. if isinstance(cell, str) and cell.strip():
  162. try:
  163. return json.loads(cell)
  164. except json.JSONDecodeError:
  165. return base_value # non-JSON cell (e.g. comma-joined) -> keep base
  166. return base_value if cell is None or cell == "" else cell
  167. if base_value is None:
  168. return None if cell is None or cell == "" else cell
  169. return base_value if cell is None else str(cell)
  170. def _set_if_exists(obj: dict[str, Any], dotted: str, cell: Any) -> None:
  171. parts = dotted.split(".")
  172. cursor = obj
  173. for part in parts[:-1]:
  174. if not isinstance(cursor, dict) or part not in cursor:
  175. return
  176. cursor = cursor[part]
  177. leaf = parts[-1]
  178. if isinstance(cursor, dict) and leaf in cursor:
  179. cursor[leaf] = _coerce(cell, cursor[leaf])
  180. def _overlay_obj(obj: dict[str, Any], row: dict[str, Any], spec: SheetSpec) -> None:
  181. for column, value in row.items():
  182. if column == SKIP_COLUMN or column == spec.id_excel or column == spec.parent_fk:
  183. continue
  184. path = spec.renames.get(column, column)
  185. _set_if_exists(obj, path, value)
  186. def _nested_targets(base: dict[str, Any], spec: SheetSpec) -> dict[tuple, dict[str, Any]]:
  187. index: dict[tuple, dict[str, Any]] = {}
  188. for pack in base.get("rule_packs", []):
  189. cursor: Any = pack
  190. for part in spec.child_path.split("."):
  191. cursor = cursor.get(part) if isinstance(cursor, dict) else None
  192. for child in cursor or []:
  193. index[(pack.get("rule_pack_id"), child.get(spec.id_json))] = child
  194. return index
  195. def overlay_workbook(xlsx: Path, base: dict[str, Any], specs: list[SheetSpec]) -> dict[str, Any]:
  196. wb = _load_workbook(xlsx)
  197. for spec in specs:
  198. if spec.mode in RUNTIME_MODES:
  199. raise ValueError(f"{spec.sheet} is a runtime sheet and needs a dedicated builder")
  200. rows = _read_rows(wb[spec.sheet])
  201. if spec.child_path:
  202. index = _nested_targets(base, spec)
  203. for row in rows:
  204. obj = index.get((row.get(spec.parent_fk), row.get(spec.id_excel)))
  205. if obj is not None:
  206. _overlay_obj(obj, row, spec)
  207. else:
  208. index = {obj.get(spec.id_json): obj for obj in base.get(spec.section, [])}
  209. for row in rows:
  210. obj = index.get(row.get(spec.id_excel))
  211. if obj is not None:
  212. _overlay_obj(obj, row, spec)
  213. return base
  214. def _build_score_weight_profiles(rows: list[dict[str, Any]]) -> dict[str, Any] | None:
  215. runtime_rows = [row for row in rows if _runtime_row_enabled(row, "score_weight_profiles")]
  216. if not runtime_rows:
  217. return None
  218. profiles: dict[str, dict[str, Any]] = {}
  219. for row in runtime_rows:
  220. profile_id = str(row.get("profile_id") or "").strip()
  221. dimension_key = str(row.get("dimension_key") or "").strip()
  222. if not profile_id or not dimension_key:
  223. raise ValueError("score_weight_profiles runtime rows require profile_id and dimension_key")
  224. profile = profiles.setdefault(
  225. profile_id,
  226. {
  227. "profile_id": profile_id,
  228. "profile_label": row.get("profile_label") or profile_id,
  229. "historical_alias_ids": _split_list(row.get("historical_alias_ids")),
  230. "platform_scope": _split_list(row.get("platform_scope")) or ["*"],
  231. "content_format": row.get("content_format") or "*",
  232. "applies_when": row.get("applies_when") or "always",
  233. "weights": {},
  234. "normalization_policy": row.get("normalization_policy") or "sum_100",
  235. "normalization_reason": row.get("normalization_reason") or "",
  236. "missing_dimension_policy": row.get("missing_dimension_policy") or "score_missing",
  237. "critical_dimensions": [],
  238. "score_source_paths": {},
  239. "priority": int(row.get("priority") or 0),
  240. "fallback_profile_id": row.get("fallback_profile_id") or None,
  241. "runtime_enabled": True,
  242. "approval_status": "approved",
  243. "owner": row.get("owner"),
  244. "last_reviewed_at": str(row.get("last_reviewed_at")),
  245. },
  246. )
  247. profile["weights"][dimension_key] = _number(row.get("weight_percent"))
  248. score_source_path = row.get("score_source_path")
  249. if score_source_path:
  250. profile["score_source_paths"][dimension_key] = str(score_source_path)
  251. if _parse_bool(row.get("critical_dimension")):
  252. profile["critical_dimensions"].append(dimension_key)
  253. return {
  254. "schema_version": "score_weight_profiles.v2",
  255. "profiles": list(profiles.values()),
  256. }
  257. def _apply_score_weight_profiles(rule_pack: dict[str, Any], rows: list[dict[str, Any]]) -> None:
  258. built = _build_score_weight_profiles(rows)
  259. if built is None:
  260. return
  261. for pack in rule_pack.get("rule_packs", []):
  262. scorecard = pack.get("scorecard")
  263. if isinstance(scorecard, dict):
  264. scorecard["score_weight_profiles"] = built
  265. def _build_score_thresholds(base: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]:
  266. runtime_rows = [row for row in rows if _runtime_row_enabled(row, "score_threshold_profiles")]
  267. if not runtime_rows:
  268. return base
  269. thresholds = {
  270. key: copy.deepcopy(value)
  271. for key, value in base.items()
  272. if key.startswith("_") or key == "schema_version"
  273. }
  274. for row in runtime_rows:
  275. key = str(row.get("threshold_key") or "").strip()
  276. if key not in THRESHOLD_KEYS:
  277. raise ValueError(f"score_threshold_profiles unknown threshold_key: {key}")
  278. value = row.get("threshold_value")
  279. if value is None or value == "":
  280. raise ValueError(f"score_threshold_profiles missing value for {key}")
  281. scope_values = _split_list(row.get("platform_scope")) or ["default"]
  282. for scope in scope_values:
  283. target = "default" if scope == "*" else scope
  284. thresholds.setdefault(target, {})[key] = int(round(float(value)))
  285. return thresholds
  286. def _build_platform_profiles(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
  287. by_platform: dict[str, dict[str, Any]] = {}
  288. for profile_path in PROFILE_DIR.glob("*.json"):
  289. profile, _ = config_store.load_json(profile_path)
  290. by_platform[profile_path.stem] = profile
  291. for row in rows:
  292. if not _runtime_row_enabled(row, "platform_edge_matrix"):
  293. continue
  294. platform = str(row.get("platform") or "").strip()
  295. edge_id = str(row.get("edge_id") or "").strip()
  296. status = str(row.get("status") or "").strip()
  297. if platform not in by_platform:
  298. raise ValueError(f"platform_edge_matrix unknown platform: {platform}")
  299. if not edge_id or not status:
  300. raise ValueError("platform_edge_matrix runtime rows require platform, edge_id, status")
  301. spec = by_platform[platform].setdefault("edges", {}).setdefault(edge_id, {})
  302. spec["status"] = status
  303. if row.get("status_reason"):
  304. spec["reason"] = str(row.get("status_reason"))
  305. return by_platform
  306. def _set_wrapped_value(container: dict[str, Any], key: str, value: Any) -> None:
  307. current = container.get(key)
  308. if isinstance(current, dict) and "value" in current:
  309. current["value"] = value
  310. else:
  311. container[key] = value
  312. def _build_walk_policy(base: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]:
  313. runtime_rows = [row for row in rows if _runtime_row_enabled(row, "walk_policy_budget_matrix")]
  314. if not runtime_rows:
  315. return base
  316. policy = copy.deepcopy(base)
  317. platform_rows = []
  318. for row in runtime_rows:
  319. platform = str(row.get("platform") or "").strip()
  320. edge_id = str(row.get("edge_id") or "").strip()
  321. if not platform or not edge_id:
  322. raise ValueError("walk_policy_budget_matrix runtime rows require platform and edge_id")
  323. if platform == "default" and edge_id == "__global__":
  324. platform_rows.append(
  325. {
  326. "platform": platform,
  327. "edge_id": edge_id,
  328. "max_total_actions": _nullable_int(row.get("max_total_actions")),
  329. "max_depth": _nullable_int(row.get("max_depth")),
  330. "budget_exhausted_reason_code": row.get("budget_exhausted_reason_code") or "budget_exhausted",
  331. "operator_display_status": row.get("operator_display_status") or "budget_blocked",
  332. }
  333. )
  334. if row.get("max_total_actions") not in {None, ""}:
  335. _set_wrapped_value(policy["global"], "max_total_actions_per_run", int(row.get("max_total_actions")))
  336. if row.get("max_depth") not in {None, ""}:
  337. _set_wrapped_value(policy["global"], "max_depth", int(row.get("max_depth")))
  338. continue
  339. budget_row = {
  340. "platform": platform,
  341. "edge_id": edge_id,
  342. "max_total_actions": _nullable_int(row.get("max_total_actions")),
  343. "max_per_content": _nullable_int(row.get("max_per_content")),
  344. "max_works_per_author": _nullable_int(row.get("max_works_per_author")),
  345. "max_pages": _nullable_int(row.get("max_pages")),
  346. "budget_exhausted_reason_code": row.get("budget_exhausted_reason_code") or "budget_exhausted",
  347. "operator_display_status": row.get("operator_display_status") or "budget_blocked",
  348. }
  349. platform_rows.append(budget_row)
  350. if platform == "default":
  351. legacy = next((item for item in policy.get("edge_budgets", []) if item.get("edge_id") == edge_id), None)
  352. if legacy is None:
  353. legacy = {"edge_id": edge_id}
  354. policy.setdefault("edge_budgets", []).append(legacy)
  355. for field in ["max_total_actions", "max_per_content", "max_works_per_author", "max_pages"]:
  356. if budget_row[field] is not None:
  357. legacy[field] = budget_row[field]
  358. policy["platform_edge_budgets"] = platform_rows
  359. return policy
  360. def _nullable_int(value: Any) -> int | None:
  361. if value is None or value == "":
  362. return None
  363. return int(round(float(value)))
  364. def _number(value: Any) -> int | float:
  365. parsed = float(value)
  366. return int(parsed) if parsed.is_integer() else parsed
  367. def _get_path(obj: dict[str, Any], dotted: str) -> tuple[bool, Any]:
  368. cursor: Any = obj
  369. for part in dotted.split("."):
  370. if not isinstance(cursor, dict) or part not in cursor:
  371. return False, None
  372. cursor = cursor[part]
  373. return True, cursor
  374. def _cell_value(value: Any) -> Any:
  375. if isinstance(value, bool):
  376. return value
  377. if isinstance(value, (list, dict)):
  378. return json.dumps(value, ensure_ascii=False)
  379. return value
  380. def sync_workbook(xlsx: Path, base: dict[str, Any], specs: list[SheetSpec]) -> int:
  381. """JSON -> Excel: write base values into the mapped Excel cells (round-trip safe).
  382. Used to reconcile a drifted Excel to the authoritative JSON (JSON wins). Only
  383. mapped value cells are overwritten; id / FK / 注释 / unmapped columns are left.
  384. """
  385. wb = _load_workbook(xlsx, read_only=False)
  386. changed = 0
  387. for spec in specs:
  388. ws = wb[spec.sheet]
  389. headers = [c.value for c in ws[1]]
  390. col_idx = {h: i + 1 for i, h in enumerate(headers) if h is not None}
  391. if spec.child_path:
  392. index = _nested_targets(base, spec)
  393. else:
  394. index = {obj.get(spec.id_json): obj for obj in base.get(spec.section, [])}
  395. for r in range(2, ws.max_row + 1):
  396. first = ws.cell(row=r, column=1).value
  397. if first is not None and str(first).startswith(SENTINEL):
  398. continue
  399. row = {h: ws.cell(row=r, column=col_idx[h]).value for h in col_idx}
  400. if spec.child_path:
  401. obj = index.get((row.get(spec.parent_fk), row.get(spec.id_excel)))
  402. else:
  403. obj = index.get(row.get(spec.id_excel))
  404. if obj is None:
  405. continue
  406. for col, ci in col_idx.items():
  407. if col in {SKIP_COLUMN, spec.id_excel, spec.parent_fk}:
  408. continue
  409. exists, value = _get_path(obj, spec.renames.get(col, col))
  410. if not exists:
  411. continue
  412. new = _cell_value(value)
  413. if ws.cell(row=r, column=ci).value != new:
  414. ws.cell(row=r, column=ci, value=new)
  415. changed += 1
  416. wb.save(xlsx)
  417. return changed
  418. def build() -> dict[str, dict[str, Any]]:
  419. """Return {json_path: regenerated_obj} for all runtime config files."""
  420. rule_pack_base, _ = config_store.load_json(RULE_PACK_JSON)
  421. walk_base, _ = config_store.load_json(WALK_JSON)
  422. score_threshold_base, _ = config_store.load_json(SCORE_THRESHOLDS_JSON)
  423. walk_policy_base, _ = config_store.load_json(WALK_POLICY_JSON)
  424. rule_wb = _load_workbook(RULE_PACK_XLSX)
  425. walk_wb = _load_workbook(WALK_XLSX)
  426. rule_pack = overlay_workbook(RULE_PACK_XLSX, rule_pack_base, RULE_PACK_SPECS)
  427. _apply_score_weight_profiles(rule_pack, _sheet_rows(rule_wb, "score_weight_profiles"))
  428. score_thresholds = _build_score_thresholds(score_threshold_base, _sheet_rows(rule_wb, "score_threshold_profiles"))
  429. walk_strategy = overlay_workbook(WALK_XLSX, walk_base, WALK_SPECS)
  430. walk_strategy.pop("walk_edge_catalog", None)
  431. walk_policy = _build_walk_policy(walk_policy_base, _sheet_rows(walk_wb, "walk_policy_budget_matrix"))
  432. built = {
  433. str(RULE_PACK_JSON): rule_pack,
  434. str(WALK_JSON): walk_strategy,
  435. str(SCORE_THRESHOLDS_JSON): score_thresholds,
  436. str(WALK_POLICY_JSON): walk_policy,
  437. }
  438. for platform, profile in _build_platform_profiles(_sheet_rows(walk_wb, "platform_edge_matrix")).items():
  439. built[str(PROFILE_DIR / f"{platform}.json")] = profile
  440. return built
  441. def governance_report() -> list[dict[str, Any]]:
  442. """Summarize governed sheets so no-op sheets and runtime rows are visible in gates."""
  443. report: list[dict[str, Any]] = []
  444. for xlsx, sheets in [
  445. (RULE_PACK_XLSX, GOVERNED_RULE_PACK_SHEETS),
  446. (WALK_XLSX, GOVERNED_WALK_SHEETS),
  447. ]:
  448. wb = _load_workbook(xlsx)
  449. for sheet, mode in sheets.items():
  450. rows = _sheet_rows(wb, sheet)
  451. runtime_count = sum(1 for row in rows if _runtime_row_enabled(row, sheet))
  452. report.append(
  453. {
  454. "workbook": str(xlsx.relative_to(ROOT)),
  455. "sheet": sheet,
  456. "mode": mode,
  457. "row_count": len(rows),
  458. "runtime_row_count": runtime_count,
  459. }
  460. )
  461. return report
  462. def _first_diff(expected: str, actual: str) -> str:
  463. e, a = expected.splitlines(), actual.splitlines()
  464. for i in range(max(len(e), len(a))):
  465. el = e[i] if i < len(e) else "<missing>"
  466. al = a[i] if i < len(a) else "<missing>"
  467. if el != al:
  468. return f"line {i + 1}:\n json: {el}\n excel: {al}"
  469. return "<no line diff>"
  470. def main() -> int:
  471. args = _parse_args()
  472. if args.sync_excel:
  473. rule_pack_base, _ = config_store.load_json(RULE_PACK_JSON)
  474. walk_base, _ = config_store.load_json(WALK_JSON)
  475. n1 = sync_workbook(RULE_PACK_XLSX, rule_pack_base, RULE_PACK_SPECS)
  476. n2 = sync_workbook(WALK_XLSX, walk_base, WALK_SPECS)
  477. print(json.dumps({"status": "synced", "cells_changed": {"rule_pack": n1, "walk": n2}}, ensure_ascii=False, indent=2))
  478. return 0
  479. built = build()
  480. findings = []
  481. for path_str, obj in built.items():
  482. path = Path(path_str)
  483. generated = canonical_dumps(obj)
  484. current = path.read_text(encoding="utf-8")
  485. if args.write:
  486. path.write_text(generated, encoding="utf-8")
  487. findings.append({"config_path": str(path.relative_to(ROOT)), "written": True})
  488. else:
  489. ok = generated == current
  490. entry = {"config_path": str(path.relative_to(ROOT)), "byte_equal": ok}
  491. if not ok:
  492. entry["first_diff"] = _first_diff(current, generated)
  493. findings.append(entry)
  494. status = "written" if args.write else ("pass" if all(f.get("byte_equal") for f in findings) else "fail")
  495. print(json.dumps({"status": status, "findings": findings, "governance": governance_report()}, ensure_ascii=False, indent=2))
  496. return 0 if status in {"written", "pass"} else 1
  497. def _parse_args() -> argparse.Namespace:
  498. parser = argparse.ArgumentParser(description=__doc__)
  499. parser.add_argument("--check", action="store_true", help="byte-equal check (default)")
  500. parser.add_argument("--write", action="store_true", help="regenerate JSON from Excel instead of checking")
  501. parser.add_argument("--sync-excel", action="store_true", help="JSON wins: write JSON values back into Excel (one-time reconcile)")
  502. return parser.parse_args()
  503. if __name__ == "__main__":
  504. sys.exit(main())