From 547ac4b8331083b480e4de6e979a81d3c1181f21 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 14 May 2026 13:41:10 +1000 Subject: [PATCH 1/9] docs/plans: SDG-SSA quality hardening plan (Phases 0-7) Captures the agreed plan for porting rpd-ssa-builder's quality patterns into gatekeeper's SSA report-docx output path. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/plans/sdg-ssa-quality-hardening.md | 128 ++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/plans/sdg-ssa-quality-hardening.md diff --git a/docs/plans/sdg-ssa-quality-hardening.md b/docs/plans/sdg-ssa-quality-hardening.md new file mode 100644 index 0000000..61c2ca1 --- /dev/null +++ b/docs/plans/sdg-ssa-quality-hardening.md @@ -0,0 +1,128 @@ +# SDG-SSA Quality Hardening Plan + +**Date:** 2026-05-14 +**Status:** PROPOSED — awaiting operator approval +**Worktree:** `C:\Users\AlanRichardson\gatekeeper\.claude\worktrees\brave-mestorf-8f2596` +**Branch:** `claude/brave-mestorf-8f2596` (from `main`) + +--- + +## Goal + +Apply rpd-ssa-builder's four quality guards (deterministic post-pass, OOXML schema validation, LibreOffice headless smoke, drift checker) plus the `--check` preflight, CHANGELOG discipline, and byte-hash regression to gatekeeper's SSA pipeline. Operator answered **1B / 2**: SDG audits stay in gatekeeper; rpd-ssa-builder's quality patterns are the import. + +## Scope + +**In scope:** + +- Gatekeeper's SSA report-docx output path (`pims/services/ssa_pipeline.py` → `build_ssa_report_docx`), which is shared by RPD and SDG. Hardening SDG necessarily hardens RPD because they share code. State the fact, don't fork it. +- `pims/scripts/run_ssa_pipeline.py` — add `--check` preflight wiring. +- Tests under `tests/test_ssa_pipeline.py` — add deterministic-hash and OOXML schema assertions. + +**Out of scope (this plan):** + +- Rebranding rpd-ssa-builder; it stays a standalone tool at `G:\My Drive\alan_mcxico\SSA-evidence\SSA rules\`. +- Changing gatekeeper's SDG vs RPD divergence logic (`_BULK_ENDPOINT`, STAGING-NO-BULK-ENDPOINT.txt sentinel). +- Spinning out a separate `sdg-ssa-builder` private GitHub repo. **Recommendation: don't.** Gatekeeper is the active pipeline; a thin wrapper repo would just be the four guards + a CLI — too thin to justify two repos. If split is wanted later, it's a Phase 7 conversation. + +## Repo Structure Decision + +**Recommended: harden inside gatekeeper.** New subpackage `pims/services/ssa_quality/`: + +``` +pims/services/ssa_quality/ + __init__.py + determinism.py # lifted as-is from rpd-ssa-builder + oxml_validator.py # lifted, points at shared %LOCALAPPDATA%\rpd-ssa-validator cache + libreoffice_smoke.py # extracted from build_report.py:167-198 + preflight.py # parameterised port of rpd-ssa-builder src/preflight.py +tools/ + check_ssa_drift.py # parameterised port of rpd-ssa-builder tools/check_drift.py +docs/ + CHANGELOG-SSA.md # SSA-scoped changelog, not to clobber any existing root file +``` + +Wire-ins all live in `pims/services/ssa_pipeline.py` and `pims/scripts/run_ssa_pipeline.py`. + +## What Gets Lifted (verified portable) + +| Module | Source path | Destination | Self-contained? | +|---|---|---|---| +| Determinism post-pass | `G:\My Drive\alan_mcxico\SSA-evidence\SSA rules\src\determinism.py` (186 lines) | `pims/services/ssa_quality/determinism.py` | **Yes** — stdlib only | +| OOXML validator | `…\SSA rules\src\oxml_validator.py` (200 lines) | `pims/services/ssa_quality/oxml_validator.py` | **Yes** — stdlib only; reuses cached `%LOCALAPPDATA%\rpd-ssa-validator\bin\Release\net8.0\Validate.dll` | +| LibreOffice smoke | `…\SSA rules\build_report.py:167–198` | `pims/services/ssa_quality/libreoffice_smoke.py` | **Yes** — once candidate paths are passed in | +| Drift checker | `…\SSA rules\tools\check_drift.py` (203 lines) | `tools/check_ssa_drift.py` | **Medium** — needs injection of constants instead of importing rpd config | +| `--check` preflight | `…\SSA rules\src\preflight.py` | `pims/services/ssa_quality/preflight.py` | **Medium** — checks are folder-shape and file-presence; need to adapt for SDG/RPD folder naming `YYYY-MM-DD-{RPD,SDG}[-NN]` (per `run_ssa_pipeline.py` line ~102) | + +**OOXML validator note:** we do NOT copy the C# project (`_validator/Program.cs`, `Validate.csproj`). The DLL is already built and cached at `%LOCALAPPDATA%\rpd-ssa-validator\bin\Release\net8.0\Validate.dll` by rpd-ssa-builder. Gatekeeper just calls `dotnet `. If the cache is missing, fall through to building it via the rpd-ssa-builder source. This avoids duplicating the C# project across two repos. + +## Phase Breakdown (checkpoint-driven, ≤3 files per phase) + +Each phase: implement → run tests → diff summary → wait for approval → commit. Never push without separate approval. + +### Phase 0 — Verify build environment +- Confirm `%LOCALAPPDATA%\rpd-ssa-validator\bin\Release\net8.0\Validate.dll` exists. +- Confirm `C:\Program Files\LibreOffice\program\soffice.exe` exists. +- Confirm `dotnet --version` reports 8.x. +- Run `python -m pytest tests/test_ssa_pipeline.py -q` to capture baseline pass count. +- No code changes. Report results. + +### Phase 1 — Determinism post-pass +- Files: `pims/services/ssa_quality/__init__.py`, `pims/services/ssa_quality/determinism.py`, wire in `pims/services/ssa_pipeline.py::build_ssa_report_docx`. +- Test: build a fixture report twice; assert SHA-256 matches. +- Stop condition: hash matches across two builds. If not, debug before proceeding. + +### Phase 2 — OOXML schema validator +- Files: `pims/services/ssa_quality/oxml_validator.py`, wire-in in `ssa_pipeline.py` after determinism call. +- Test: pass a known-good fixture; assert exit 0. Pass a deliberately-broken docx; assert non-zero. +- Stop condition: current SSA output validates clean. + +### Phase 3 — LibreOffice smoke test +- Files: `pims/services/ssa_quality/libreoffice_smoke.py`, wire-in. +- Test: build → smoke; assert pdf appears in temp dir and is non-empty. +- Timeout: 180s (same as rpd-ssa-builder). + +### Phase 4 — `--check` preflight +- Files: `pims/services/ssa_quality/preflight.py`, wire `--check` flag in `pims/scripts/run_ssa_pipeline.py`. +- Test: known-bad folder fails fast; known-good folder passes. + +### Phase 5 — Drift checker +- Files: `tools/check_ssa_drift.py`, plus a constants surface (probably a small `pims/services/ssa_quality/contract.py` that names the placeholder tokens, status colours, xlsx headers, font, recipients gatekeeper actually uses). +- Open question: gatekeeper's `ssa_pipeline.py` does token substitution but I haven't yet enumerated its placeholder set the way rpd-ssa-builder's `config.PH_*` does. Phase 5 starts with mapping that surface — possibly 1 read-only investigation commit first. +- Test: drift checker exits 0 on current state. + +### Phase 6 — CHANGELOG + byte-hash regression +- Files: `docs/CHANGELOG-SSA.md` (new), `tests/test_ssa_determinism.py` (new fixture-driven hash regression). +- The hash regression test pins the SHA-256 of the fixture build; future drift on output formatting is caught by CI. +- Stop condition: test passes locally. + +### Phase 7 — (Optional, gated) Spin out `sdg-ssa-builder` repo +- Only if operator wants visible separation. Recommendation: defer. + +## Approval Gates + +- After each phase: operator approves the diff before commit. +- No push to `main` (or any branch) without explicit per-push authorisation. +- If any phase touches more than 3 files, pause and re-scope before continuing. + +## Stop Conditions + +- OOXML validator reports schema errors on current gatekeeper output → stop, surface findings, do not paper over. +- Determinism post-pass fails to make builds byte-identical → stop, diagnose, do not commit half-deterministic output. +- Drift checker turns out to be a poor fit for gatekeeper's token style → drop or rewrite Phase 5; don't force a broken adapter. +- Any phase reveals an architectural decision (e.g., gatekeeper's SDG output needs to be split across two docs) → stop, write the product decision, do not slice further. + +## Open Questions (for operator) + +1. **Single repo (recommended) vs. separate `sdg-ssa-builder` repo?** Recommend single repo (hardening inside gatekeeper) for the reasons above. Phase 7 is optional. +2. **Determinism scope — docx only, or all three deliverables?** rpd-ssa-builder only deterministically rebuilds the docx because that's its only output. Gatekeeper produces two xlsx files too. xlsx is also a zip-archive with embedded timestamps. Should I extend determinism to xlsx outputs as well? Recommend **yes, but in a later phase** — keep Phase 1 docx-only to match rpd-ssa-builder's exact pattern, then a Phase 1b adds xlsx if desired. +3. **CHANGELOG location.** Recommend `docs/CHANGELOG-SSA.md` to avoid clobbering any existing root-level changelog. Confirm before writing. +4. **Drift checker scope.** Should it check the SDG report template's placeholder set, the xlsx headers in `build_pims_enriched_xlsx`, the staging xlsx headers, or all of the above? Recommend all of the above but split across two passes — template+report first, xlsx headers second. + +## Outstanding (separate from this plan) + +- Audit folders `2026-05-13_RPD-01` and `2026-05-13_RPD-02` use underscores; rpd-ssa-builder's regex requires hyphens. Rename to `2026-05-13-RPD-01` and `2026-05-13-RPD-02`. Not blocking this plan. + +## Next Step + +Operator approves this plan (or redirects), then I execute Phase 0 (environment verification, no code changes) and report back. From c196c599fa40058e38f5e74067df70adcdfed1e6 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 14 May 2026 13:41:22 +1000 Subject: [PATCH 2/9] pims/ssa: deterministic post-pass on report docx build Two runs over the same inputs now produce byte-identical output; docxcompose-style paraId drift and dcterms wall-clock stamps no longer leak into the deliverable. Lifted from rpd-ssa-builder. Co-Authored-By: Claude Opus 4.7 (1M context) --- pims/services/ssa_pipeline.py | 10 ++ pims/services/ssa_quality/__init__.py | 14 ++ pims/services/ssa_quality/determinism.py | 185 +++++++++++++++++++++++ tests/test_ssa_determinism.py | 119 +++++++++++++++ 4 files changed, 328 insertions(+) create mode 100644 pims/services/ssa_quality/__init__.py create mode 100644 pims/services/ssa_quality/determinism.py create mode 100644 tests/test_ssa_determinism.py diff --git a/pims/services/ssa_pipeline.py b/pims/services/ssa_pipeline.py index 628e9b5..7ab37d7 100644 --- a/pims/services/ssa_pipeline.py +++ b/pims/services/ssa_pipeline.py @@ -30,6 +30,8 @@ from pathlib import Path from typing import Iterable +from pims.services.ssa_quality import make_docx_deterministic + log = logging.getLogger(__name__) @@ -2905,6 +2907,14 @@ def build_ssa_report_docx( doc.save(tmp) os.replace(tmp, output_path) + # Deterministic post-pass: stable zip mtimes + strip non-deterministic + # OOXML metadata so re-runs over the same inputs are byte-identical. + import datetime as _dt + try: + _audit_date = _dt.datetime.strptime(audit_date_ddmmyyyy, "%d/%m/%Y").date() + except (ValueError, TypeError): + _audit_date = _dt.date(2000, 1, 1) + make_docx_deterministic(output_path, _audit_date) return diagnostics diff --git a/pims/services/ssa_quality/__init__.py b/pims/services/ssa_quality/__init__.py new file mode 100644 index 0000000..10d5572 --- /dev/null +++ b/pims/services/ssa_quality/__init__.py @@ -0,0 +1,14 @@ +"""SSA output quality guards. + +Subpackage that hardens the SSA report-docx output path. Modules land +across Phases 1-5 of the SDG-SSA quality hardening plan +(docs/plans/sdg-ssa-quality-hardening.md): + +- determinism (Phase 1) — byte-stable docx output +- oxml_validator (Phase 2) — OOXML schema validation +- libreoffice_smoke (Phase 3) — headless render smoke test +- preflight (Phase 4) — --check folder/inputs validation +""" +from .determinism import make_docx_deterministic + +__all__ = ["make_docx_deterministic"] diff --git a/pims/services/ssa_quality/determinism.py b/pims/services/ssa_quality/determinism.py new file mode 100644 index 0000000..9187e18 --- /dev/null +++ b/pims/services/ssa_quality/determinism.py @@ -0,0 +1,185 @@ +"""Post-process a generated docx so byte output is reproducible across runs. + +python-docx stamps `dcterms:created` and `dcterms:modified` in +`docProps/core.xml` with the current wall-clock time on save, and the +underlying zip writer encodes the host's local time into every entry's +file-mod-time field. Both inject non-determinism into otherwise identical +output. + +This module rewrites the docx zip in place with: +- a fixed timestamp (derived from the audit date) on every zip entry, +- core.xml `created` / `modified` set to that same timestamp, +- entries sorted by filename so directory order is stable. +""" + +from __future__ import annotations + +import datetime as _dt +import re +import shutil +import tempfile +import zipfile +from pathlib import Path + + +_CORE_NS = "{http://purl.org/dc/terms/}" +_RE_CREATED = re.compile( + r"(]*>)[^<]*()", + re.DOTALL, +) +_RE_MODIFIED = re.compile( + r"(]*>)[^<]*()", + re.DOTALL, +) + +# Strip optional w14 paragraph identifiers. docxcompose merges two documents +# without renumbering paraIds, leaving duplicates that Word flags as +# "unreadable content". They're regenerated on next save anyway, so stripping +# them entirely is the safe and minimal fix. +_RE_PARA_ID = re.compile(r'\s+w14:paraId="[^"]*"') +_RE_TEXT_ID = re.compile(r'\s+w14:textId="[^"]*"') + +# Self-closing custom styles with no trigger Word's "Show Repairs" +# dialog. docxcompose pulls these in when the source doc has unnamed quick +# styles (Alan-Richardson.docx ships with a `` stub). +# Injecting a `` keyed off the styleId keeps Word silent and never +# clobbers existing names. +_RE_STYLE_SELF_CLOSING = re.compile( + r']*?)\bw:styleId="([^"]+)"([^>]*?)/>' +) +_RE_STYLE_OPEN_NO_NAME = re.compile( + r'(]*?\bw:styleId="([^"]+)"[^>]*?>)(?!.*? bytes: + text = content.decode("utf-8") + text = _RE_CREATED.sub(rf"\g<1>{iso_timestamp}\g<2>", text) + text = _RE_MODIFIED.sub(rf"\g<1>{iso_timestamp}\g<2>", text) + return text.encode("utf-8") + + +def _strip_paraids(content: bytes) -> bytes: + text = content.decode("utf-8") + text = _RE_PARA_ID.sub("", text) + text = _RE_TEXT_ID.sub("", text) + return text.encode("utf-8") + + +def _ensure_styles_have_names(content: bytes) -> bytes: + """Expand any self-closing or empty `` that lacks a ``. + + Word's "Show Repairs" dialog fires for any custom style missing its name. + Inject `` and rewrite the element as open/close. + """ + text = content.decode("utf-8") + + # Pass 1: self-closing styles -> expand to open/close with injected w:name. + def _self_closing_fix(m: re.Match) -> str: + before_id, style_id, after_id = m.group(1), m.group(2), m.group(3) + return ( + f'' + f'' + f'' + ) + + text = _RE_STYLE_SELF_CLOSING.sub(_self_closing_fix, text) + + # Pass 2: open ... blocks that contain no inside their + # body. Cheaper to walk each style block individually with the stdlib than + # to write a single recursive regex. + def _open_fix(text_in: str) -> str: + out: list[str] = [] + cursor = 0 + for m in re.finditer(r']*?>', text_in): + # Find the matching + end_m = re.search(r'', text_in[m.end():]) + if not end_m: + continue + block_start = m.start() + block_end = m.end() + end_m.end() + block = text_in[block_start:block_end] + if '', + 1, + ) + # Substitute back into text_in. Build incrementally: + out.append(text_in[cursor:block_start]) + out.append(fixed_block) + cursor = block_end + out.append(text_in[cursor:]) + return "".join(out) + + text = _open_fix(text) + return text.encode("utf-8") + + +def _is_word_part_xml(name: str) -> bool: + """Word body / header / footer / endnote XML parts that carry paraIds.""" + if not name.startswith("word/"): + return False + if not name.endswith(".xml"): + return False + base = name[len("word/"):] + return ( + base == "document.xml" + or base.startswith("header") and base.endswith(".xml") + or base.startswith("footer") and base.endswith(".xml") + or base == "endnotes.xml" + or base == "footnotes.xml" + or base == "comments.xml" + ) + + +def make_docx_deterministic(docx_path: Path, audit_date: _dt.date) -> None: + """Rewrite `docx_path` in place so two runs over the same inputs produce + byte-identical files. + + `audit_date` seeds the timestamp written into every zip entry and into + `docProps/core.xml`. Using the audit date (not a hard-coded epoch) means + a build refresh after re-running humanise — which legitimately changes + content — still produces a stable file for that input set. + """ + iso = f"{audit_date.isoformat()}T00:00:00Z" + zip_date = (audit_date.year, audit_date.month, audit_date.day, 0, 0, 0) + + with zipfile.ZipFile(docx_path, "r") as zin: + names = sorted(zin.namelist()) + entries: list[tuple[zipfile.ZipInfo, bytes]] = [] + for name in names: + data = zin.read(name) + if name == "docProps/core.xml": + data = _patch_core_xml(data, iso) + if _is_word_part_xml(name): + data = _strip_paraids(data) + if name == "word/styles.xml": + data = _ensure_styles_have_names(data) + info = zipfile.ZipInfo(filename=name, date_time=zip_date) + # Preserve compression mode for the entry. + original_info = zin.getinfo(name) + info.compress_type = original_info.compress_type + info.external_attr = original_info.external_attr + entries.append((info, data)) + + # Write to a temp file then atomically replace. + tmp_fd, tmp_path_str = tempfile.mkstemp(prefix="ssa-det-", suffix=".docx") + tmp_path = Path(tmp_path_str) + try: + with zipfile.ZipFile(tmp_path, "w") as zout: + for info, data in entries: + zout.writestr(info, data) + # zipfile keeps the FD open until context exits; close fd we got from mkstemp. + import os + os.close(tmp_fd) + shutil.move(str(tmp_path), str(docx_path)) + except Exception: + tmp_path.unlink(missing_ok=True) + raise diff --git a/tests/test_ssa_determinism.py b/tests/test_ssa_determinism.py new file mode 100644 index 0000000..4f4abf8 --- /dev/null +++ b/tests/test_ssa_determinism.py @@ -0,0 +1,119 @@ +"""Phase 1 byte-determinism regression for build_ssa_report_docx. + +Two runs over identical inputs must produce byte-identical output. +Guards against accidental reintroduction of timestamp non-determinism +(zip mtimes, dcterms:created/modified) and against docxcompose-style +paraId drift. +""" +from __future__ import annotations + +import hashlib +import zipfile +from pathlib import Path + +from PIL import Image + +from pims.services.ssa_pipeline import ( + EnrichedRow, + ObservationRow, + build_ssa_report_docx, +) + + +def _save_jpeg(path: Path, size=(800, 600), color="red") -> Path: + Image.new("RGB", size, color).save(path, "JPEG") + return path + + +def _sha256(p: Path) -> str: + return hashlib.sha256(p.read_bytes()).hexdigest() + + +def _build_rows(photos_dir: Path) -> list[EnrichedRow]: + p1 = _save_jpeg(photos_dir / "a.jpg", (1200, 800)) + p2 = _save_jpeg(photos_dir / "b.jpg", (800, 1200), "blue") + obs1 = ObservationRow( + csv_row=1, timestamp_raw="", timestamp_iso=None, + observation_text="x", csv_filename="a.jpg", + resolved_filename="a.jpg", resolved_path=p1, + ) + obs2 = ObservationRow( + csv_row=2, timestamp_raw="", timestamp_iso=None, + observation_text="y", csv_filename="b.jpg", + resolved_filename="b.jpg", resolved_path=p2, + ) + return [ + EnrichedRow(obs=obs1, finding="Top rail missing.", + conformance_status="NCR", legal_ref="WHS Reg cl.79"), + EnrichedRow(obs=obs2, observation_text_clean="Site sign clear.", + conformance_status="Compliant", legal_ref="WHS Reg cl.34"), + ] + + +def test_build_ssa_report_docx_byte_deterministic(tmp_path): + """Two builds with identical inputs must produce byte-identical output.""" + photos_dir = tmp_path / "photos" + photos_dir.mkdir() + + out1 = tmp_path / "r1.docx" + out2 = tmp_path / "r2.docx" + + build_ssa_report_docx( + rows=_build_rows(photos_dir), + site_address="12 Test Street, Sydney NSW", + audit_date_ddmmyyyy="01/05/2026", + narrative_summary="Audit summary text.", + output_path=out1, + prepared_by="Alan Richardson", + ) + build_ssa_report_docx( + rows=_build_rows(photos_dir), + site_address="12 Test Street, Sydney NSW", + audit_date_ddmmyyyy="01/05/2026", + narrative_summary="Audit summary text.", + output_path=out2, + prepared_by="Alan Richardson", + ) + + assert _sha256(out1) == _sha256(out2) + + +def test_build_ssa_report_docx_deterministic_with_empty_audit_date(tmp_path): + """Empty audit date hits the sentinel fallback; output stays byte-stable.""" + photos_dir = tmp_path / "photos" + photos_dir.mkdir() + + out1 = tmp_path / "r1.docx" + out2 = tmp_path / "r2.docx" + + for out in (out1, out2): + build_ssa_report_docx( + rows=_build_rows(photos_dir), + site_address="addr", + audit_date_ddmmyyyy="", + narrative_summary="x", + output_path=out, + prepared_by="Alan Richardson", + ) + + assert _sha256(out1) == _sha256(out2) + + +def test_build_ssa_report_docx_has_no_dcterms_timestamps(tmp_path): + """After determinism post-pass, core.xml carries the audit-date + timestamp, not the wall clock.""" + photos_dir = tmp_path / "photos" + photos_dir.mkdir() + out = tmp_path / "r.docx" + build_ssa_report_docx( + rows=_build_rows(photos_dir), + site_address="addr", + audit_date_ddmmyyyy="01/05/2026", + narrative_summary="x", + output_path=out, + prepared_by="Alan Richardson", + ) + with zipfile.ZipFile(out) as z: + core = z.read("docProps/core.xml").decode("utf-8") + # Both fields stamped with the audit-date iso, not a wall-clock time. + assert "2026-05-01T00:00:00Z" in core From 089574514dcf9440376cf81d22f55136008743fc Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 14 May 2026 13:58:50 +1000 Subject: [PATCH 3/9] pims/ssa: OOXML schema validation on report docx build Wraps Microsoft's OpenXmlValidator (via rpd-ssa-builder's .NET CLI) and runs it after the determinism post-pass. Findings surface on the diagnostics dict and as log warnings; the build does not abort on errors so callers retain hard-fail policy. Soft-skips when dotnet is unreachable. Reuses rpd-ssa-builder's cached DLL at %LOCALAPPDATA%\rpd-ssa-validator; rebuilds from its source on miss (override path via GATEKEEPER_SSA_VALIDATOR_SOURCE). The clean-output test is currently xfail(strict=True): current gatekeeper output emits in the wrong position inside . Follow-up commit fixes the schema-order bug and unxfails. Co-Authored-By: Claude Opus 4.7 (1M context) --- pims/services/ssa_pipeline.py | 16 +- pims/services/ssa_quality/__init__.py | 8 +- pims/services/ssa_quality/oxml_validator.py | 230 ++++++++++++++++++++ tests/test_ssa_oxml_validator.py | 123 +++++++++++ 4 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 pims/services/ssa_quality/oxml_validator.py create mode 100644 tests/test_ssa_oxml_validator.py diff --git a/pims/services/ssa_pipeline.py b/pims/services/ssa_pipeline.py index 7ab37d7..6b0b30f 100644 --- a/pims/services/ssa_pipeline.py +++ b/pims/services/ssa_pipeline.py @@ -30,7 +30,7 @@ from pathlib import Path from typing import Iterable -from pims.services.ssa_quality import make_docx_deterministic +from pims.services.ssa_quality import make_docx_deterministic, validate_docx log = logging.getLogger(__name__) @@ -2915,6 +2915,20 @@ def build_ssa_report_docx( except (ValueError, TypeError): _audit_date = _dt.date(2000, 1, 1) make_docx_deterministic(output_path, _audit_date) + # OOXML schema validation (soft-skip if dotnet missing). Errors are + # logged and surfaced via the diagnostics dict; the build does not + # abort on findings so callers retain control over hard-fail policy. + _validation = validate_docx(output_path) + diagnostics["oxml_validation_errors"] = ( + [e.short() for e in _validation.errors] if _validation.available else None + ) + if _validation.available and _validation.errors: + log.warning( + "OOXML schema validation: %d error(s) in %s", + len(_validation.errors), output_path.name, + ) + for _e in _validation.errors[:5]: + log.warning(" %s", _e.short()) return diagnostics diff --git a/pims/services/ssa_quality/__init__.py b/pims/services/ssa_quality/__init__.py index 10d5572..e13742a 100644 --- a/pims/services/ssa_quality/__init__.py +++ b/pims/services/ssa_quality/__init__.py @@ -10,5 +10,11 @@ - preflight (Phase 4) — --check folder/inputs validation """ from .determinism import make_docx_deterministic +from .oxml_validator import ValidationError, ValidationResult, validate_docx -__all__ = ["make_docx_deterministic"] +__all__ = [ + "make_docx_deterministic", + "ValidationError", + "ValidationResult", + "validate_docx", +] diff --git a/pims/services/ssa_quality/oxml_validator.py b/pims/services/ssa_quality/oxml_validator.py new file mode 100644 index 0000000..3829e91 --- /dev/null +++ b/pims/services/ssa_quality/oxml_validator.py @@ -0,0 +1,230 @@ +"""Run Microsoft's OpenXmlValidator over a generated docx. + +Wraps the .NET CLI from rpd-ssa-builder's `_validator/` project. Validates +against Microsoft 365 — the same target Word uses when deciding whether +to fire the "found unreadable content" recovery prompt. Catching errors +here means we never ship a docx that needs Word recovery. + +Cache strategy: the validator DLL is shared with rpd-ssa-builder at +``%LOCALAPPDATA%\\rpd-ssa-validator\\`` (off Google Drive to avoid sync +fighting `dotnet build`'s memory-mapped writes). If the cache is missing, +this module rebuilds it from rpd-ssa-builder's source dir. The default +source path can be overridden with the ``GATEKEEPER_SSA_VALIDATOR_SOURCE`` +env var for non-Alan-Richardson installs. + +Soft-then-hard policy: +- If `dotnet` isn't reachable → return ``ValidationResult(available=False)``; + the caller decides whether to warn or fail. +- If `dotnet` IS reachable → build the CLI if needed, run it. Errors are + returned via ``ValidationResult.errors``. The caller decides whether to + abort the build. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +# Source directory holding `Validate.csproj` and `Program.cs`. Default points +# at rpd-ssa-builder's vendored source on Alan's machine; override via the +# `GATEKEEPER_SSA_VALIDATOR_SOURCE` env var for other installs. +_DEFAULT_SOURCE_DIR = Path( + "G:/My Drive/alan_mcxico/SSA-evidence/SSA rules/_validator" +) +_VALIDATOR_SRC_DIR = Path( + os.environ.get("GATEKEEPER_SSA_VALIDATOR_SOURCE", str(_DEFAULT_SOURCE_DIR)) +) + +# Build artefacts live OFF the source tree so Google Drive sync doesn't +# fight `dotnet build`'s memory-mapped writes during the apphost step. +# Shared with rpd-ssa-builder — both tools co-own this cache since they +# build from the same source. +_LOCALAPPDATA = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) +_BUILD_ROOT = _LOCALAPPDATA / "rpd-ssa-validator" +_VALIDATOR_DLL = _BUILD_ROOT / "bin" / "Release" / "net8.0" / "Validate.dll" + + +@dataclass(frozen=True) +class ValidationError: + id: str + description: str + error_type: str + part_uri: str + xpath: str + node: str + related_node: str + related_part: str + + def short(self) -> str: + """One-line summary suitable for log output.""" + loc = self.part_uri or "?" + if self.node: + loc += f" / <{self.node}>" + return f"[{self.id or self.error_type}] {loc}: {self.description}" + + +@dataclass(frozen=True) +class ValidationResult: + available: bool # dotnet reachable and validator built + errors: list[ValidationError] + skip_reason: str = "" # populated when available=False + + +# --------------------------------------------------------------------------- +# dotnet detection +# --------------------------------------------------------------------------- + +def _find_dotnet() -> str | None: + """Resolve the dotnet executable. + + Git Bash strips ``C:\\Program Files\\dotnet\\`` from its default PATH on + Windows, so a PATH probe alone misses it. Falls back to common absolute + install paths. + """ + found = shutil.which("dotnet") + if found: + return found + for cand in ( + Path("C:/Program Files/dotnet/dotnet.exe"), + Path("C:/Program Files (x86)/dotnet/dotnet.exe"), + ): + if cand.is_file(): + return str(cand) + return None + + +def _dotnet_works(dotnet_path: str) -> bool: + try: + result = subprocess.run( + [dotnet_path, "--version"], + capture_output=True, text=True, timeout=10, + ) + except (subprocess.SubprocessError, OSError): + return False + return result.returncode == 0 + + +# --------------------------------------------------------------------------- +# Build (one-time, cached) +# --------------------------------------------------------------------------- + +class _BuildError(Exception): + pass + + +def _build_validator(dotnet_path: str) -> None: + """Compile `_validator/` in Release mode, output redirected off Google + Drive. NuGet restore happens implicitly. + + Caches under ``%LOCALAPPDATA%\\rpd-ssa-validator\\bin\\Release\\net8.0\\``. + Subsequent calls are no-ops because the dll exists. + """ + if _VALIDATOR_DLL.is_file(): + return + if not _VALIDATOR_SRC_DIR.is_dir(): + raise _BuildError( + f"validator source not found at {_VALIDATOR_SRC_DIR}. " + "Either install rpd-ssa-builder or set " + "GATEKEEPER_SSA_VALIDATOR_SOURCE to a directory containing " + "Validate.csproj and Program.cs." + ) + _BUILD_ROOT.mkdir(parents=True, exist_ok=True) + bin_dir = _BUILD_ROOT / "bin" + obj_dir = _BUILD_ROOT / "obj" + # Trailing backslash is required by MSBuild for *OutputPath properties. + bin_arg = f"-p:BaseOutputPath={bin_dir}\\" + obj_arg = f"-p:BaseIntermediateOutputPath={obj_dir}\\" + + result = subprocess.run( + [dotnet_path, "build", "Validate.csproj", + "--configuration", "Release", + "--nologo", "--verbosity", "quiet", + bin_arg, obj_arg], + cwd=str(_VALIDATOR_SRC_DIR), + capture_output=True, text=True, timeout=300, + env={**os.environ, + "DOTNET_NOLOGO": "true", + "DOTNET_CLI_TELEMETRY_OPTOUT": "1"}, + ) + if result.returncode != 0: + raise _BuildError( + f"`dotnet build` failed (exit {result.returncode}):\n" + f"{result.stdout.strip()}\n{result.stderr.strip()}" + ) + if not _VALIDATOR_DLL.is_file(): + raise _BuildError( + f"`dotnet build` succeeded but {_VALIDATOR_DLL} is missing" + ) + + +# --------------------------------------------------------------------------- +# Run +# --------------------------------------------------------------------------- + +def validate_docx(docx_path: Path) -> ValidationResult: + """Validate `docx_path`. Returns a ValidationResult; never raises.""" + dotnet_path = _find_dotnet() + if dotnet_path is None or not _dotnet_works(dotnet_path): + return ValidationResult( + available=False, errors=[], + skip_reason=( + "dotnet not found — install the .NET 8 SDK to enable " + "OOXML schema validation " + "(https://dotnet.microsoft.com/download)." + ), + ) + + try: + _build_validator(dotnet_path) + except _BuildError as e: + return ValidationResult( + available=False, errors=[], + skip_reason=f"validator build unavailable:\n{e}", + ) + + result = subprocess.run( + [dotnet_path, str(_VALIDATOR_DLL), str(docx_path)], + capture_output=True, text=True, timeout=120, + ) + + if result.returncode == 2: + return ValidationResult( + available=False, errors=[], + skip_reason=f"validator arg error: {result.stderr.strip()}", + ) + if result.returncode == 3: + return ValidationResult( + available=True, errors=[], + skip_reason=f"validator crashed: {result.stderr.strip()}", + ) + + # Both 0 (clean) and 1 (errors) emit a JSON array on stdout. + try: + raw = json.loads(result.stdout or "[]") + except json.JSONDecodeError: + return ValidationResult( + available=True, errors=[], + skip_reason=( + f"validator returned non-JSON stdout:\n{result.stdout.strip()}" + ), + ) + + errors = [ + ValidationError( + id=str(e.get("id", "")), + description=str(e.get("description", "")), + error_type=str(e.get("errorType", "")), + part_uri=str(e.get("partUri", "")), + xpath=str(e.get("xpath", "")), + node=str(e.get("node", "")), + related_node=str(e.get("relatedNode", "")), + related_part=str(e.get("relatedPart", "")), + ) + for e in raw + ] + return ValidationResult(available=True, errors=errors) diff --git a/tests/test_ssa_oxml_validator.py b/tests/test_ssa_oxml_validator.py new file mode 100644 index 0000000..9ad5a15 --- /dev/null +++ b/tests/test_ssa_oxml_validator.py @@ -0,0 +1,123 @@ +"""Phase 2 OOXML schema validation tests. + +Three scenarios: +1. A clean build_ssa_report_docx output validates cleanly. +2. A deliberately-mutated docx (unknown element injected into the body) + produces validation errors. +3. When dotnet isn't reachable the validator soft-skips with a reason. +""" +from __future__ import annotations + +import shutil +import zipfile +from pathlib import Path + +import pytest +from PIL import Image + +from pims.services.ssa_pipeline import ( + EnrichedRow, + ObservationRow, + build_ssa_report_docx, +) +from pims.services.ssa_quality import oxml_validator as _ov +from pims.services.ssa_quality.oxml_validator import ( + ValidationResult, + _find_dotnet, + validate_docx, +) + + +_DOTNET_MISSING = _find_dotnet() is None + + +def _save_jpeg(path: Path, size=(800, 600), color="red") -> Path: + Image.new("RGB", size, color).save(path, "JPEG") + return path + + +def _build_minimal_report(out: Path) -> None: + photos_dir = out.parent / "photos" + photos_dir.mkdir(exist_ok=True) + p = _save_jpeg(photos_dir / "a.jpg", (800, 600)) + obs = ObservationRow( + csv_row=1, timestamp_raw="", timestamp_iso=None, + observation_text="x", csv_filename="a.jpg", + resolved_filename="a.jpg", resolved_path=p, + ) + rows = [ + EnrichedRow(obs=obs, finding="Top rail missing.", + conformance_status="NCR", legal_ref="WHS Reg cl.79"), + ] + build_ssa_report_docx( + rows=rows, + site_address="12 Test St", + audit_date_ddmmyyyy="01/05/2026", + narrative_summary="Audit summary.", + output_path=out, + prepared_by="Alan Richardson", + ) + + +@pytest.mark.skipif(_DOTNET_MISSING, reason="dotnet not available on this host") +@pytest.mark.xfail( + strict=True, + reason=( + "known: emitted out of schema order in " + "_apply_all_cell_borders (pims/services/ssa_pipeline.py:1089). " + "Schema requires tblBorders at position 11 in , before " + "; .append() puts it after. Follow-up commit fixes " + "this and removes the xfail mark." + ), +) +def test_validate_docx_clean_output_has_no_errors(tmp_path): + """build_ssa_report_docx output must validate clean against the + Microsoft 365 schema.""" + out = tmp_path / "r.docx" + _build_minimal_report(out) + result = validate_docx(out) + assert result.available, result.skip_reason + rendered = [e.short() for e in result.errors] + assert result.errors == [], "\n".join(rendered) + + +@pytest.mark.skipif(_DOTNET_MISSING, reason="dotnet not available on this host") +def test_validate_docx_detects_schema_violation(tmp_path): + """Injecting an unknown element into the document body must trip the + schema validator.""" + out = tmp_path / "r.docx" + _build_minimal_report(out) + + # Pull document.xml, inject a bogus element inside , repack. + rebuilt = tmp_path / "r-broken.docx" + with zipfile.ZipFile(out, "r") as zin, zipfile.ZipFile(rebuilt, "w") as zout: + for item in zin.infolist(): + data = zin.read(item.filename) + if item.filename == "word/document.xml": + text = data.decode("utf-8") + # Inject just before the closing body tag. + text = text.replace( + "", "", 1 + ) + data = text.encode("utf-8") + zout.writestr(item, data) + + result = validate_docx(rebuilt) + assert result.available, result.skip_reason + assert result.errors, "expected at least one schema error" + # The bogus element should surface somewhere in the descriptions. + joined = " ".join(e.description for e in result.errors) + assert "totallyBogus" in joined or "unrecognized" in joined.lower() \ + or "not declared" in joined.lower() + + +def test_validate_docx_soft_skips_when_dotnet_missing(tmp_path, monkeypatch): + """When `_find_dotnet` returns None the validator must return + ``available=False`` with a non-empty skip_reason rather than raising.""" + monkeypatch.setattr(_ov, "_find_dotnet", lambda: None) + out = tmp_path / "r.docx" + out.write_bytes(b"") # don't even need a real docx — we soft-skip first + result = validate_docx(out) + assert isinstance(result, ValidationResult) + assert result.available is False + assert "dotnet" in result.skip_reason.lower() From 24d872a9e5729e46baceaec31da0f7731ee31294 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 14 May 2026 14:04:30 +1000 Subject: [PATCH 4/9] pims/ssa: insert in schema-correct position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _apply_all_cell_borders was emitting at the end of via plain .append(). The OOXML schema places tblBorders at position 11; (position 15) is already on every template table, so appending put tblBorders after tblLook and tripped four Sch_UnexpectedElementContentExpectingComplex errors per built docx. Word's recovery prompt tolerates this; OpenXmlValidator does not. Fixed by inserting before the first child whose tag falls in the positions-after-tblBorders set (shd, tblLayout, tblCellMar, tblLook, tblCaption, tblDescription, tblPrChange). Removes the xfail mark on test_validate_docx_clean_output_has_no_errors — current output now validates clean against Microsoft 365. Co-Authored-By: Claude Opus 4.7 (1M context) --- pims/services/ssa_pipeline.py | 24 +++++++++++++++++++++++- tests/test_ssa_oxml_validator.py | 10 ---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/pims/services/ssa_pipeline.py b/pims/services/ssa_pipeline.py index 6b0b30f..d114e5e 100644 --- a/pims/services/ssa_pipeline.py +++ b/pims/services/ssa_pipeline.py @@ -1061,6 +1061,28 @@ def _cm_to_twips(cm: float) -> int: return int(round(cm * 567)) +def _insert_tblborders_in_schema_order(tblPr, borders) -> None: + """Insert ```` at the schema-correct position in ````. + + OOXML places tblBorders at position 11; ````, ````, + ````, ```` etc. (positions 12-18) must come + after. Template tables always carry ````, so a plain + ``tblPr.append(tblBorders)`` violates schema order. Word's recovery + prompt is tolerant of this; the OpenXmlValidator is not. + """ + from docx.oxml.ns import qn + after_tblborders = { + qn("w:shd"), qn("w:tblLayout"), qn("w:tblCellMar"), + qn("w:tblLook"), qn("w:tblCaption"), + qn("w:tblDescription"), qn("w:tblPrChange"), + } + for i, child in enumerate(tblPr): + if child.tag in after_tblborders: + tblPr.insert(i, borders) + return + tblPr.append(borders) + + def _apply_all_cell_borders(tbl) -> None: """Apply Word's "All" border preset — single-line ½-pt borders on every cell edge inside and outside the table. Sets ```` @@ -1086,7 +1108,7 @@ def _apply_all_cell_borders(tbl) -> None: b.set(qn("w:space"), "0") b.set(qn("w:color"), "auto") borders.append(b) - tblPr.append(borders) + _insert_tblborders_in_schema_order(tblPr, borders) def _apply_table_format( diff --git a/tests/test_ssa_oxml_validator.py b/tests/test_ssa_oxml_validator.py index 9ad5a15..b41a9f3 100644 --- a/tests/test_ssa_oxml_validator.py +++ b/tests/test_ssa_oxml_validator.py @@ -60,16 +60,6 @@ def _build_minimal_report(out: Path) -> None: @pytest.mark.skipif(_DOTNET_MISSING, reason="dotnet not available on this host") -@pytest.mark.xfail( - strict=True, - reason=( - "known: emitted out of schema order in " - "_apply_all_cell_borders (pims/services/ssa_pipeline.py:1089). " - "Schema requires tblBorders at position 11 in , before " - "; .append() puts it after. Follow-up commit fixes " - "this and removes the xfail mark." - ), -) def test_validate_docx_clean_output_has_no_errors(tmp_path): """build_ssa_report_docx output must validate clean against the Microsoft 365 schema.""" From b9fc03c5b0168c0b069b7e53f61789ab7bc41d5f Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 14 May 2026 14:09:50 +1000 Subject: [PATCH 5/9] pims/ssa: headless LibreOffice render smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone module wrapping `soffice --headless --convert-to pdf` so the pipeline can catch "python-docx accepts it but LibreOffice / Word won't render it" defects that the OOXML schema validator may miss. Mirrors the OOXML validator's soft-skip pattern: returns SmokeResult(available=False) when soffice is not installed; never raises. A PDF that's missing or zero-byte counts as a failure even when soffice exits 0. Module is standalone for this commit — no production wire-in into build_ssa_report_docx or run_ssa_pipeline.py yet. Wiring lands with the --check preflight in Phase 4 (call-site level, not per-build) so the 14 build_ssa_report_docx unit tests don't each spawn soffice. Co-Authored-By: Claude Opus 4.7 (1M context) --- pims/services/ssa_quality/__init__.py | 3 + .../services/ssa_quality/libreoffice_smoke.py | 107 ++++++++++++++++++ tests/test_ssa_libreoffice_smoke.py | 107 ++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 pims/services/ssa_quality/libreoffice_smoke.py create mode 100644 tests/test_ssa_libreoffice_smoke.py diff --git a/pims/services/ssa_quality/__init__.py b/pims/services/ssa_quality/__init__.py index e13742a..0d096da 100644 --- a/pims/services/ssa_quality/__init__.py +++ b/pims/services/ssa_quality/__init__.py @@ -10,6 +10,7 @@ - preflight (Phase 4) — --check folder/inputs validation """ from .determinism import make_docx_deterministic +from .libreoffice_smoke import SmokeResult, smoke_test_docx from .oxml_validator import ValidationError, ValidationResult, validate_docx __all__ = [ @@ -17,4 +18,6 @@ "ValidationError", "ValidationResult", "validate_docx", + "SmokeResult", + "smoke_test_docx", ] diff --git a/pims/services/ssa_quality/libreoffice_smoke.py b/pims/services/ssa_quality/libreoffice_smoke.py new file mode 100644 index 0000000..9512775 --- /dev/null +++ b/pims/services/ssa_quality/libreoffice_smoke.py @@ -0,0 +1,107 @@ +"""Headless LibreOffice round-trip smoke test for generated docx files. + +Catches "Word can open it but other consumers can't" issues by feeding +each built docx through LibreOffice's command-line PDF converter. If +soffice exits non-zero — or exits 0 without producing a non-empty PDF — +the docx has something wrong that python-docx and OpenXmlValidator both +missed. + +Mirrors the OOXML validator's soft-skip pattern: when LibreOffice is +not installed, the smoke check returns ``SmokeResult(available=False)`` +with a populated ``skip_reason`` rather than failing the build. +""" +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + + +# Probe in order: absolute Windows install paths first, then PATH lookup +# for soffice / soffice.exe. Matches rpd-ssa-builder's candidate list. +_LIBREOFFICE_CANDIDATES = ( + r"C:\Program Files\LibreOffice\program\soffice.exe", + r"C:\Program Files (x86)\LibreOffice\program\soffice.exe", + "/usr/bin/soffice", + "/usr/bin/libreoffice", + "soffice", + "soffice.exe", +) +_TIMEOUT_SECONDS = 180 + + +@dataclass(frozen=True) +class SmokeResult: + available: bool # soffice reachable + success: bool # True iff conversion produced a non-empty PDF + skip_reason: str = "" # populated when available=False + failure_detail: str = "" # populated when available=True and success=False + + +def _find_soffice() -> str | None: + """Resolve the soffice executable from the candidate list.""" + for candidate in _LIBREOFFICE_CANDIDATES: + if Path(candidate).is_file(): + return candidate + which = shutil.which(candidate) + if which: + return which + return None + + +def smoke_test_docx(docx_path: Path) -> SmokeResult: + """Convert ``docx_path`` to PDF via headless LibreOffice. Never raises.""" + soffice = _find_soffice() + if soffice is None: + return SmokeResult( + available=False, success=False, + skip_reason=( + "LibreOffice not found on PATH or in standard install " + "locations. Install LibreOffice to enable the headless " + "render smoke test." + ), + ) + + try: + with tempfile.TemporaryDirectory(prefix="ssa-smoke-") as tmp: + result = subprocess.run( + [soffice, "--headless", "--convert-to", "pdf", + "--outdir", tmp, str(docx_path)], + capture_output=True, text=True, timeout=_TIMEOUT_SECONDS, + ) + if result.returncode != 0: + return SmokeResult( + available=True, success=False, + failure_detail=( + f"exit {result.returncode}; " + f"stdout={result.stdout.strip()!r}; " + f"stderr={result.stderr.strip()!r}" + ), + ) + # soffice has been observed to exit 0 without producing a PDF + # when the source docx is sufficiently malformed; assert a + # non-empty file actually landed in the temp dir. + pdfs = list(Path(tmp).glob("*.pdf")) + if not pdfs: + return SmokeResult( + available=True, success=False, + failure_detail="soffice exit 0 but no PDF produced", + ) + if pdfs[0].stat().st_size == 0: + return SmokeResult( + available=True, success=False, + failure_detail=f"PDF produced but empty: {pdfs[0].name}", + ) + return SmokeResult(available=True, success=True) + except subprocess.TimeoutExpired: + return SmokeResult( + available=True, success=False, + failure_detail=f"LibreOffice timed out after {_TIMEOUT_SECONDS}s", + ) + except (OSError, subprocess.SubprocessError) as e: + return SmokeResult( + available=True, success=False, + failure_detail=f"LibreOffice invocation failed: {type(e).__name__}: {e}", + ) diff --git a/tests/test_ssa_libreoffice_smoke.py b/tests/test_ssa_libreoffice_smoke.py new file mode 100644 index 0000000..7c8a6aa --- /dev/null +++ b/tests/test_ssa_libreoffice_smoke.py @@ -0,0 +1,107 @@ +"""Phase 3 headless LibreOffice smoke-test tests. + +Three scenarios: +1. A clean build_ssa_report_docx output round-trips through soffice + cleanly (available=True, success=True, no failure_detail). +2. A docx with its word/document.xml removed produces an + available=True, success=False result (soffice can't render it). +3. When soffice isn't reachable the smoke check returns + available=False with a populated skip_reason. +""" +from __future__ import annotations + +import zipfile +from pathlib import Path + +import pytest +from PIL import Image + +from pims.services.ssa_pipeline import ( + EnrichedRow, + ObservationRow, + build_ssa_report_docx, +) +from pims.services.ssa_quality import libreoffice_smoke as _smoke +from pims.services.ssa_quality.libreoffice_smoke import ( + SmokeResult, + _find_soffice, + smoke_test_docx, +) + + +_SOFFICE_MISSING = _find_soffice() is None + + +def _save_jpeg(path: Path, size=(800, 600), color="red") -> Path: + Image.new("RGB", size, color).save(path, "JPEG") + return path + + +def _build_minimal_report(out: Path) -> None: + photos_dir = out.parent / "photos" + photos_dir.mkdir(exist_ok=True) + p = _save_jpeg(photos_dir / "a.jpg", (800, 600)) + obs = ObservationRow( + csv_row=1, timestamp_raw="", timestamp_iso=None, + observation_text="x", csv_filename="a.jpg", + resolved_filename="a.jpg", resolved_path=p, + ) + rows = [EnrichedRow(obs=obs, finding="Top rail missing.", + conformance_status="NCR", legal_ref="WHS Reg cl.79")] + build_ssa_report_docx( + rows=rows, + site_address="12 Test St", + audit_date_ddmmyyyy="01/05/2026", + narrative_summary="Audit summary.", + output_path=out, + prepared_by="Alan Richardson", + ) + + +@pytest.mark.skipif(_SOFFICE_MISSING, reason="LibreOffice not installed on this host") +def test_smoke_test_clean_docx_succeeds(tmp_path): + """build_ssa_report_docx output must convert to PDF via headless soffice.""" + out = tmp_path / "r.docx" + _build_minimal_report(out) + result = smoke_test_docx(out) + assert result.available, result.skip_reason + assert result.success, result.failure_detail + assert result.failure_detail == "" + + +@pytest.mark.skipif(_SOFFICE_MISSING, reason="LibreOffice not installed on this host") +def test_smoke_test_corrupt_docx_fails_cleanly(tmp_path): + """A docx missing its document body part must produce a clean + SmokeResult(success=False), not raise. + + Strips ``word/document.xml`` from a valid build_ssa_report_docx + output — leaves the zip structurally valid but with no document + body for soffice to render. soffice should fail and the smoke + check should report it via failure_detail. + """ + out = tmp_path / "r.docx" + _build_minimal_report(out) + + broken = tmp_path / "r-broken.docx" + with zipfile.ZipFile(out, "r") as zin, zipfile.ZipFile(broken, "w") as zout: + for item in zin.infolist(): + if item.filename == "word/document.xml": + continue # strip the body + zout.writestr(item, zin.read(item.filename)) + + result = smoke_test_docx(broken) + assert result.available, result.skip_reason + assert not result.success + assert result.failure_detail # populated with diagnostic + + +def test_smoke_test_soft_skips_when_soffice_missing(tmp_path, monkeypatch): + """When `_find_soffice` returns None the smoke check must return + available=False with a populated skip_reason rather than raising.""" + monkeypatch.setattr(_smoke, "_find_soffice", lambda: None) + out = tmp_path / "r.docx" + out.write_bytes(b"") # never read because we soft-skip first + result = smoke_test_docx(out) + assert isinstance(result, SmokeResult) + assert result.available is False + assert "libreoffice" in result.skip_reason.lower() From c73223b19fd6e5d9ce974cc93c15b031ffa18f49 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 14 May 2026 14:14:32 +1000 Subject: [PATCH 6/9] pims/ssa: preflight checks for audit folder inputs Gatekeeper-native preflight module. Checks: - folder name matches YYYY-MM-DD-{RPD,SDG}[-NN] - Evidence_Master.csv exists in folder root - Evidence_Master.csv has the (timestamp, observation, filename) headers - Evidence_Master.csv has >= 1 data row - folder contains >= 1 .jpg / .jpeg / .png - SSA report template readable at configured path - LibreOffice reachable (informational) - dotnet reachable (informational) - ANTHROPIC_API_KEY set (informational) Borrows the CheckResult / run_preflight / format_results shape from rpd-ssa-builder's src/preflight.py but replaces all RPD-specific checks (SafetyCulture docx, score pattern, Site_Visit_Report xlsx) with the gatekeeper input contract. Module is standalone for this commit. --check CLI wiring into run_ssa_pipeline.py lands in the follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- pims/services/ssa_quality/__init__.py | 4 + pims/services/ssa_quality/preflight.py | 215 +++++++++++++++++++++++++ tests/test_ssa_preflight.py | 130 +++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 pims/services/ssa_quality/preflight.py create mode 100644 tests/test_ssa_preflight.py diff --git a/pims/services/ssa_quality/__init__.py b/pims/services/ssa_quality/__init__.py index 0d096da..64999a0 100644 --- a/pims/services/ssa_quality/__init__.py +++ b/pims/services/ssa_quality/__init__.py @@ -12,6 +12,7 @@ from .determinism import make_docx_deterministic from .libreoffice_smoke import SmokeResult, smoke_test_docx from .oxml_validator import ValidationError, ValidationResult, validate_docx +from .preflight import CheckResult, format_results, run_preflight __all__ = [ "make_docx_deterministic", @@ -20,4 +21,7 @@ "validate_docx", "SmokeResult", "smoke_test_docx", + "CheckResult", + "format_results", + "run_preflight", ] diff --git a/pims/services/ssa_quality/preflight.py b/pims/services/ssa_quality/preflight.py new file mode 100644 index 0000000..aa2230f --- /dev/null +++ b/pims/services/ssa_quality/preflight.py @@ -0,0 +1,215 @@ +"""--check preflight for the SSA pipeline. + +Runs every check against an audit folder and reports them all — never +bails on the first failure. Hard failures block the build; informational +items print a warning only. + +Mirrors rpd-ssa-builder's preflight shape (``CheckResult`` + ``run_preflight`` ++ ``format_results``) but with gatekeeper-native checks for the SSA input +contract: folder name pattern, ``Evidence_Master.csv``, images in folder +root, SSA report template, and environment tools (LibreOffice, dotnet, +``ANTHROPIC_API_KEY``). +""" +from __future__ import annotations + +import csv +import os +import re +from dataclasses import dataclass +from pathlib import Path + +from pims.services.ssa_pipeline import SSA_REPORT_TEMPLATE +from pims.services.ssa_quality.libreoffice_smoke import _find_soffice +from pims.services.ssa_quality.oxml_validator import _find_dotnet + + +# Folder name contract — mirrors run_ssa_pipeline.py's _FOLDER_RE. +_FOLDER_RE = re.compile( + r"^(\d{4})-(\d{2})-(\d{2})-(RPD|SDG)(?:-(\d{2}))?$" +) +_IMAGE_EXTS = {".jpg", ".jpeg", ".png"} +_EVIDENCE_CSV_NAME = "Evidence_Master.csv" +# Mirrors ssa_pipeline._REQUIRED_HEADER. Kept locally so a rename in +# ssa_pipeline.py would surface as a drift-checker finding rather than +# silently breaking preflight. +_EXPECTED_CSV_HEADERS = ("timestamp", "observation", "filename") + + +@dataclass +class CheckResult: + label: str + passed: bool + detail: str = "" + informational: bool = False # True = doesn't block the build + + +def _ok(label: str, detail: str = "") -> CheckResult: + return CheckResult(label=label, passed=True, detail=detail) + + +def _fail(label: str, detail: str, informational: bool = False) -> CheckResult: + return CheckResult( + label=label, passed=False, detail=detail, informational=informational + ) + + +# --------------------------------------------------------------------------- +# Individual checks +# --------------------------------------------------------------------------- + +def _check_folder_name(folder: Path) -> CheckResult: + label = "Folder name matches YYYY-MM-DD-{RPD,SDG}[-NN]" + m = _FOLDER_RE.match(folder.name) + if not m: + return _fail(label, f"got {folder.name!r}") + detail = f"client={m.group(4)}" + if m.group(5): + detail += f" sub_id={m.group(5)}" + return _ok(label, detail) + + +def _check_evidence_csv(folder: Path) -> list[CheckResult]: + p = folder / _EVIDENCE_CSV_NAME + exists_label = f"{_EVIDENCE_CSV_NAME} exists in folder root" + headers_label = ( + f"{_EVIDENCE_CSV_NAME} has expected headers " + "(timestamp, observation, filename)" + ) + rows_label = f"{_EVIDENCE_CSV_NAME} has at least one data row" + + results: list[CheckResult] = [] + if not p.is_file(): + results.append(_fail(exists_label, "missing")) + results.append(_fail(headers_label, "(skipped — csv missing)")) + results.append(_fail(rows_label, "(skipped — csv missing)")) + return results + results.append(_ok(exists_label, f"{p.stat().st_size} bytes")) + + try: + text = p.read_text(encoding="utf-8-sig", errors="replace") + except OSError as e: + results.append(_fail(headers_label, f"unreadable: {e}")) + results.append(_fail(rows_label, "(skipped — csv unreadable)")) + return results + + rows = list(csv.reader(text.splitlines())) + if not rows: + results.append(_fail(headers_label, "csv is empty")) + results.append(_fail(rows_label, "(skipped — csv empty)")) + return results + header = tuple(c.strip().lower() for c in rows[0]) + if header == _EXPECTED_CSV_HEADERS: + results.append(_ok(headers_label)) + else: + results.append(_fail(headers_label, f"got {header}")) + data_rows = [r for r in rows[1:] if any(c.strip() for c in r)] + if data_rows: + results.append(_ok(rows_label, f"{len(data_rows)} row(s)")) + else: + results.append(_fail(rows_label, "no non-blank rows after header")) + return results + + +def _check_images(folder: Path) -> CheckResult: + label = "Folder contains at least one image (.jpg / .jpeg / .png)" + imgs = [ + p for p in folder.iterdir() + if p.is_file() and p.suffix.lower() in _IMAGE_EXTS + ] + if imgs: + return _ok(label, f"{len(imgs)} image(s)") + return _fail(label, "none found in folder root") + + +def _check_template() -> CheckResult: + label = "SSA report template readable at configured path" + if SSA_REPORT_TEMPLATE.is_file(): + return _ok(label, str(SSA_REPORT_TEMPLATE)) + return _fail(label, f"missing: {SSA_REPORT_TEMPLATE}") + + +def _check_libreoffice() -> CheckResult: + label = "LibreOffice reachable (for render smoke test)" + soffice = _find_soffice() + if soffice: + return _ok(label, soffice) + return _fail( + label, + "not found — install LibreOffice to enable the smoke test", + informational=True, + ) + + +def _check_dotnet() -> CheckResult: + label = "dotnet reachable (for OOXML schema validation)" + dotnet = _find_dotnet() + if dotnet: + return _ok(label, dotnet) + return _fail( + label, + "not found — install .NET 8 SDK to enable schema validation", + informational=True, + ) + + +def _check_anthropic_key() -> CheckResult: + label = "ANTHROPIC_API_KEY set (for vision enrichment)" + if os.environ.get("ANTHROPIC_API_KEY"): + return _ok(label) + return _fail( + label, + "unset — vision pass will fail unless --no-enrich is used", + informational=True, + ) + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + +def run_preflight(folder: Path | str) -> tuple[list[CheckResult], bool]: + """Run every check. Returns ``(results, hard_pass)``. + + ``hard_pass`` is ``True`` iff all non-informational checks passed. + """ + folder = Path(folder) + results: list[CheckResult] = [] + results.append(_check_folder_name(folder)) + results.extend(_check_evidence_csv(folder)) + results.append(_check_images(folder)) + results.append(_check_template()) + results.append(_check_libreoffice()) + results.append(_check_dotnet()) + results.append(_check_anthropic_key()) + hard_pass = all(r.passed for r in results if not r.informational) + return results, hard_pass + + +def format_results(results: list[CheckResult], use_colour: bool = True) -> str: + """Render the check list with [X] / [!] / [ ] markers and optional colour.""" + if use_colour: + try: + from colorama import Fore, Style, init as _init + _init() + green = Fore.GREEN + red = Fore.RED + yellow = Fore.YELLOW + reset = Style.RESET_ALL + except ImportError: + green = red = yellow = reset = "" + else: + green = red = yellow = reset = "" + + lines: list[str] = [] + for r in results: + if r.passed: + mark = f"{green}[X]{reset}" + elif r.informational: + mark = f"{yellow}[!]{reset}" + else: + mark = f"{red}[ ]{reset}" + line = f" {mark} {r.label}" + if r.detail: + line += f" -- {r.detail}" + lines.append(line) + return "\n".join(lines) diff --git a/tests/test_ssa_preflight.py b/tests/test_ssa_preflight.py new file mode 100644 index 0000000..8e7dd29 --- /dev/null +++ b/tests/test_ssa_preflight.py @@ -0,0 +1,130 @@ +"""Phase 4 preflight tests for the SSA pipeline. + +Covers all gatekeeper-native checks via a "make good folder, mutate one +aspect, assert blocks" pattern. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest +from PIL import Image + +from pims.services.ssa_quality.preflight import ( + CheckResult, + format_results, + run_preflight, +) + + +def _make_good_folder(tmp_path: Path) -> Path: + folder = tmp_path / "2026-05-01-SDG-01" + folder.mkdir() + (folder / "Evidence_Master.csv").write_text( + "timestamp,observation,filename\n" + "2026-05-01_09-00-00,Top rail missing,a.jpg\n", + encoding="utf-8", + ) + Image.new("RGB", (400, 300), "red").save(folder / "a.jpg", "JPEG") + return folder + + +def test_preflight_clean_folder_passes(tmp_path, monkeypatch): + """A folder with the canonical inputs passes hard checks. Tool + availability is informational so missing tools don't block.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") # keep informational green + folder = _make_good_folder(tmp_path) + results, hard_pass = run_preflight(folder) + assert hard_pass, format_results(results, use_colour=False) + # All hard checks (non-informational) must be green. + for r in results: + if not r.informational: + assert r.passed, f"hard check failed: {r.label} — {r.detail}" + + +def test_preflight_bad_folder_name_blocks(tmp_path): + """A folder whose name doesn't match the contract must block.""" + folder = tmp_path / "bad-folder-name" + folder.mkdir() + results, hard_pass = run_preflight(folder) + assert not hard_pass + name_check = next(r for r in results if "Folder name" in r.label) + assert not name_check.passed + assert not name_check.informational + + +def test_preflight_missing_csv_blocks(tmp_path): + """Missing Evidence_Master.csv must block and the dependent checks + must show (skipped) detail, not silently pass.""" + folder = _make_good_folder(tmp_path) + (folder / "Evidence_Master.csv").unlink() + results, hard_pass = run_preflight(folder) + assert not hard_pass + csv_checks = [r for r in results if "Evidence_Master" in r.label] + assert len(csv_checks) == 3 # exists / headers / rows + assert not any(r.passed for r in csv_checks) + assert any("skipped" in r.detail for r in csv_checks[1:]) + + +def test_preflight_bad_csv_headers_blocks(tmp_path): + """A CSV with the wrong header row must fail the header check but + still pass the existence check.""" + folder = _make_good_folder(tmp_path) + (folder / "Evidence_Master.csv").write_text( + "wrong,header,names\nrow,row,row\n", encoding="utf-8", + ) + results, hard_pass = run_preflight(folder) + assert not hard_pass + exists_check = next(r for r in results if "exists in folder root" in r.label) + header_check = next(r for r in results if "expected headers" in r.label) + assert exists_check.passed + assert not header_check.passed + assert "wrong" in header_check.detail + + +def test_preflight_empty_csv_blocks(tmp_path): + """Header-only CSV (no data rows) must block.""" + folder = _make_good_folder(tmp_path) + (folder / "Evidence_Master.csv").write_text( + "timestamp,observation,filename\n", encoding="utf-8", + ) + results, hard_pass = run_preflight(folder) + assert not hard_pass + row_check = next(r for r in results if "at least one data row" in r.label) + assert not row_check.passed + + +def test_preflight_no_images_blocks(tmp_path): + """No images in the folder root must block.""" + folder = _make_good_folder(tmp_path) + (folder / "a.jpg").unlink() + results, hard_pass = run_preflight(folder) + assert not hard_pass + img_check = next(r for r in results if "at least one image" in r.label) + assert not img_check.passed + + +def test_preflight_missing_anthropic_key_is_informational(tmp_path, monkeypatch): + """Missing ANTHROPIC_API_KEY warns but does not block — hard_pass + stays True since the build can still run with --no-enrich.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + folder = _make_good_folder(tmp_path) + results, hard_pass = run_preflight(folder) + assert hard_pass + key_check = next(r for r in results if "ANTHROPIC_API_KEY" in r.label) + assert not key_check.passed + assert key_check.informational + + +def test_format_results_renders_all_marks(tmp_path, monkeypatch): + """format_results must produce one of [X] / [!] / [ ] for every check.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + folder = _make_good_folder(tmp_path) + results, _ = run_preflight(folder) + rendered = format_results(results, use_colour=False) + assert "[X]" in rendered + for line in rendered.splitlines(): + stripped = line.strip() + if not stripped: + continue + assert stripped.startswith(("[X]", "[!]", "[ ]")), stripped From 572000c9dce35c35a97fc686fe78ff66da6db9ed Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 14 May 2026 14:24:16 +1000 Subject: [PATCH 7/9] pims/ssa: wire --check preflight + LibreOffice smoke into run_ssa_pipeline --check runs run_preflight on the audit folder and exits without building deliverables. Hard failures return exit 1; informational warnings don't block. After a successful full or from-state build, the report docx is smoke-tested via headless LibreOffice. Wire-in is at CLI-main level (not inside run_once) so the build_ssa_report_docx unit tests don't each spawn soffice. Also fixes a latent circular import: preflight.py now lazy-imports SSA_REPORT_TEMPLATE inside _check_template. Top-level import was OK when tests entered via test_ssa_preflight.py but blew up when entry was run_ssa_pipeline.py (different cycle traversal order). Co-Authored-By: Claude Opus 4.7 (1M context) --- pims/scripts/run_ssa_pipeline.py | 38 ++++++++++++++++++++++++++ pims/services/ssa_quality/preflight.py | 6 +++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/pims/scripts/run_ssa_pipeline.py b/pims/scripts/run_ssa_pipeline.py index aed8874..f88b854 100644 --- a/pims/scripts/run_ssa_pipeline.py +++ b/pims/scripts/run_ssa_pipeline.py @@ -1442,6 +1442,13 @@ def run_once( def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(prog="run_ssa_pipeline") ap.add_argument("folder", type=Path, help="audit evidence folder") + ap.add_argument( + "--check", action="store_true", + help=( + "run preflight checks on the audit folder and exit " + "without building any deliverables" + ), + ) ap.add_argument("--prepared-by", default="Alan Richardson") ap.add_argument("--ignore-freeze", action="store_true") ap.add_argument( @@ -1522,6 +1529,20 @@ def main(argv: list[str] | None = None) -> int: format="%(asctime)s %(levelname)s %(name)s | %(message)s", ) + if args.check: + from pims.services.ssa_quality import format_results, run_preflight + results, hard_pass = run_preflight(args.folder) + print(format_results(results)) + print() + info_fails = [r for r in results if not r.passed and r.informational] + hard_fails = [r for r in results if not r.passed and not r.informational] + if hard_pass: + extra = f" ({len(info_fails)} informational warning(s))" if info_fails else "" + print(f"OK - ready to build{extra}") + return 0 + print(f"BLOCKED - {len(hard_fails)} required check(s) failed") + return 1 + try: phase_flags = ( args.enrich_only, args.from_state, args.from_report, args.resume, @@ -1608,6 +1629,23 @@ def main(argv: list[str] | None = None) -> int: print(f"outputs: {len(payload['outputs'])} files in {args.folder}") for name in payload["outputs"]: print(f" - {name}") + # LibreOffice render smoke test on the report docx (soft-skip if + # soffice not installed). Runs once per CLI invocation; build_ssa_ + # report_docx unit tests don't pay this cost. + report_name = next( + (n for n in payload.get("outputs", []) + if n.startswith("Site-Safety-Audit-Report-") and n.endswith(".docx")), + None, + ) + if report_name: + from pims.services.ssa_quality import smoke_test_docx + smoke = smoke_test_docx(args.folder / report_name) + if not smoke.available: + print("libreoffice smoke: skipped (LibreOffice not found)") + elif smoke.success: + print("libreoffice smoke: passed") + else: + print(f"libreoffice smoke: FAILED - {smoke.failure_detail}") return 0 diff --git a/pims/services/ssa_quality/preflight.py b/pims/services/ssa_quality/preflight.py index aa2230f..fb29d4d 100644 --- a/pims/services/ssa_quality/preflight.py +++ b/pims/services/ssa_quality/preflight.py @@ -18,10 +18,13 @@ from dataclasses import dataclass from pathlib import Path -from pims.services.ssa_pipeline import SSA_REPORT_TEMPLATE from pims.services.ssa_quality.libreoffice_smoke import _find_soffice from pims.services.ssa_quality.oxml_validator import _find_dotnet +# ``SSA_REPORT_TEMPLATE`` is imported lazily inside ``_check_template``; +# top-level import would create a cycle because ``ssa_pipeline`` itself +# imports from ``ssa_quality`` (which imports this module). + # Folder name contract — mirrors run_ssa_pipeline.py's _FOLDER_RE. _FOLDER_RE = re.compile( @@ -123,6 +126,7 @@ def _check_images(folder: Path) -> CheckResult: def _check_template() -> CheckResult: label = "SSA report template readable at configured path" + from pims.services.ssa_pipeline import SSA_REPORT_TEMPLATE if SSA_REPORT_TEMPLATE.is_file(): return _ok(label, str(SSA_REPORT_TEMPLATE)) return _fail(label, f"missing: {SSA_REPORT_TEMPLATE}") From 8ec26f85f083722f48b9c8a92c11fbc65826a26d Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 14 May 2026 14:27:23 +1000 Subject: [PATCH 8/9] pims/ssa: minimal drift checker for paired SSA constants tools/check_ssa_drift.py exits 0 if all paired constants agree, 1 otherwise. v1 covers the one drift case introduced in Phase 4a: ssa_pipeline._REQUIRED_HEADER must equal preflight._EXPECTED_CSV_HEADERS. A full drift checker on the rpd-ssa-builder model would need a refactor-first pass to extract gatekeeper's inline placeholder dicts (build_ssa_report_docx body_replacements / footer_replacements) into a named constants surface. Deferred until that placeholder convention matures. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_ssa_drift_checker.py | 32 +++++++++++++++ tools/check_ssa_drift.py | 69 +++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 tests/test_ssa_drift_checker.py create mode 100644 tools/check_ssa_drift.py diff --git a/tests/test_ssa_drift_checker.py b/tests/test_ssa_drift_checker.py new file mode 100644 index 0000000..d491156 --- /dev/null +++ b/tests/test_ssa_drift_checker.py @@ -0,0 +1,32 @@ +"""Phase 5 drift checker tests.""" +from __future__ import annotations + +import sys +from pathlib import Path + + +def test_drift_checker_clean_state_returns_zero(capsys, monkeypatch): + """Current main passes — no drift between paired constants.""" + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools")) + import check_ssa_drift # noqa: E402 + rc = check_ssa_drift.main() + out = capsys.readouterr().out + assert rc == 0 + assert "[X]" in out + assert "no drift" in out.lower() + + +def test_drift_checker_flags_csv_header_mismatch(capsys, monkeypatch): + """Forcing the preflight constant to diverge from ssa_pipeline's + must trip the drift check.""" + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools")) + import check_ssa_drift # noqa: E402 + monkeypatch.setattr( + check_ssa_drift, "_EXPECTED_CSV_HEADERS", + ("timestamp", "observation", "DIVERGED"), + ) + rc = check_ssa_drift.main() + out = capsys.readouterr().out + assert rc == 1 + assert "DRIFT" in out + assert "DIVERGED" in out diff --git a/tools/check_ssa_drift.py b/tools/check_ssa_drift.py new file mode 100644 index 0000000..4fa5a6b --- /dev/null +++ b/tools/check_ssa_drift.py @@ -0,0 +1,69 @@ +"""SSA drift checker. + +Exits 0 if no drift; 1 if drift detected. Run with: + + python tools/check_ssa_drift.py + +Drift is the silent divergence between paired constants in different +files. Today this checker covers one case — the Evidence_Master.csv +header tuple, which is duplicated in: + + - pims/services/ssa_pipeline.py ``_REQUIRED_HEADER`` + - pims/services/ssa_quality/preflight.py ``_EXPECTED_CSV_HEADERS`` + +Both must stay aligned; the preflight check would silently start +accepting CSVs the pipeline can't parse if they drift. + +Future checks can be added by appending to ``_CHECKS`` — each is a +``(label, callable)`` returning ``None`` on pass or a string detail on +fail. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +# Add repo root to sys.path so this script can be run from anywhere. +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from pims.services.ssa_pipeline import _REQUIRED_HEADER +from pims.services.ssa_quality.preflight import _EXPECTED_CSV_HEADERS + + +def _check_csv_headers() -> str | None: + if _REQUIRED_HEADER == _EXPECTED_CSV_HEADERS: + return None + return ( + f"ssa_pipeline._REQUIRED_HEADER={_REQUIRED_HEADER!r} " + f"!= preflight._EXPECTED_CSV_HEADERS={_EXPECTED_CSV_HEADERS!r}" + ) + + +_CHECKS: tuple[tuple[str, callable], ...] = ( + ("Evidence_Master.csv header tuple matches across modules", + _check_csv_headers), +) + + +def main() -> int: + failures: list[tuple[str, str]] = [] + for label, fn in _CHECKS: + detail = fn() + if detail is None: + print(f" [X] {label}") + else: + print(f" [ ] {label}") + print(f" {detail}") + failures.append((label, detail)) + print() + if not failures: + print(f"OK - no drift across {len(_CHECKS)} check(s)") + return 0 + print(f"DRIFT - {len(failures)} of {len(_CHECKS)} check(s) failed") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 9e41015d8c3e063eead0f0eb682381ca1fbdd285 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 14 May 2026 14:28:28 +1000 Subject: [PATCH 9/9] docs/changelog: SSA pipeline CHANGELOG (Phases 1-5) Scoped CHANGELOG-SSA.md covering the determinism / OOXML validator / LibreOffice smoke / preflight / drift-checker landings plus the tblBorders schema-order fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/CHANGELOG-SSA.md | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/CHANGELOG-SSA.md diff --git a/docs/CHANGELOG-SSA.md b/docs/CHANGELOG-SSA.md new file mode 100644 index 0000000..83d7412 --- /dev/null +++ b/docs/CHANGELOG-SSA.md @@ -0,0 +1,47 @@ +# CHANGELOG — SSA pipeline + +Scoped changelog for the Site Safety Audit pipeline and its quality +guards. Newest at top. + +## 2026-05-14 + +### Added + +- **Deterministic post-pass** on `build_ssa_report_docx` output. Two + runs over the same inputs now produce byte-identical docx files. + Lifted from rpd-ssa-builder. See `pims/services/ssa_quality/determinism.py`. +- **OOXML schema validation** on every report docx via Microsoft's + OpenXmlValidator (wrapped in a small .NET CLI reused from + rpd-ssa-builder's cache at `%LOCALAPPDATA%\rpd-ssa-validator\`). + Errors surface on the diagnostics dict and as log warnings; the + build does not abort on findings. Soft-skips when dotnet is + unreachable. +- **Headless LibreOffice render smoke test** after each successful + full or from-state build (CLI-main level — does not run during + build_ssa_report_docx unit tests). Catches "python-docx accepts + it but LibreOffice / Word won't render it" defects that the + schema validator may miss. Soft-skips when soffice is not installed. +- **`--check` preflight CLI flag** on `run_ssa_pipeline.py`. Verifies + folder name pattern, Evidence_Master.csv presence and shape, + images in folder root, template readability, and environment + tool availability (LibreOffice, dotnet, ANTHROPIC_API_KEY). + Hard failures return exit 1; informational warnings don't block. +- **Drift checker** at `tools/check_ssa_drift.py` for paired + constants across SSA modules. v1 covers the one drift case + introduced when preflight duplicated `_REQUIRED_HEADER` as + `_EXPECTED_CSV_HEADERS`. +- **Byte-hash regression test** in `tests/test_ssa_determinism.py` + pins the SHA-256 contract for a fixture build so future + formatting drift on the docx output is caught by CI. + +### Fixed + +- **`` schema-order bug** in `_apply_all_cell_borders` + (`pims/services/ssa_pipeline.py:1089`). The function was emitting + `` at the end of `` via plain `.append()`; + the OOXML schema places `tblBorders` at position 11, before + `` (position 15) which every template table already + carries. Tripped four `Sch_UnexpectedElementContentExpectingComplex` + errors per built docx. Word's recovery prompt tolerates this; + OpenXmlValidator does not. Now inserts before the first child + whose tag falls in the positions-after-tblBorders set.