ApplyCRs / scripts /verify_applied.py
heymenn's picture
add visual verifier and fixes
5ecf925
Raw
History Blame Contribute Delete
7.22 kB
#!/usr/bin/env python3
"""
verify_applied.py β€” Independent content-verification pass for applied CRs.
Two checks, both operating on data already produced by the existing
pipeline (cr_parser.parse_cr / ts_applicator.apply_manifest). Neither
changes parsing or applying behaviour β€” this is purely additive.
Check A β€” scan_revision_marks(): classifies every OOXML tracked-change
element in a CR DOCX (w:ins/w:del content marks, plus any other
CT_TrackChange-family element such as w:moveFrom/w:moveTo or
w:*PrChange/w:cellMerge that this pipeline has no apply-path
for), used to flag "CR has tracked changes but parser extracted
0" and "CR has a revision-mark type nothing here understands".
Check B β€” verify_manifest_applied(): confirms each parsed change's new
text is actually present in the saved output TS.
"""
import sys
from pathlib import Path
import docx
from docx.oxml.ns import qn
from lxml import etree
sys.path.insert(0, str(Path(__file__).parent))
from cr_parser import (
_is_rpr_ins,
_is_inserted_para,
_is_fully_inserted_tbl,
_is_skip_marker,
)
from ts_applicator import _norm, _norm_ws, _section_range
_MIN_SNIPPET_LEN = 4 # after whitespace-stripped normalisation
# ── Check A β€” generalized revision-mark scan ───────────────────────────────────
def scan_revision_marks(cr_docx_path):
"""
Classify every OOXML tracked-change element in a CR DOCX.
Every element that extends CT_TrackChange (the OOXML base type for
revision marks) carries a w:author attribute β€” not just w:ins/w:del, but
also w:moveFrom/w:moveTo, w:rPrChange/w:pPrChange/w:tblPrChange/
w:tblGridChange/w:trPrChange/w:tcPrChange/w:sectPrChange, and
w:cellIns/w:cellDel/w:cellMerge. Walking generically by that attribute
(rather than hardcoding a w:ins/w:del tag lookup) means no revision-mark
type β€” including ones not named here β€” goes silently unnoticed.
Returns {'handled': int, 'unhandled': {tag_localname: count, ...}}.
- handled: w:ins/w:del content marks (excludes rPr-nested paragraph-
mark markers, same filter cr_parser._is_rpr_ins applies).
- unhandled: any other tracked-change element found. cr_parser.py /
ts_applicator.py have no code path for these β€” they will
NOT be reflected in the output TS.
"""
doc = docx.Document(str(cr_docx_path))
body = doc.element.body
handled = 0
unhandled = {}
for elem in body.iter():
if elem.get(qn('w:author')) is None:
continue
local = etree.QName(elem).localname
if local in ('ins', 'del'):
if not _is_rpr_ins(elem):
handled += 1
else:
unhandled[local] = unhandled.get(local, 0) + 1
return {'handled': handled, 'unhandled': unhandled}
# ── Check B β€” content-presence check ──────────────────────────────────────────
def _section_replace_ins_snippets(elements_xml):
"""From a section_replace's serialized elements, collect w:t text of the
ins-block elements only (skip del-block and separator elements)."""
snippets = []
for xml_str in elements_xml:
elem = etree.fromstring(xml_str)
tag = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag
if tag == 'p':
is_ins = _is_inserted_para(elem)
elif tag == 'tbl':
is_ins = _is_fully_inserted_tbl(elem)
else:
is_ins = False
if is_ins:
snippets.append(''.join(t.text or '' for t in elem.iter(qn('w:t'))))
return snippets
def _extract_snippets(change):
"""Return the expected inserted-text snippet(s) for one manifest change."""
ctype = change.get('type')
if ctype == 'text_replace':
new = change.get('new', '')
return [new] if new else []
if ctype == 'text_insert':
text = change.get('text', '')
return [text] if text else []
if ctype == 'para_insert':
return [p.get('text', '') for p in change.get('paragraphs', [])]
if ctype == 'row_insert':
return [c.get('text', '') for c in change.get('cells', [])]
if ctype == 'section_replace':
return _section_replace_ins_snippets(change.get('elements_xml', []))
return []
def _section_scope_text(doc, section_number):
"""Full w:t text (paragraphs + tables) of a section, or None if not found."""
start, end = _section_range(doc, section_number)
if start is None:
return None
paras = doc.paragraphs
start_elem = paras[start]._element
end_elem = paras[end]._element if end < len(paras) else None
parts = []
in_range = False
for child in doc.element.body:
if child is start_elem:
in_range = True
if in_range:
if end_elem is not None and child is end_elem:
break
parts.append(''.join(t.text or '' for t in child.iter(qn('w:t'))))
return ''.join(parts)
def _snippet_present(snippet, corpus):
if snippet in corpus:
return True
if _norm(snippet) in _norm(corpus):
return True
if _norm_ws(snippet) in _norm_ws(corpus):
return True
return False
def verify_manifest_applied(ts_doc, manifest):
"""Confirm each change's expected new-text snippet(s) are present in ts_doc.
A declared section_number is a hint, not a hard boundary: the real
applicator (ts_applicator.py) falls back to a global search whenever an
anchor/table isn't found in the declared section (see the "using global
match" WARN lines it emits), so content can legitimately land outside
its declared section. To avoid flagging those legitimate placements,
a snippet is only reported missing if it's absent from BOTH the
section-scoped corpus (when available) and the whole document.
Returns a list of 'ERROR verify: ...' lines, one per missing snippet."""
errors = []
whole_doc_corpus = None
for change in manifest:
ctype = change.get('type')
cr_uid = change.get('_cr_uid', '?')
section_number = (change.get('location') or {}).get('section_number', '')
scope_corpus = _section_scope_text(ts_doc, section_number) if section_number else None
for snippet in _extract_snippets(change):
if not snippet or _is_skip_marker(snippet):
continue
if len(_norm_ws(snippet)) < _MIN_SNIPPET_LEN:
continue
if scope_corpus is not None and _snippet_present(snippet, scope_corpus):
continue
if whole_doc_corpus is None:
whole_doc_corpus = ''.join(t.text or '' for t in ts_doc.element.body.iter(qn('w:t')))
if not _snippet_present(snippet, whole_doc_corpus):
errors.append(
f'ERROR verify: [{cr_uid}] {ctype} β€” expected text not found '
f'in output TS: {snippet[:80]!r}'
)
return errors