mattrguerra/meridian-skillsmainPrivate on GitHub, published here in full

build_sop.py

Language Python16.0 KB441 lines

skills/sop-author/scripts/build_sop.py

  1. #!/usr/bin/env python3
  2. """
  3. Meridian SOP Builder
  4. Takes a Python dict describing an SOP and produces a styled .docx file matching
  5. the Meridian Display Co. company SOP template.
  6. Usage:
  7. from build_sop import build_sop
  8. build_sop({
  9. "title": "Mockup File Naming Convention",
  10. "owner": "Matt Guerra",
  11. "initials": "MG",
  12. "category": "Operations",
  13. "effective_date": "04/2026",
  14. "folder_context": "07 - Mockup Artwork Files",
  15. "sections": [
  16. {"type": "purpose", "text": "..."},
  17. {"type": "scope", "text": "..."},
  18. {"type": "h1", "text": "Folder Structure"},
  19. {"type": "body", "text": "..."},
  20. {"type": "code", "text": "07 - Mockup Artwork Files\n├── ..."},
  21. {"type": "accent_subhead", "text": "Versioning"},
  22. {"type": "bullet", "text": "Start at v1 for every new job."},
  23. {"type": "components_table", "rows": [["Component", "Value", "Description"], ["v[#]", "v1, v2...", "Version number"]]},
  24. {"type": "examples_table", "rows": [["Folder", "Sub DIR", "File Name(s)"], ["NORTHWIND ACADEMY", "H4A", "Mockup_v1_04/2026_RG"]]},
  25. {"type": "note", "text": "Note: This applies to ... only."},
  26. {"type": "quick_ref", "text": "Mockup Format:\nMockup_v[#]_..."},
  27. ],
  28. "output_path": "/path/to/output.docx",
  29. })
  30. Run as a CLI:
  31. python3 build_sop.py --validate /path/to/file.docx
  32. """
  33. import argparse
  34. import os
  35. import re
  36. import shutil
  37. import sys
  38. from pathlib import Path
  39. from docx import Document
  40. from docx.shared import Pt, Inches, RGBColor, Emu
  41. from docx.enum.text import WD_ALIGN_PARAGRAPH
  42. from docx.oxml.ns import qn
  43. from docx.oxml import OxmlElement
  44. MERIDIAN_NAVY = RGBColor(0x1F, 0x3A, 0x5F)
  45. WARN_RED = RGBColor(0xFF, 0x00, 0x00)
  46. BODY_BLACK = RGBColor(0x00, 0x00, 0x00)
  47. GRAY = RGBColor(0x43, 0x43, 0x43)
  48. BODY_FONT = "Arial"
  49. CODE_FONT = "Courier New"
  50. TEMPLATE_PATH = Path(__file__).parent.parent / "assets" / "template.docx"
  51. def _set_cell_background(cell, hex_color):
  52. tc_pr = cell._tc.get_or_add_tcPr()
  53. shd = OxmlElement('w:shd')
  54. shd.set(qn('w:fill'), hex_color)
  55. shd.set(qn('w:val'), 'clear')
  56. tc_pr.append(shd)
  57. def _apply_table_borders(table, color="B0B0B0", size="4"):
  58. """Apply thin gray borders to every cell of a table via raw XML."""
  59. tbl_pr = table._element.find(qn('w:tblPr'))
  60. if tbl_pr is None:
  61. tbl_pr = OxmlElement('w:tblPr')
  62. table._element.insert(0, tbl_pr)
  63. tbl_borders = OxmlElement('w:tblBorders')
  64. for edge in ('top', 'left', 'bottom', 'right', 'insideH', 'insideV'):
  65. b = OxmlElement(f'w:{edge}')
  66. b.set(qn('w:val'), 'single')
  67. b.set(qn('w:sz'), size)
  68. b.set(qn('w:color'), color)
  69. tbl_borders.append(b)
  70. # Remove any existing borders block
  71. existing = tbl_pr.find(qn('w:tblBorders'))
  72. if existing is not None:
  73. tbl_pr.remove(existing)
  74. tbl_pr.append(tbl_borders)
  75. def _style_run(run, font=BODY_FONT, size=11, bold=False, italic=False, color=BODY_BLACK):
  76. run.font.name = font
  77. run.font.size = Pt(size)
  78. run.bold = bold
  79. run.italic = italic
  80. if color is not None:
  81. run.font.color.rgb = color
  82. def _add_styled_para(doc, text, *, style=None, font=BODY_FONT, size=11, bold=False,
  83. italic=False, color=BODY_BLACK, alignment=None):
  84. p = doc.add_paragraph(style=style) if style else doc.add_paragraph()
  85. if alignment is not None:
  86. p.alignment = alignment
  87. run = p.add_run(text)
  88. _style_run(run, font=font, size=size, bold=bold, italic=italic, color=color)
  89. return p
  90. def _filename(title, mm_yyyy, initials, draft=False, template=False):
  91. """Build the Meridian SOP filename. mm_yyyy uses '/' inside; filename uses '-'."""
  92. safe_title = title.strip()
  93. # Filename uses dash between month and year
  94. mm_dash_yyyy = mm_yyyy.replace("/", "-")
  95. name = f"SOP_{safe_title}_{mm_dash_yyyy}_{initials}"
  96. if template:
  97. name = f"SOP_{safe_title}_TEMPLATE_{mm_dash_yyyy}_{initials}"
  98. if draft:
  99. name = f"DRAFT_{name}"
  100. return f"{name}.docx"
  101. def _build_title_bar(doc, title, folder_context):
  102. """1-cell table at the top: 'SOP | <Title>' on line 1, folder context on line 2."""
  103. table = doc.add_table(rows=1, cols=1)
  104. table.autofit = True
  105. cell = table.cell(0, 0)
  106. _set_cell_background(cell, "EAF2F8") # very light blue tint
  107. # Wipe the default paragraph
  108. cell.text = ""
  109. p1 = cell.paragraphs[0]
  110. r1 = p1.add_run(f"SOP | {title}")
  111. _style_run(r1, font=BODY_FONT, size=16, bold=True, color=MERIDIAN_NAVY)
  112. if folder_context:
  113. p2 = cell.add_paragraph()
  114. r2 = p2.add_run(folder_context)
  115. _style_run(r2, font=CODE_FONT, size=10, bold=False, color=MERIDIAN_NAVY)
  116. return table
  117. def _build_metadata_table(doc, filename, category, owner, effective_date):
  118. """4-column header metadata table."""
  119. table = doc.add_table(rows=2, cols=4)
  120. _apply_table_borders(table)
  121. headers = ["File Name", "Category", "Owner", "Effective Date"]
  122. values = [filename.replace(".docx", ""), category, owner, effective_date]
  123. for ci, (h, v) in enumerate(zip(headers, values)):
  124. hcell = table.cell(0, ci)
  125. hcell.text = ""
  126. hp = hcell.paragraphs[0]
  127. hr = hp.add_run(h)
  128. _style_run(hr, font=BODY_FONT, size=11, bold=True, color=BODY_BLACK)
  129. _set_cell_background(hcell, "D6EAF8")
  130. vcell = table.cell(1, ci)
  131. vcell.text = ""
  132. vp = vcell.paragraphs[0]
  133. vr = vp.add_run(v)
  134. _style_run(vr, font=BODY_FONT, size=11, bold=False, color=BODY_BLACK)
  135. return table
  136. def _add_blank(doc):
  137. doc.add_paragraph()
  138. def _render_section(doc, sec):
  139. t = sec.get("type")
  140. text = sec.get("text", "")
  141. if t == "purpose":
  142. h = doc.add_heading("Purpose", level=1)
  143. _add_styled_para(doc, text, font=BODY_FONT, size=11)
  144. elif t == "scope":
  145. h = doc.add_heading("Scope & Responsibility", level=1)
  146. _add_styled_para(doc, text, font=BODY_FONT, size=11)
  147. elif t == "h1":
  148. doc.add_heading(text, level=1)
  149. elif t == "h2":
  150. doc.add_heading(text, level=2)
  151. elif t == "h3":
  152. doc.add_heading(text, level=3)
  153. elif t == "body":
  154. _add_styled_para(doc, text, font=BODY_FONT, size=11)
  155. elif t == "accent_subhead":
  156. _add_styled_para(doc, text, font=BODY_FONT, size=12, bold=True, color=MERIDIAN_NAVY)
  157. elif t == "bullet":
  158. p = doc.add_paragraph()
  159. p.paragraph_format.left_indent = Inches(0.25)
  160. r = p.add_run(f"• {text}")
  161. _style_run(r, font=BODY_FONT, size=11)
  162. elif t == "numbered":
  163. # Number gets auto-incremented within a 'numbered' run via state attached to the doc
  164. n = getattr(doc, "_sop_num_counter", 0) + 1
  165. setattr(doc, "_sop_num_counter", n)
  166. p = doc.add_paragraph()
  167. p.paragraph_format.left_indent = Inches(0.25)
  168. r = p.add_run(f"{n}. {text}")
  169. _style_run(r, font=BODY_FONT, size=11)
  170. elif t == "code":
  171. # Multi-line code or path block, monospace blue
  172. for line in text.split("\n"):
  173. p = doc.add_paragraph()
  174. r = p.add_run(line if line else " ")
  175. _style_run(r, font=CODE_FONT, size=10, color=MERIDIAN_NAVY)
  176. elif t == "format_spec":
  177. # 1-cell table with the format pattern, monospace blue bold
  178. table = doc.add_table(rows=1, cols=1)
  179. _apply_table_borders(table)
  180. cell = table.cell(0, 0)
  181. _set_cell_background(cell, "EAF2F8")
  182. cell.text = ""
  183. for i, line in enumerate(text.split("\n")):
  184. if i == 0:
  185. p = cell.paragraphs[0]
  186. else:
  187. p = cell.add_paragraph()
  188. r = p.add_run(line)
  189. _style_run(r, font=CODE_FONT, size=11, bold=True, color=MERIDIAN_NAVY)
  190. elif t == "components_table":
  191. rows = sec["rows"]
  192. table = doc.add_table(rows=len(rows), cols=len(rows[0]))
  193. _apply_table_borders(table)
  194. for ri, row in enumerate(rows):
  195. for ci, val in enumerate(row):
  196. cell = table.cell(ri, ci)
  197. cell.text = ""
  198. p = cell.paragraphs[0]
  199. r = p.add_run(str(val))
  200. is_header = (ri == 0)
  201. if is_header:
  202. _style_run(r, font=BODY_FONT, size=11, bold=True, color=BODY_BLACK)
  203. _set_cell_background(cell, "D6EAF8")
  204. else:
  205. # First column often holds the code-style identifier; render second column as code
  206. if ci == 1:
  207. _style_run(r, font=CODE_FONT, size=10, color=MERIDIAN_NAVY)
  208. else:
  209. _style_run(r, font=BODY_FONT, size=11, color=BODY_BLACK)
  210. elif t == "examples_table":
  211. rows = sec["rows"]
  212. table = doc.add_table(rows=len(rows), cols=len(rows[0]))
  213. _apply_table_borders(table)
  214. for ri, row in enumerate(rows):
  215. for ci, val in enumerate(row):
  216. cell = table.cell(ri, ci)
  217. cell.text = ""
  218. p = cell.paragraphs[0]
  219. r = p.add_run(str(val))
  220. is_header = (ri == 0)
  221. last_col = (ci == len(row) - 1)
  222. if is_header:
  223. _style_run(r, font=BODY_FONT, size=11, bold=True, color=BODY_BLACK)
  224. _set_cell_background(cell, "D6EAF8")
  225. else:
  226. if last_col:
  227. _style_run(r, font=CODE_FONT, size=10, color=MERIDIAN_NAVY)
  228. else:
  229. _style_run(r, font=BODY_FONT, size=11, color=BODY_BLACK)
  230. elif t == "note":
  231. # Italic gray note, full-width 1-cell table
  232. table = doc.add_table(rows=1, cols=1)
  233. cell = table.cell(0, 0)
  234. _set_cell_background(cell, "FCF3CF") # light yellow note tint
  235. cell.text = ""
  236. p = cell.paragraphs[0]
  237. r = p.add_run(text)
  238. _style_run(r, font=BODY_FONT, size=10, italic=True, color=GRAY)
  239. elif t == "warning":
  240. # Red bold callout
  241. _add_styled_para(doc, text, font=BODY_FONT, size=12, bold=True, color=WARN_RED)
  242. elif t == "quick_ref":
  243. # 1-cell table styled like a reference card. Monospace, multi-line.
  244. table = doc.add_table(rows=1, cols=1)
  245. cell = table.cell(0, 0)
  246. _set_cell_background(cell, "EAF2F8")
  247. cell.text = ""
  248. for i, line in enumerate(text.split("\n")):
  249. if i == 0:
  250. p = cell.paragraphs[0]
  251. else:
  252. p = cell.add_paragraph()
  253. r = p.add_run(line if line else " ")
  254. _style_run(r, font=CODE_FONT, size=10, color=MERIDIAN_NAVY)
  255. elif t == "blank":
  256. _add_blank(doc)
  257. else:
  258. # Unknown type: render as plain body but flag in console
  259. print(f"WARN: unknown section type '{t}', rendering as body", file=sys.stderr)
  260. _add_styled_para(doc, text, font=BODY_FONT, size=11)
  261. def build_sop(spec):
  262. """
  263. Build a Meridian SOP .docx from a spec dict.
  264. Required keys:
  265. title (str) : the process title
  266. owner (str) : full name of the owner
  267. initials (str) : author initials, e.g. "MG"
  268. category (str) : Operations / Design / etc.
  269. effective_date (str) : "MM/YYYY"
  270. folder_context (str) : the Shared Drive path / scope, e.g. "00 - SOPs > 02 - Operations"
  271. sections (list[dict]) : the body, see section schemas above
  272. output_path (str|Path) : where to save the .docx
  273. Optional keys:
  274. draft (bool) : prefix filename with DRAFT_
  275. template (bool) : insert _TEMPLATE before the date
  276. page_margin_inches (float) : defaults to 1.0
  277. """
  278. if TEMPLATE_PATH.exists():
  279. # Copy template to a scratch path, then open
  280. doc = Document(str(TEMPLATE_PATH))
  281. # Wipe everything in the template body: we want the styles, not the content
  282. for element in list(doc.element.body):
  283. doc.element.body.remove(element)
  284. # Re-add a sectPr at the end (required by Word)
  285. from docx.oxml import OxmlElement
  286. sectPr = OxmlElement("w:sectPr")
  287. doc.element.body.append(sectPr)
  288. else:
  289. doc = Document()
  290. margin = Inches(spec.get("page_margin_inches", 1.0))
  291. for section in doc.sections:
  292. section.top_margin = margin
  293. section.bottom_margin = margin
  294. section.left_margin = margin
  295. section.right_margin = margin
  296. title = spec["title"]
  297. owner = spec["owner"]
  298. initials = spec["initials"]
  299. category = spec["category"]
  300. effective_date = spec["effective_date"] # MM/YYYY
  301. folder_context = spec.get("folder_context", "")
  302. sections = spec.get("sections", [])
  303. filename = _filename(
  304. title, effective_date, initials,
  305. draft=spec.get("draft", False),
  306. template=spec.get("template", False),
  307. )
  308. _build_title_bar(doc, title, folder_context)
  309. _add_blank(doc)
  310. _build_metadata_table(doc, filename, category, owner, effective_date)
  311. _add_blank(doc)
  312. for sec in sections:
  313. _render_section(doc, sec)
  314. out = spec.get("output_path")
  315. if out is None:
  316. out = filename
  317. out_path = Path(out)
  318. if out_path.is_dir():
  319. out_path = out_path / filename
  320. elif not str(out_path).endswith(".docx"):
  321. out_path = Path(str(out_path) + ".docx")
  322. # If the user passed a directory or generic name, override with the canonical filename
  323. if out_path.name != filename and spec.get("force_filename", True):
  324. out_path = out_path.parent / filename
  325. doc.save(str(out_path))
  326. return str(out_path)
  327. def validate(path):
  328. """Quick structural check of a Meridian SOP."""
  329. doc = Document(path)
  330. findings = []
  331. # Filename check
  332. fn = Path(path).name
  333. if not re.match(r"^(DRAFT_)?SOP_.+_(TEMPLATE_)?\d{2}-\d{4}_[A-Z]{2,3}\.docx$", fn):
  334. findings.append(f"FAIL: filename '{fn}' does not match SOP_<Title>_<MM-YYYY>_<Initials>.docx pattern")
  335. # Title bar (first table, 1 cell, contains 'SOP |')
  336. if not doc.tables:
  337. findings.append("FAIL: no tables found, title bar missing")
  338. else:
  339. first = doc.tables[0]
  340. if first.rows[0].cells[0].text.startswith("SOP |") or first.rows[0].cells[0].text.startswith("SOP|"):
  341. findings.append("PASS: title bar present")
  342. else:
  343. findings.append(f"FAIL: first table is not a title bar (content: {first.rows[0].cells[0].text[:60]!r})")
  344. # Metadata table (4-col, headers File Name / Category / Owner / Effective Date)
  345. meta_ok = False
  346. for table in doc.tables[:3]:
  347. if len(table.columns) == 4 and len(table.rows) >= 2:
  348. headers = [c.text.strip() for c in table.rows[0].cells]
  349. if headers == ["File Name", "Category", "Owner", "Effective Date"]:
  350. meta_ok = True
  351. break
  352. findings.append("PASS: metadata table present" if meta_ok else "FAIL: metadata table missing or wrong shape")
  353. # Required Heading 1 sections
  354. h1_texts = [p.text for p in doc.paragraphs if p.style and p.style.name == "Heading 1"]
  355. for required in ("Purpose", "Scope & Responsibility"):
  356. if any(required in t for t in h1_texts):
  357. findings.append(f"PASS: '{required}' section present")
  358. else:
  359. findings.append(f"FAIL: '{required}' section missing")
  360. # Quick Reference Card
  361. if any("Quick Reference" in t for t in h1_texts):
  362. findings.append("PASS: Quick Reference Card present")
  363. else:
  364. findings.append("WARN: Quick Reference Card missing (recommended but not strictly required)")
  365. return findings
  366. def _cli():
  367. parser = argparse.ArgumentParser(description="Meridian SOP builder")
  368. parser.add_argument("--validate", metavar="FILE", help="Validate an existing .docx against Meridian SOP shape")
  369. args = parser.parse_args()
  370. if args.validate:
  371. findings = validate(args.validate)
  372. for f in findings:
  373. print(f)
  374. fails = [f for f in findings if f.startswith("FAIL")]
  375. sys.exit(1 if fails else 0)
  376. else:
  377. parser.print_help()
  378. if __name__ == "__main__":
  379. _cli()