From 1b0f83a4ad4da0e0a979488b63a96f25e6ce3c95 Mon Sep 17 00:00:00 2001 From: Vasanth Date: Wed, 28 Oct 2026 17:45:47 +0530 Subject: [PATCH] rpa-bot-handoff: drop to_docx.py, Claude builds the PDF itself SKILL.md step 4 now tells Claude to render the SOP as one styled HTML page (A4, 1.8 cm margins, 10.5 pt sans, bordered tables with a shaded header, grey email box) and print it with headless Chrome or Edge, then look at every page. The skill is one 54-line SKILL.md. Tests updated; the docx selfcheck test is removed with the converter. Co-Authored-By: Claude Opus 5.5 --- GLOSSARY.md | 2 +- docs/adr/0005-handover-is-a-short-sop.md | 2 +- skills/rpa-bot-handoff/SKILL.md | 2 +- skills/rpa-bot-handoff/templates/to_docx.py | 195 -------------------- tests/test_templates.py | 14 +- 5 files changed, 7 insertions(+), 208 deletions(-) delete mode 100644 skills/rpa-bot-handoff/templates/to_docx.py diff --git a/GLOSSARY.md b/GLOSSARY.md index 03a3f09..77e7b02 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -37,7 +37,7 @@ It is hidden for bots that take no input file. **Fake bot**: a bundled script that prints the Run tab protocol lines, used to verify the page. The skill never runs the real bot. -**Handover SOP**: the ` Handover SOP` the `rpa-bot-handoff` skill writes in a bot's repo (`HANDOVER-SOP.md` plus Word and PDF copies; ops get the PDF), for the operator who runs the bot and does not read code. At most 4 pages, 5 sections in the operator's order: how to run, what you see while it runs, result, when something goes wrong, email us. See ADR-0005. _Avoid_: "ops guide" (the old 11-section format). +**Handover SOP**: the ` Handover SOP` the `rpa-bot-handoff` skill writes in a bot's repo (`HANDOVER-SOP.md` and the PDF ops get), for the operator who runs the bot and does not read code. At most 4 pages, 5 sections in the operator's order: how to run, what you see while it runs, result, when something goes wrong, email us. See ADR-0005. _Avoid_: "ops guide" (the old 11-section format). **Exception list**: the table in section 4 of the Handover SOP: one numbered row (`E01`...) per message the operator can see, with what it means and what to do, including when to email the dev team. diff --git a/docs/adr/0005-handover-is-a-short-sop.md b/docs/adr/0005-handover-is-a-short-sop.md index e2f7f49..7bd8eaa 100644 --- a/docs/adr/0005-handover-is-a-short-sop.md +++ b/docs/adr/0005-handover-is-a-short-sop.md @@ -12,4 +12,4 @@ Supersedes ADR-0004. **Shorter skill (Oct 2026):** the user found the skill itself too detailed. SKILL.md is now a short checklist (under 500 words), the 12 Tebra failure classes are folded into 6 one-line "don't miss" checks, and the MEK Charge Reconciliation SOP (the 3-page PDF ops liked) replaces the Tebra example as the model. -**One file (Oct 2026):** the user asked for about 50 lines. The skill is now one `SKILL.md` (under 60 lines) with the SOP shape inline, plus `templates/to_docx.py` for the Word and PDF. The template, the reference example and the README are removed. +**One file (Oct 2026):** the user asked for about 50 lines. The skill is now one `SKILL.md` (under 60 lines) with the SOP shape inline, plus `templates/to_docx.py` for the Word and PDF. The template, the reference example and the README are removed. Later the converter went too: SKILL.md tells Claude to build the PDF itself (styled HTML printed with headless Chrome or Edge), so the skill is the one file. diff --git a/skills/rpa-bot-handoff/SKILL.md b/skills/rpa-bot-handoff/SKILL.md index 6c4dabd..50ccfbd 100644 --- a/skills/rpa-bot-handoff/SKILL.md +++ b/skills/rpa-bot-handoff/SKILL.md @@ -36,7 +36,7 @@ Run started / Error: E - / Where / What I did / Attached: scre Never send: passwords, patient data, result files that hold patient data. ``` -4. **Build:** `python /templates/to_docx.py HANDOVER-SOP.md -o " Handover SOP.docx" --pdf`. Over 4 pages: shorten cells, never drop a message. +4. **Make the PDF yourself:** turn the SOP into one self-contained HTML page (A4, 1.8 cm margins, clean sans-serif 10.5 pt, bold headings, bordered tables with a shaded header row at 9.5 pt, the email block in a grey box, no page break inside a table row), then print it with headless Chrome or Edge (`--headless --print-to-pdf`) to ` Handover SOP.pdf`. Look at every page. Over 4 pages: shorten cells, never drop a message. 5. **Hand over** the PDF. Problems for the dev team go in the chat (at most 5 lines), not in the SOP. **Rules** diff --git a/skills/rpa-bot-handoff/templates/to_docx.py b/skills/rpa-bot-handoff/templates/to_docx.py deleted file mode 100644 index 5c02709..0000000 --- a/skills/rpa-bot-handoff/templates/to_docx.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Turn the Handover SOP (HANDOVER-SOP.md) into a Word document, and a PDF, ops can open, print and pass around. - - python to_docx.py HANDOVER-SOP.md # writes HANDOVER-SOP.docx next to it - python to_docx.py HANDOVER-SOP.md -o " Handover SOP.docx" --pdf # + the .pdf next to it - python to_docx.py --selfcheck - -Prints the word count (at most MAX_WORDS) and, with --pdf, the real page count (at most MAX_PAGES). -The PDF is made by Word on Windows (through PowerShell) or by LibreOffice (soffice) elsewhere. - -Handles what the template uses: # headings, paragraphs, - bullets, - [ ] checkboxes, 1. steps, -| tables |, ``` blocks, **bold**, *italic* and `code`. Needs python-docx (pip install python-docx). -""" -import argparse -import re -import subprocess -import sys -import tempfile -from pathlib import Path - -from docx import Document -from docx.shared import Cm, Pt - -MAX_WORDS = 1400 # about 4 pages with the margins and table font below -MAX_PAGES = 4 -INLINE = re.compile(r"(\*\*[^*]+\*\*|\*[^*\s][^*]*\*|`[^`]+`)") - - -def add_runs(par, text): - for part in INLINE.split(text): - if part.startswith("**") and part.endswith("**"): - par.add_run(part[2:-2]).bold = True - elif len(part) > 2 and part.startswith("*") and part.endswith("*"): - par.add_run(part[1:-1]).italic = True - elif part.startswith("`") and part.endswith("`"): - par.add_run(part[1:-1]).font.name = "Consolas" - elif part: - par.add_run(part) - - -def cells(line): - return [c.strip() for c in line.strip().strip("|").split("|")] - - -def convert(md_text, out_path): - doc = Document() - doc.styles["Normal"].font.name = "Calibri" - doc.styles["Normal"].font.size = Pt(10.5) - for section in doc.sections: # Word's 2.5 cm default margins push a 4-page SOP onto 5 - section.left_margin = section.right_margin = Cm(1.8) - section.top_margin = section.bottom_margin = Cm(1.6) - lines = md_text.splitlines() - i = 0 - while i < len(lines): - line = lines[i] - if line.startswith("```"): - block = [] - i += 1 - while i < len(lines) and not lines[i].startswith("```"): - block.append(lines[i]) - i += 1 - run = doc.add_paragraph().add_run("\n".join(block)) - run.font.name = "Consolas" - run.font.size = Pt(9.5) - elif line.startswith("|"): - rows = [] - while i < len(lines) and lines[i].startswith("|"): - if not re.match(r"^\|[\s:|-]+\|\s*$", lines[i]): # skip the |---| separator - rows.append(cells(lines[i])) - i += 1 - i -= 1 - width = max(len(r) for r in rows) - table = doc.add_table(rows=len(rows), cols=width) - table.style = "Table Grid" - for r, row in enumerate(rows): - for c, text in enumerate(row): - par = table.cell(r, c).paragraphs[0] - add_runs(par, text) - for run in par.runs: - run.font.size = Pt(9.5) - if r == 0: - for run in par.runs: - run.bold = True - elif m := re.match(r"^(#{1,3})\s+(.*)", line): - doc.add_heading(m[2], level=len(m[1]) - 1 if len(m[1]) > 1 else 0) - elif m := re.match(r"^\s*- \[ \]\s+(.*)", line): - add_runs(doc.add_paragraph(style="List Bullet"), "☐ " + m[1]) - elif m := re.match(r"^\s*[-*]\s+(.*)", line): - add_runs(doc.add_paragraph(style="List Bullet"), m[1]) - elif m := re.match(r"^\s*\d+\.\s+(.*)", line): - add_runs(doc.add_paragraph(style="List Number"), m[1]) - elif line.strip() and line.strip() != "---": - add_runs(doc.add_paragraph(), line.strip()) - i += 1 - doc.save(out_path) - return doc - - -def words(md_text): - return len(re.findall(r"[A-Za-z0-9][\w'’.-]*", re.sub(r"^\|[\s:|-]+\|\s*$", "", md_text, flags=re.M))) - - -def to_pdf(docx_path): - """Write the PDF next to the docx. Returns (pdf path, page count or None); pdf path is None on failure.""" - docx_path = Path(docx_path).resolve() - pdf = docx_path.with_suffix(".pdf") - pages = None - if sys.platform == "win32": - q = lambda p: str(p).replace("'", "''") # PowerShell single-quote escape - ps = (f"$w = New-Object -ComObject Word.Application; $w.Visible = $false; " - f"$d = $w.Documents.Open('{q(docx_path)}', $false, $true); " - f"Write-Output ('pages=' + $d.ComputeStatistics(2)); $d.SaveAs2('{q(pdf)}', 17); " - f"try {{ $d.Close(0) }} catch {{}}; try {{ $w.Quit() }} catch {{}}") - r = subprocess.run(["powershell", "-NoProfile", "-Command", ps], capture_output=True, text=True) - if m := re.search(r"pages=(\d+)", r.stdout): - pages = int(m[1]) - else: - subprocess.run(["soffice", "--headless", "--convert-to", "pdf", "--outdir", str(pdf.parent), str(docx_path)], - capture_output=True) - return (pdf if pdf.exists() else None), pages - - -SAMPLE = """# Sample Bot Handover SOP - -Version 1.0 · Problems: email dev@example.com, CC manager@example.com (section 5) - -## 1. How to run - -1. Open **Sample Bot** and click **Run**. - -## 2. What you see while it runs - -| Where on the screen | What it shows | -|---|---| -| Status | "Running…" then "Finished" | - -## 3. Result - -| Status | Meaning | What you do | -|---|---|---| -| Done | Posted | Nothing | - -## 4. When something goes wrong - -| ID | Message you see | What it means | What you do | -|---|---|---|---| -| E01 | "Couldn't open *Item*" | The app was slow | Run again | - -## 5. Email us - -``` -To: dev@example.com -CC: manager@example.com -``` -""" - - -def selfcheck(): - sop = SAMPLE - out = Path(tempfile.mkdtemp()) / "HANDOVER-SOP.docx" - convert(sop, out) - doc = Document(out) - heads = [p.text for p in doc.paragraphs if p.style.name.startswith("Heading")] - assert doc.paragraphs[0].text.endswith("Handover SOP"), doc.paragraphs[0].text - assert [h.split(".")[0] for h in heads if h[:1].isdigit()] == [str(n) for n in range(1, 6)], heads - assert len(doc.tables) == 3, len(doc.tables) - assert doc.tables[0].cell(0, 0).text == "Where on the screen", doc.tables[0].cell(0, 0).text - assert not any("**" in p.text or "|---" in p.text for p in doc.paragraphs) - assert words(sop) <= MAX_WORDS, words(sop) - print(f"selfcheck ok: {len(heads)} headings, {len(doc.tables)} tables, {words(sop)} words -> {out}") - - -if __name__ == "__main__": - ap = argparse.ArgumentParser() - ap.add_argument("md", nargs="?") - ap.add_argument("-o", "--out") - ap.add_argument("--pdf", action="store_true", help="also write the PDF next to the docx") - ap.add_argument("--selfcheck", action="store_true") - a = ap.parse_args() - if a.selfcheck: - selfcheck() - sys.exit(0) - if not a.md: - ap.error("give the HANDOVER.md path, or --selfcheck") - src = Path(a.md) - out = Path(a.out) if a.out else src.with_suffix(".docx") - text = src.read_text(encoding="utf-8") - convert(text, out) - n = words(text) - print(f"wrote {out} ({n} words{'' if n <= MAX_WORDS else f', OVER the {MAX_WORDS}-word limit: cut it'})") - if a.pdf: - pdf, pages = to_pdf(out) - if not pdf: - sys.exit("no PDF: needs Word (Windows) or LibreOffice (soffice). Hand over the docx instead.") - over = pages and pages > MAX_PAGES - print(f"wrote {pdf} ({pages or '?'} pages{f', OVER the {MAX_PAGES}-page limit: cut it' if over else ''})") diff --git a/tests/test_templates.py b/tests/test_templates.py index cfd5ec8..733b644 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -49,12 +49,11 @@ def handoff_skill(): return (ROOT / "skills" / "rpa-bot-handoff" / "SKILL.md").read_text(encoding="utf-8") -def test_handoff_skill_is_one_short_file_plus_the_converter(): - files = sorted(p.relative_to(ROOT / "skills" / "rpa-bot-handoff").as_posix() - for p in (ROOT / "skills" / "rpa-bot-handoff").rglob("*") if p.is_file()) - assert files == ["SKILL.md", "templates/to_docx.py"], files +def test_handoff_skill_is_one_short_file(): + files = [p.name for p in (ROOT / "skills" / "rpa-bot-handoff").rglob("*") if p.is_file()] + assert files == ["SKILL.md"], files assert len(handoff_skill().splitlines()) <= 60 - assert "templates/to_docx.py" in handoff_skill() + assert "--print-to-pdf" in handoff_skill() def test_handoff_skill_lists_six_dont_miss_checks(): @@ -68,8 +67,3 @@ def test_handoff_sop_shape_has_five_sections_and_to_cc(): assert "To: " in shape and "CC: " in shape -def test_handoff_docx_selfcheck(): - pytest.importorskip("docx") - script = ROOT / "skills" / "rpa-bot-handoff" / "templates" / "to_docx.py" - r = subprocess.run([sys.executable, str(script), "--selfcheck"], capture_output=True, text=True) - assert r.returncode == 0, r.stderr