build_config_from_excel.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. """Excel -> runtime JSON converter (V2-M1C), byte-equal via overlay-onto-base.
  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. python scripts/build_config_from_excel.py --check # byte-equal? exit 1 on drift
  10. python scripts/build_config_from_excel.py --write # regenerate JSON from Excel
  11. This handles VALUE edits (thresholds, scores, gate values, flags). Structural
  12. changes (adding/removing rows) need a base update — out of V2-M1 scope.
  13. """
  14. from __future__ import annotations
  15. import argparse
  16. import json
  17. import sys
  18. from pathlib import Path
  19. from typing import Any
  20. ROOT = Path(__file__).resolve().parents[1]
  21. if str(ROOT) not in sys.path:
  22. sys.path.insert(0, str(ROOT))
  23. from openpyxl import load_workbook
  24. from content_agent.integrations import config_store
  25. from scripts.check_config_json_canonical import canonical_dumps
  26. RULE_PACK_XLSX = ROOT / "tech_documents/规则包映射/规则包映射配置表.xlsx"
  27. WALK_XLSX = ROOT / "tech_documents/游走策略/游走策略配置表.xlsx"
  28. RULE_PACK_JSON = ROOT / "product_documents/规则包/douyin_rule_packs.v1.json"
  29. WALK_JSON = ROOT / "product_documents/抖音游走策略/douyin_walk_strategy.v1.json"
  30. SENTINEL = "备注" # row 2 of every sheet; skipped
  31. SKIP_COLUMN = "注释" # human annotation column with no JSON home
  32. # A SheetSpec maps one Excel sheet onto a JSON section by overlay.
  33. # top-level: base[section] is a list of objects indexed by id_json (== row[id_excel]).
  34. # nested: base["rule_packs"][*][child_path...] grouped by parent_fk (rule_pack_id).
  35. class SheetSpec:
  36. def __init__(self, sheet, *, section, id_excel, id_json=None, renames=None, parent_fk=None, child_path=None):
  37. self.sheet = sheet
  38. self.section = section # top-level JSON key (for nested: "rule_packs")
  39. self.id_excel = id_excel
  40. self.id_json = id_json or id_excel
  41. self.renames = renames or {} # excel column -> dotted base path
  42. self.parent_fk = parent_fk # excel column holding rule_pack_id (nested only)
  43. self.child_path = child_path # dotted path within a rule_pack (nested only)
  44. _WHEN = {"field_path": "when.field", "operator": "when.op", "expected_value": "when.value"}
  45. RULE_PACK_SPECS = [
  46. SheetSpec("rule_pack_dispatch", section="rule_pack_dispatch", id_excel="dispatch_id"),
  47. SheetSpec("decision_reason_codes", section="decision_reason_codes", id_excel="decision_reason_code"),
  48. SheetSpec("effect_status_mapping", section="effect_status_mapping", id_excel="mapping_id"),
  49. SheetSpec("query_effect_aggregation", section="query_effect_aggregation", id_excel="aggregation_id"),
  50. SheetSpec("hard_gate_rules", section="rule_packs", child_path="hard_gates", parent_fk="rule_pack_id",
  51. id_excel="gate_id", renames={"gate_label": "label", **_WHEN}),
  52. SheetSpec("scorecard_dimensions", section="rule_packs", child_path="scorecard.dimensions", parent_fk="rule_pack_id",
  53. id_excel="dimension_key", id_json="key", renames={"dimension_label": "label"}),
  54. SheetSpec("scorecard_scoring_rules", section="rule_packs", child_path="scorecard.scoring_rules", parent_fk="rule_pack_id",
  55. id_excel="scoring_rule_id"),
  56. SheetSpec("threshold_actions", section="rule_packs", child_path="thresholds", parent_fk="rule_pack_id",
  57. id_excel="decision_reason_code"),
  58. ]
  59. # Walk sheets mirror JSON 1:1 (identity columns; only 注释 skipped).
  60. # V3 清理: 13 段收窄到 3 个仍被消费的段(其余 10 段已被 walk_graph+walk_policy 取代,
  61. # JSON 段与对应 Excel sheet 一并删除)。
  62. _WALK = {
  63. "walk_edge_catalog": "edge_id",
  64. "walk_rule_pack_binding": "binding_id",
  65. "v4_walk_gate": "gate_id",
  66. "walk_fact_contract": "runtime_file",
  67. }
  68. WALK_SPECS = [SheetSpec(sheet, section=sheet, id_excel=idc) for sheet, idc in _WALK.items()]
  69. def _read_rows(ws) -> list[dict[str, Any]]:
  70. headers = [c.value for c in ws[1]]
  71. rows = []
  72. for excel_row in ws.iter_rows(min_row=2, values_only=True):
  73. if excel_row and str(excel_row[0] or "").startswith(SENTINEL):
  74. continue # sentinel comment row
  75. rows.append({h: v for h, v in zip(headers, excel_row) if h is not None})
  76. return rows
  77. def _coerce(cell: Any, base_value: Any) -> Any:
  78. if isinstance(base_value, bool):
  79. return str(cell).strip().lower() in {"true", "1", "yes"} if not isinstance(cell, bool) else cell
  80. if isinstance(base_value, int):
  81. return base_value if cell is None or cell == "" else int(round(float(cell)))
  82. if isinstance(base_value, float):
  83. return base_value if cell is None or cell == "" else float(cell)
  84. if isinstance(base_value, (list, dict)):
  85. if isinstance(cell, str) and cell.strip():
  86. try:
  87. return json.loads(cell)
  88. except json.JSONDecodeError:
  89. return base_value # non-JSON cell (e.g. comma-joined) -> keep base
  90. return base_value if cell is None or cell == "" else cell
  91. if base_value is None:
  92. return None if cell is None or cell == "" else cell
  93. return base_value if cell is None else str(cell)
  94. def _set_if_exists(obj: dict[str, Any], dotted: str, cell: Any) -> None:
  95. parts = dotted.split(".")
  96. cursor = obj
  97. for part in parts[:-1]:
  98. if not isinstance(cursor, dict) or part not in cursor:
  99. return
  100. cursor = cursor[part]
  101. leaf = parts[-1]
  102. if isinstance(cursor, dict) and leaf in cursor:
  103. cursor[leaf] = _coerce(cell, cursor[leaf])
  104. def _overlay_obj(obj: dict[str, Any], row: dict[str, Any], spec: SheetSpec) -> None:
  105. for column, value in row.items():
  106. if column == SKIP_COLUMN or column == spec.id_excel or column == spec.parent_fk:
  107. continue
  108. path = spec.renames.get(column, column)
  109. _set_if_exists(obj, path, value)
  110. def _nested_targets(base: dict[str, Any], spec: SheetSpec) -> dict[tuple, dict[str, Any]]:
  111. index: dict[tuple, dict[str, Any]] = {}
  112. for pack in base.get("rule_packs", []):
  113. cursor: Any = pack
  114. for part in spec.child_path.split("."):
  115. cursor = cursor.get(part) if isinstance(cursor, dict) else None
  116. for child in cursor or []:
  117. index[(pack.get("rule_pack_id"), child.get(spec.id_json))] = child
  118. return index
  119. def overlay_workbook(xlsx: Path, base: dict[str, Any], specs: list[SheetSpec]) -> dict[str, Any]:
  120. wb = load_workbook(xlsx, data_only=True, read_only=True)
  121. for spec in specs:
  122. rows = _read_rows(wb[spec.sheet])
  123. if spec.child_path:
  124. index = _nested_targets(base, spec)
  125. for row in rows:
  126. obj = index.get((row.get(spec.parent_fk), row.get(spec.id_excel)))
  127. if obj is not None:
  128. _overlay_obj(obj, row, spec)
  129. else:
  130. index = {obj.get(spec.id_json): obj for obj in base.get(spec.section, [])}
  131. for row in rows:
  132. obj = index.get(row.get(spec.id_excel))
  133. if obj is not None:
  134. _overlay_obj(obj, row, spec)
  135. return base
  136. def _get_path(obj: dict[str, Any], dotted: str) -> tuple[bool, Any]:
  137. cursor: Any = obj
  138. for part in dotted.split("."):
  139. if not isinstance(cursor, dict) or part not in cursor:
  140. return False, None
  141. cursor = cursor[part]
  142. return True, cursor
  143. def _cell_value(value: Any) -> Any:
  144. if isinstance(value, bool):
  145. return value
  146. if isinstance(value, (list, dict)):
  147. return json.dumps(value, ensure_ascii=False)
  148. return value
  149. def sync_workbook(xlsx: Path, base: dict[str, Any], specs: list[SheetSpec]) -> int:
  150. """JSON -> Excel: write base values into the mapped Excel cells (round-trip safe).
  151. Used to reconcile a drifted Excel to the authoritative JSON (JSON wins). Only
  152. mapped value cells are overwritten; id / FK / 注释 / unmapped columns are left.
  153. """
  154. wb = load_workbook(xlsx)
  155. changed = 0
  156. for spec in specs:
  157. ws = wb[spec.sheet]
  158. headers = [c.value for c in ws[1]]
  159. col_idx = {h: i + 1 for i, h in enumerate(headers) if h is not None}
  160. if spec.child_path:
  161. index = _nested_targets(base, spec)
  162. else:
  163. index = {obj.get(spec.id_json): obj for obj in base.get(spec.section, [])}
  164. for r in range(2, ws.max_row + 1):
  165. first = ws.cell(row=r, column=1).value
  166. if first is not None and str(first).startswith(SENTINEL):
  167. continue
  168. row = {h: ws.cell(row=r, column=col_idx[h]).value for h in col_idx}
  169. if spec.child_path:
  170. obj = index.get((row.get(spec.parent_fk), row.get(spec.id_excel)))
  171. else:
  172. obj = index.get(row.get(spec.id_excel))
  173. if obj is None:
  174. continue
  175. for col, ci in col_idx.items():
  176. if col in {SKIP_COLUMN, spec.id_excel, spec.parent_fk}:
  177. continue
  178. exists, value = _get_path(obj, spec.renames.get(col, col))
  179. if not exists:
  180. continue
  181. new = _cell_value(value)
  182. if ws.cell(row=r, column=ci).value != new:
  183. ws.cell(row=r, column=ci, value=new)
  184. changed += 1
  185. wb.save(xlsx)
  186. return changed
  187. def build() -> dict[str, dict[str, Any]]:
  188. """Return {json_path: regenerated_obj} for both config files."""
  189. rule_pack_base, _ = config_store.load_json(RULE_PACK_JSON)
  190. walk_base, _ = config_store.load_json(WALK_JSON)
  191. return {
  192. str(RULE_PACK_JSON): overlay_workbook(RULE_PACK_XLSX, rule_pack_base, RULE_PACK_SPECS),
  193. str(WALK_JSON): overlay_workbook(WALK_XLSX, walk_base, WALK_SPECS),
  194. }
  195. def _first_diff(expected: str, actual: str) -> str:
  196. e, a = expected.splitlines(), actual.splitlines()
  197. for i in range(max(len(e), len(a))):
  198. el = e[i] if i < len(e) else "<missing>"
  199. al = a[i] if i < len(a) else "<missing>"
  200. if el != al:
  201. return f"line {i + 1}:\n json: {el}\n excel: {al}"
  202. return "<no line diff>"
  203. def main() -> int:
  204. args = _parse_args()
  205. if args.sync_excel:
  206. rule_pack_base, _ = config_store.load_json(RULE_PACK_JSON)
  207. walk_base, _ = config_store.load_json(WALK_JSON)
  208. n1 = sync_workbook(RULE_PACK_XLSX, rule_pack_base, RULE_PACK_SPECS)
  209. n2 = sync_workbook(WALK_XLSX, walk_base, WALK_SPECS)
  210. print(json.dumps({"status": "synced", "cells_changed": {"rule_pack": n1, "walk": n2}}, ensure_ascii=False, indent=2))
  211. return 0
  212. built = build()
  213. findings = []
  214. for path_str, obj in built.items():
  215. path = Path(path_str)
  216. generated = canonical_dumps(obj)
  217. current = path.read_text(encoding="utf-8")
  218. if args.write:
  219. path.write_text(generated, encoding="utf-8")
  220. findings.append({"config_path": str(path.relative_to(ROOT)), "written": True})
  221. else:
  222. ok = generated == current
  223. entry = {"config_path": str(path.relative_to(ROOT)), "byte_equal": ok}
  224. if not ok:
  225. entry["first_diff"] = _first_diff(current, generated)
  226. findings.append(entry)
  227. status = "written" if args.write else ("pass" if all(f.get("byte_equal") for f in findings) else "fail")
  228. print(json.dumps({"status": status, "findings": findings}, ensure_ascii=False, indent=2))
  229. return 0 if status in {"written", "pass"} else 1
  230. def _parse_args() -> argparse.Namespace:
  231. parser = argparse.ArgumentParser(description=__doc__)
  232. parser.add_argument("--check", action="store_true", help="byte-equal check (default)")
  233. parser.add_argument("--write", action="store_true", help="regenerate JSON from Excel instead of checking")
  234. parser.add_argument("--sync-excel", action="store_true", help="JSON wins: write JSON values back into Excel (one-time reconcile)")
  235. return parser.parse_args()
  236. if __name__ == "__main__":
  237. sys.exit(main())