Spaces:
Running
Running
R6A-R4-R6 four-language qualitative appendix semantic identity and final DOCX layout gate
#43
by GuXiaopeng - opened
- core/production_guard.py +68 -3
- utils/docx_builder.py +74 -5
core/production_guard.py
CHANGED
|
@@ -11,6 +11,8 @@ from dataclasses import dataclass
|
|
| 11 |
from typing import Any
|
| 12 |
|
| 13 |
from docx import Document
|
|
|
|
|
|
|
| 14 |
|
| 15 |
from utils.docx_builder import DocxBuildError, text_to_docx
|
| 16 |
|
|
@@ -54,6 +56,55 @@ def validate_docx_bytes(data: bytes, *, minimum_paragraphs: int = 8) -> None:
|
|
| 54 |
)
|
| 55 |
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
_REFERENCE_HEADING_RE = re.compile(
|
| 59 |
r"(?im)^#\s+(?:References|参考文献|Литература|Пайдаланылған әдебиеттер)\s*$"
|
|
@@ -469,13 +520,21 @@ def build_validated_package(
|
|
| 469 |
validate_markdown_manuscript(english_markdown, language_name="English")
|
| 470 |
validate_markdown_manuscript(translated_markdown, language_name="Target-language")
|
| 471 |
|
|
|
|
| 472 |
try:
|
| 473 |
-
english_docx = text_to_docx(
|
| 474 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
except DocxBuildError as exc:
|
| 476 |
raise OutputValidationError(str(exc)) from exc
|
| 477 |
validate_docx_bytes(english_docx)
|
| 478 |
validate_docx_bytes(translated_docx)
|
|
|
|
|
|
|
|
|
|
| 479 |
|
| 480 |
entries: dict[str, bytes] = {
|
| 481 |
english_filename: english_docx,
|
|
@@ -506,7 +565,13 @@ def build_validated_package(
|
|
| 506 |
f"ZIP file list mismatch: actual={sorted(names)}, expected={sorted(required)}"
|
| 507 |
)
|
| 508 |
for name in (english_filename, translated_filename):
|
| 509 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
except OutputValidationError:
|
| 511 |
raise
|
| 512 |
except Exception as exc:
|
|
|
|
| 11 |
from typing import Any
|
| 12 |
|
| 13 |
from docx import Document
|
| 14 |
+
from docx.enum.section import WD_ORIENT
|
| 15 |
+
from docx.oxml.ns import qn
|
| 16 |
|
| 17 |
from utils.docx_builder import DocxBuildError, text_to_docx
|
| 18 |
|
|
|
|
| 56 |
)
|
| 57 |
|
| 58 |
|
| 59 |
+
def validate_qualitative_appendix_docx_layout(data: bytes, *, label: str = "DOCX") -> None:
|
| 60 |
+
"""Fail closed if the qualitative evidence Appendix lost its layout contract.
|
| 61 |
+
|
| 62 |
+
Validation is intentionally language-blind. The renderer marks the
|
| 63 |
+
semantic Appendix table through structure + ASQ mode, while this final gate
|
| 64 |
+
verifies only the resulting OOXML layout signature.
|
| 65 |
+
"""
|
| 66 |
+
try:
|
| 67 |
+
document = Document(io.BytesIO(data))
|
| 68 |
+
except Exception as exc:
|
| 69 |
+
raise OutputValidationError(f"{label} qualitative layout cannot be inspected: {exc}") from exc
|
| 70 |
+
|
| 71 |
+
orientations = [section.orientation for section in document.sections]
|
| 72 |
+
if not any(value == WD_ORIENT.LANDSCAPE for value in orientations):
|
| 73 |
+
raise OutputValidationError(f"{label} qualitative Appendix did not enter landscape orientation.")
|
| 74 |
+
if not orientations or orientations[-1] != WD_ORIENT.PORTRAIT:
|
| 75 |
+
raise OutputValidationError(f"{label} qualitative Appendix did not return to portrait orientation.")
|
| 76 |
+
|
| 77 |
+
candidates = []
|
| 78 |
+
for table in document.tables:
|
| 79 |
+
if len(table.columns) != 6:
|
| 80 |
+
continue
|
| 81 |
+
layout = table._tbl.tblPr.find(qn("w:tblLayout"))
|
| 82 |
+
if layout is None or layout.get(qn("w:type")) != "fixed":
|
| 83 |
+
continue
|
| 84 |
+
candidates.append(table)
|
| 85 |
+
if len(candidates) != 1:
|
| 86 |
+
raise OutputValidationError(
|
| 87 |
+
f"{label} qualitative Appendix expected exactly one fixed six-column evidence table; found {len(candidates)}."
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
table = candidates[0]
|
| 91 |
+
grid_cols = list(table._tbl.tblGrid.gridCol_lst)
|
| 92 |
+
widths = [int(col.get(qn("w:w")) or 0) for col in grid_cols]
|
| 93 |
+
if len(widths) != 6 or widths[-1] != max(widths) or widths[-1] <= widths[0] * 2:
|
| 94 |
+
raise OutputValidationError(
|
| 95 |
+
f"{label} qualitative Appendix representative-quote column lost its dominant width."
|
| 96 |
+
)
|
| 97 |
+
if not table.rows:
|
| 98 |
+
raise OutputValidationError(f"{label} qualitative Appendix evidence table has no rows.")
|
| 99 |
+
|
| 100 |
+
header_pr = table.rows[0]._tr.get_or_add_trPr()
|
| 101 |
+
if header_pr.find(qn("w:tblHeader")) is None:
|
| 102 |
+
raise OutputValidationError(f"{label} qualitative Appendix header is not configured to repeat across pages.")
|
| 103 |
+
for row in table.rows:
|
| 104 |
+
if row._tr.get_or_add_trPr().find(qn("w:cantSplit")) is None:
|
| 105 |
+
raise OutputValidationError(f"{label} qualitative Appendix contains a row that may split across pages.")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
|
| 109 |
_REFERENCE_HEADING_RE = re.compile(
|
| 110 |
r"(?im)^#\s+(?:References|参考文献|Литература|Пайдаланылған әдебиеттер)\s*$"
|
|
|
|
| 520 |
validate_markdown_manuscript(english_markdown, language_name="English")
|
| 521 |
validate_markdown_manuscript(translated_markdown, language_name="Target-language")
|
| 522 |
|
| 523 |
+
qualitative_mode = str(mode or "").strip().casefold() == "asq"
|
| 524 |
try:
|
| 525 |
+
english_docx = text_to_docx(
|
| 526 |
+
english_markdown, qualitative_appendix=qualitative_mode
|
| 527 |
+
).getvalue()
|
| 528 |
+
translated_docx = text_to_docx(
|
| 529 |
+
translated_markdown, qualitative_appendix=qualitative_mode
|
| 530 |
+
).getvalue()
|
| 531 |
except DocxBuildError as exc:
|
| 532 |
raise OutputValidationError(str(exc)) from exc
|
| 533 |
validate_docx_bytes(english_docx)
|
| 534 |
validate_docx_bytes(translated_docx)
|
| 535 |
+
if qualitative_mode:
|
| 536 |
+
validate_qualitative_appendix_docx_layout(english_docx, label="English DOCX")
|
| 537 |
+
validate_qualitative_appendix_docx_layout(translated_docx, label="Target-language DOCX")
|
| 538 |
|
| 539 |
entries: dict[str, bytes] = {
|
| 540 |
english_filename: english_docx,
|
|
|
|
| 565 |
f"ZIP file list mismatch: actual={sorted(names)}, expected={sorted(required)}"
|
| 566 |
)
|
| 567 |
for name in (english_filename, translated_filename):
|
| 568 |
+
archived_docx = archive.read(name)
|
| 569 |
+
validate_docx_bytes(archived_docx)
|
| 570 |
+
if qualitative_mode:
|
| 571 |
+
validate_qualitative_appendix_docx_layout(
|
| 572 |
+
archived_docx,
|
| 573 |
+
label="Archived English DOCX" if name == english_filename else "Archived target-language DOCX",
|
| 574 |
+
)
|
| 575 |
except OutputValidationError:
|
| 576 |
raise
|
| 577 |
except Exception as exc:
|
utils/docx_builder.py
CHANGED
|
@@ -30,12 +30,56 @@ def _normalize_heading_label(text):
|
|
| 30 |
return value.rstrip(" ::")
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
class DocxRenderer(mistune.HTMLRenderer):
|
| 34 |
-
def __init__(self):
|
| 35 |
super().__init__()
|
| 36 |
self.doc = Document()
|
| 37 |
self.h_counters = [0] * 6
|
| 38 |
self.is_first_h1 = True
|
|
|
|
|
|
|
|
|
|
| 39 |
try:
|
| 40 |
style = self.doc.styles['Normal']
|
| 41 |
style.font.name = APP_CONFIG["docx_styles"]["font_name"]
|
|
@@ -134,12 +178,21 @@ class DocxRenderer(mistune.HTMLRenderer):
|
|
| 134 |
if max_cols == 0:
|
| 135 |
return ''
|
| 136 |
|
|
|
|
| 137 |
header_labels = [cell.get_text(strip=True) for cell in rows[0].find_all(['td', 'th'])]
|
| 138 |
qualitative_headers = [
|
| 139 |
'Higher-order Theme', 'Empirical Concept', 'Process Stage',
|
| 140 |
'Case/Actor', 'Evidence Role', 'Representative Quote',
|
| 141 |
]
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
if qualitative_evidence_table:
|
| 145 |
previous = self.doc.sections[-1]
|
|
@@ -206,10 +259,26 @@ class DocxRenderer(mistune.HTMLRenderer):
|
|
| 206 |
self.doc.add_paragraph(f"[Image: {alt or src}]")
|
| 207 |
return ''
|
| 208 |
|
| 209 |
-
def text_to_docx(markdown_content):
|
| 210 |
-
"""Render markdown strictly; never disguise renderer failures as success.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
try:
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
mistune.create_markdown(renderer=renderer, plugins=['table'])(str(markdown_content or ''))
|
| 214 |
doc = renderer.doc
|
| 215 |
if not doc.paragraphs and not doc.tables:
|
|
|
|
| 30 |
return value.rstrip(" ::")
|
| 31 |
|
| 32 |
|
| 33 |
+
def _table_column_count(token):
|
| 34 |
+
if not isinstance(token, dict) or token.get("type") != "table":
|
| 35 |
+
return 0
|
| 36 |
+
for child in token.get("children") or []:
|
| 37 |
+
if isinstance(child, dict) and child.get("type") == "table_head":
|
| 38 |
+
return len(child.get("children") or [])
|
| 39 |
+
return 0
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _qualitative_appendix_table_ordinal(markdown_content):
|
| 43 |
+
"""Return the semantic evidence-table ordinal for an ASQ manuscript.
|
| 44 |
+
|
| 45 |
+
The qualitative Appendix is a structural business object: the unique
|
| 46 |
+
six-column Markdown table between the penultimate and final level-1
|
| 47 |
+
headings (Appendix and References in the frozen manuscript architecture).
|
| 48 |
+
Reader-facing header wording is intentionally irrelevant so EN/ZH/RU/KK
|
| 49 |
+
translations retain the same table identity.
|
| 50 |
+
"""
|
| 51 |
+
tokens = mistune.create_markdown(renderer="ast", plugins=["table"])(str(markdown_content or ""))
|
| 52 |
+
h1_positions = [
|
| 53 |
+
index for index, token in enumerate(tokens)
|
| 54 |
+
if isinstance(token, dict)
|
| 55 |
+
and token.get("type") == "heading"
|
| 56 |
+
and int((token.get("attrs") or {}).get("level") or 0) == 1
|
| 57 |
+
]
|
| 58 |
+
if len(h1_positions) < 2:
|
| 59 |
+
return None
|
| 60 |
+
appendix_start, references_start = h1_positions[-2], h1_positions[-1]
|
| 61 |
+
table_ordinal = -1
|
| 62 |
+
candidates = []
|
| 63 |
+
for index, token in enumerate(tokens):
|
| 64 |
+
if not isinstance(token, dict) or token.get("type") != "table":
|
| 65 |
+
continue
|
| 66 |
+
table_ordinal += 1
|
| 67 |
+
if appendix_start < index < references_start and _table_column_count(token) == 6:
|
| 68 |
+
candidates.append(table_ordinal)
|
| 69 |
+
if len(candidates) != 1:
|
| 70 |
+
return None
|
| 71 |
+
return candidates[0]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
class DocxRenderer(mistune.HTMLRenderer):
|
| 75 |
+
def __init__(self, *, qualitative_appendix_table_ordinal=None, semantic_qualitative_appendix=False):
|
| 76 |
super().__init__()
|
| 77 |
self.doc = Document()
|
| 78 |
self.h_counters = [0] * 6
|
| 79 |
self.is_first_h1 = True
|
| 80 |
+
self.table_ordinal = -1
|
| 81 |
+
self.qualitative_appendix_table_ordinal = qualitative_appendix_table_ordinal
|
| 82 |
+
self.semantic_qualitative_appendix = bool(semantic_qualitative_appendix)
|
| 83 |
try:
|
| 84 |
style = self.doc.styles['Normal']
|
| 85 |
style.font.name = APP_CONFIG["docx_styles"]["font_name"]
|
|
|
|
| 178 |
if max_cols == 0:
|
| 179 |
return ''
|
| 180 |
|
| 181 |
+
self.table_ordinal += 1
|
| 182 |
header_labels = [cell.get_text(strip=True) for cell in rows[0].find_all(['td', 'th'])]
|
| 183 |
qualitative_headers = [
|
| 184 |
'Higher-order Theme', 'Empirical Concept', 'Process Stage',
|
| 185 |
'Case/Actor', 'Evidence Role', 'Representative Quote',
|
| 186 |
]
|
| 187 |
+
if self.semantic_qualitative_appendix:
|
| 188 |
+
qualitative_evidence_table = (
|
| 189 |
+
self.qualitative_appendix_table_ordinal is not None
|
| 190 |
+
and self.table_ordinal == self.qualitative_appendix_table_ordinal
|
| 191 |
+
)
|
| 192 |
+
else:
|
| 193 |
+
# Legacy direct-render compatibility. Production ASQ packaging uses
|
| 194 |
+
# the semantic structural identity above and never depends on labels.
|
| 195 |
+
qualitative_evidence_table = header_labels == qualitative_headers
|
| 196 |
|
| 197 |
if qualitative_evidence_table:
|
| 198 |
previous = self.doc.sections[-1]
|
|
|
|
| 259 |
self.doc.add_paragraph(f"[Image: {alt or src}]")
|
| 260 |
return ''
|
| 261 |
|
| 262 |
+
def text_to_docx(markdown_content, *, qualitative_appendix=False):
|
| 263 |
+
"""Render markdown strictly; never disguise renderer failures as success.
|
| 264 |
+
|
| 265 |
+
``qualitative_appendix=True`` is an internal semantic contract supplied by
|
| 266 |
+
ASQ packaging. It binds the deterministic evidence table by manuscript
|
| 267 |
+
structure rather than reader-facing English/Chinese/Russian/Kazakh labels.
|
| 268 |
+
"""
|
| 269 |
try:
|
| 270 |
+
semantic_qualitative_appendix = bool(qualitative_appendix)
|
| 271 |
+
qualitative_table_ordinal = None
|
| 272 |
+
if semantic_qualitative_appendix:
|
| 273 |
+
qualitative_table_ordinal = _qualitative_appendix_table_ordinal(markdown_content)
|
| 274 |
+
if qualitative_table_ordinal is None:
|
| 275 |
+
raise DocxBuildError(
|
| 276 |
+
"Qualitative Appendix semantic table identity is missing or ambiguous."
|
| 277 |
+
)
|
| 278 |
+
renderer = DocxRenderer(
|
| 279 |
+
qualitative_appendix_table_ordinal=qualitative_table_ordinal,
|
| 280 |
+
semantic_qualitative_appendix=semantic_qualitative_appendix,
|
| 281 |
+
)
|
| 282 |
mistune.create_markdown(renderer=renderer, plugins=['table'])(str(markdown_content or ''))
|
| 283 |
doc = renderer.doc
|
| 284 |
if not doc.paragraphs and not doc.tables:
|