R6A-R4-R6 four-language qualitative appendix semantic identity and final DOCX layout gate

#43
Files changed (2) hide show
  1. core/production_guard.py +68 -3
  2. 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(english_markdown).getvalue()
474
- translated_docx = text_to_docx(translated_markdown).getvalue()
 
 
 
 
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
- validate_docx_bytes(archive.read(name))
 
 
 
 
 
 
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
- qualitative_evidence_table = header_labels == qualitative_headers
 
 
 
 
 
 
 
 
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
- renderer = DocxRenderer()
 
 
 
 
 
 
 
 
 
 
 
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: