E3-C-C-L1 four-language AI output label

#130
Files changed (2) hide show
  1. core/aigc_output_label.py +620 -0
  2. core/production_guard.py +53 -1
core/aigc_output_label.py ADDED
@@ -0,0 +1,620 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Post-generation DOCX AI-content labeling for SmartPaper.
3
+
4
+ This module deliberately operates only on an already-rendered DOCX package. It
5
+ must not change the manuscript Markdown, generation prompts, translation logic,
6
+ or academic content. The visible marker is ordinary editable Word text, while
7
+ the implicit marker follows the OOXML custom-property pattern in TC260-PG-20258A.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import io
13
+ import json
14
+ import os
15
+ import zipfile
16
+ import xml.etree.ElementTree as ET
17
+ from copy import copy
18
+
19
+ from lxml import etree
20
+
21
+
22
+ VISIBLE_LABEL = "AI辅助生成内容"
23
+ VISIBLE_LABELS = {
24
+ "zh": "AI辅助生成内容",
25
+ "en": "AI-Assisted Generated Content",
26
+ "ru": "Контент, созданный с помощью ИИ",
27
+ "kk": "ЖИ көмегімен жасалған мазмұн",
28
+ }
29
+
30
+
31
+ def visible_label_for_language(language_or_filename: str | None = None) -> str:
32
+ """Return SmartPaper's deterministic reader-facing AIGC marker.
33
+
34
+ This is intentionally a fixed product mapping, not a model/translation call.
35
+ It accepts either a language name/code or the final DOCX filename so the
36
+ post-generation packaging layer can localize the marker without touching
37
+ manuscript generation or translation. Unknown inputs retain the frozen
38
+ Chinese default for backward compatibility.
39
+ """
40
+ value = str(language_or_filename or "").strip().casefold()
41
+ if any(token in value for token in ("english", "paper_english", "_en.", " en ")) or value in {"en", "en-us", "en-gb"}:
42
+ return VISIBLE_LABELS["en"]
43
+ if any(token in value for token in ("russian", "рус", "paper_russian", "_ru.")) or value in {"ru", "ru-ru"}:
44
+ return VISIBLE_LABELS["ru"]
45
+ if any(token in value for token in ("kazakh", "қазақ", "paper_kazakh", "_kk.")) or value in {"kk", "kk-kz"}:
46
+ return VISIBLE_LABELS["kk"]
47
+ if any(token in value for token in ("chinese", "中文", "paper_chinese", "_zh.")) or value in {"zh", "zh-cn", "zh-hans"}:
48
+ return VISIBLE_LABELS["zh"]
49
+ return VISIBLE_LABEL
50
+ AIGC_PROPERTY_NAME = "AIGC"
51
+ CONTENT_PRODUCER = "SmartPaper"
52
+ CONTENT_PRODUCER_ENV = "SMARTPAPER_AIGC_CONTENT_PRODUCER"
53
+
54
+ _W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
55
+ _CP_NS = "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"
56
+ _VT_NS = "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"
57
+ _REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
58
+ _CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
59
+ _CUSTOM_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"
60
+ _CUSTOM_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.custom-properties+xml"
61
+ _CUSTOM_FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"
62
+
63
+
64
+ def _xml(data: bytes) -> etree._Element:
65
+ parser = etree.XMLParser(resolve_entities=False, no_network=True, remove_blank_text=False)
66
+ return etree.fromstring(data, parser=parser)
67
+
68
+
69
+ def _serialize(root: etree._Element) -> bytes:
70
+ return etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True)
71
+
72
+
73
+ def _paragraph_text(element: etree._Element) -> str:
74
+ return "".join(element.xpath(".//w:t/text()", namespaces={"w": _W_NS})).strip()
75
+
76
+
77
+ def _append_visible_label(document_xml: bytes, label: str) -> bytes:
78
+ root = _xml(document_xml)
79
+ body = root.find(f"{{{_W_NS}}}body")
80
+ if body is None:
81
+ raise ValueError("DOCX word/document.xml has no w:body")
82
+
83
+ content_children = [child for child in body if child.tag != f"{{{_W_NS}}}sectPr"]
84
+ if content_children:
85
+ last = content_children[-1]
86
+ if last.tag == f"{{{_W_NS}}}p":
87
+ last_text = _paragraph_text(last)
88
+ if last_text == label:
89
+ return document_xml
90
+ # Deterministic relabeling: if a previous SmartPaper language marker
91
+ # is already the final paragraph, replace it rather than appending a
92
+ # second marker. Academic manuscript paragraphs are never touched.
93
+ if last_text in set(VISIBLE_LABELS.values()):
94
+ body.remove(last)
95
+
96
+ paragraph = etree.Element(f"{{{_W_NS}}}p")
97
+ run = etree.SubElement(paragraph, f"{{{_W_NS}}}r")
98
+ text = etree.SubElement(run, f"{{{_W_NS}}}t")
99
+ text.text = label
100
+
101
+ section = body.find(f"{{{_W_NS}}}sectPr")
102
+ if section is None:
103
+ body.append(paragraph)
104
+ else:
105
+ body.insert(body.index(section), paragraph)
106
+ return _serialize(root)
107
+
108
+
109
+ def _content_id(original_docx: bytes) -> str:
110
+ return "SP-" + hashlib.sha256(original_docx).hexdigest()
111
+
112
+
113
+ def _aigc_json(original_docx: bytes, producer: str) -> str:
114
+ produce_id = _content_id(original_docx)
115
+ payload = {
116
+ "Label": "1",
117
+ "ContentProducer": producer,
118
+ "ProduceID": produce_id,
119
+ "ReservedCode1": "",
120
+ "ContentPropagator": producer,
121
+ "PropagateID": produce_id,
122
+ "ReservedCode2": "",
123
+ }
124
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
125
+
126
+
127
+ def _upsert_custom_property(existing: bytes | None, value: str) -> bytes:
128
+ if existing:
129
+ root = _xml(existing)
130
+ else:
131
+ root = etree.Element(
132
+ f"{{{_CP_NS}}}Properties",
133
+ nsmap={None: _CP_NS, "vt": _VT_NS},
134
+ )
135
+
136
+ existing_props = root.findall(f"{{{_CP_NS}}}property")
137
+ for prop in list(existing_props):
138
+ if prop.get("name") == AIGC_PROPERTY_NAME:
139
+ root.remove(prop)
140
+
141
+ pids: list[int] = []
142
+ for prop in root.findall(f"{{{_CP_NS}}}property"):
143
+ try:
144
+ pids.append(int(prop.get("pid") or "0"))
145
+ except ValueError:
146
+ continue
147
+ pid = max([1, *pids]) + 1
148
+
149
+ prop = etree.SubElement(
150
+ root,
151
+ f"{{{_CP_NS}}}property",
152
+ fmtid=_CUSTOM_FMTID,
153
+ pid=str(pid),
154
+ name=AIGC_PROPERTY_NAME,
155
+ )
156
+ lpwstr = etree.SubElement(prop, f"{{{_VT_NS}}}lpwstr")
157
+ lpwstr.text = value
158
+ return _serialize(root)
159
+
160
+
161
+ def _ensure_content_type(content_types_xml: bytes) -> bytes:
162
+ root = _xml(content_types_xml)
163
+ for override in root.findall(f"{{{_CT_NS}}}Override"):
164
+ if override.get("PartName") == "/docProps/custom.xml":
165
+ override.set("ContentType", _CUSTOM_CONTENT_TYPE)
166
+ return _serialize(root)
167
+ etree.SubElement(
168
+ root,
169
+ f"{{{_CT_NS}}}Override",
170
+ PartName="/docProps/custom.xml",
171
+ ContentType=_CUSTOM_CONTENT_TYPE,
172
+ )
173
+ return _serialize(root)
174
+
175
+
176
+ def _ensure_relationship(rels_xml: bytes) -> bytes:
177
+ root = _xml(rels_xml)
178
+ for rel in root.findall(f"{{{_REL_NS}}}Relationship"):
179
+ if rel.get("Type") == _CUSTOM_REL_TYPE or rel.get("Target") == "docProps/custom.xml":
180
+ rel.set("Type", _CUSTOM_REL_TYPE)
181
+ rel.set("Target", "docProps/custom.xml")
182
+ return _serialize(root)
183
+
184
+ used = {rel.get("Id") for rel in root.findall(f"{{{_REL_NS}}}Relationship")}
185
+ index = 1
186
+ while f"rId{index}" in used:
187
+ index += 1
188
+ etree.SubElement(
189
+ root,
190
+ f"{{{_REL_NS}}}Relationship",
191
+ Id=f"rId{index}",
192
+ Type=_CUSTOM_REL_TYPE,
193
+ Target="docProps/custom.xml",
194
+ )
195
+ return _serialize(root)
196
+
197
+
198
+ def _new_zipinfo(name: str, template: zipfile.ZipInfo | None = None) -> zipfile.ZipInfo:
199
+ if template is not None:
200
+ info = copy(template)
201
+ info.filename = name
202
+ return info
203
+ info = zipfile.ZipInfo(filename=name, date_time=(1980, 1, 1, 0, 0, 0))
204
+ info.compress_type = zipfile.ZIP_DEFLATED
205
+ return info
206
+
207
+
208
+ def apply_aigc_output_label(
209
+ docx_bytes: bytes,
210
+ *,
211
+ visible_label: str = VISIBLE_LABEL,
212
+ content_producer: str | None = None,
213
+ ) -> bytes:
214
+ """Add visible and implicit AI labels to an already-rendered DOCX.
215
+
216
+ The operation is deterministic for the same rendered input and idempotent
217
+ with respect to the visible/custom-property label. It does not re-render the
218
+ manuscript through python-docx, which minimizes layout risk.
219
+ """
220
+ if not docx_bytes:
221
+ raise ValueError("DOCX bytes are empty")
222
+ label = str(visible_label or "").strip()
223
+ producer = str(
224
+ content_producer
225
+ or os.getenv(CONTENT_PRODUCER_ENV)
226
+ or os.getenv("SMARTPAPER_OPERATOR_NAME")
227
+ or CONTENT_PRODUCER
228
+ ).strip()
229
+ if not label:
230
+ raise ValueError("visible AI label is empty")
231
+ if not producer:
232
+ raise ValueError("AI content producer is empty")
233
+
234
+ with zipfile.ZipFile(io.BytesIO(docx_bytes), "r") as source:
235
+ bad = source.testzip()
236
+ if bad:
237
+ raise ValueError(f"source DOCX ZIP is corrupt at {bad}")
238
+ infos = {info.filename: info for info in source.infolist()}
239
+ required = {"word/document.xml", "[Content_Types].xml", "_rels/.rels"}
240
+ missing = required - set(infos)
241
+ if missing:
242
+ raise ValueError(f"source DOCX is missing required OOXML parts: {sorted(missing)}")
243
+ contents = {name: source.read(name) for name in infos}
244
+
245
+ contents["word/document.xml"] = _append_visible_label(contents["word/document.xml"], label)
246
+ contents["[Content_Types].xml"] = _ensure_content_type(contents["[Content_Types].xml"])
247
+ contents["_rels/.rels"] = _ensure_relationship(contents["_rels/.rels"])
248
+ contents["docProps/custom.xml"] = _upsert_custom_property(
249
+ contents.get("docProps/custom.xml"),
250
+ _aigc_json(docx_bytes, producer),
251
+ )
252
+
253
+ buffer = io.BytesIO()
254
+ with zipfile.ZipFile(buffer, "w") as target:
255
+ for name, info in infos.items():
256
+ target.writestr(_new_zipinfo(name, info), contents[name])
257
+ if "docProps/custom.xml" not in infos:
258
+ template = infos.get("docProps/core.xml") or next(iter(infos.values()), None)
259
+ target.writestr(_new_zipinfo("docProps/custom.xml", template), contents["docProps/custom.xml"])
260
+ labeled = buffer.getvalue()
261
+ validate_aigc_output_label(labeled, expected_visible_label=label)
262
+ return labeled
263
+
264
+
265
+ def read_aigc_metadata(docx_bytes: bytes) -> dict[str, str]:
266
+ with zipfile.ZipFile(io.BytesIO(docx_bytes), "r") as archive:
267
+ if "docProps/custom.xml" not in archive.namelist():
268
+ raise ValueError("DOCX has no docProps/custom.xml AIGC metadata")
269
+ root = _xml(archive.read("docProps/custom.xml"))
270
+ matches = [
271
+ prop for prop in root.findall(f"{{{_CP_NS}}}property")
272
+ if prop.get("name") == AIGC_PROPERTY_NAME
273
+ ]
274
+ if len(matches) != 1:
275
+ raise ValueError(f"DOCX must contain exactly one AIGC custom property, found {len(matches)}")
276
+ value = matches[0].find(f"{{{_VT_NS}}}lpwstr")
277
+ if value is None or not (value.text or "").strip():
278
+ raise ValueError("AIGC custom property has no lpwstr JSON value")
279
+ payload = json.loads(value.text)
280
+ if not isinstance(payload, dict):
281
+ raise ValueError("AIGC custom property JSON must be an object")
282
+ return {str(key): str(val) for key, val in payload.items()}
283
+
284
+
285
+ def validate_aigc_output_label(docx_bytes: bytes, *, expected_visible_label: str = VISIBLE_LABEL) -> None:
286
+ with zipfile.ZipFile(io.BytesIO(docx_bytes), "r") as archive:
287
+ bad = archive.testzip()
288
+ if bad:
289
+ raise ValueError(f"labeled DOCX ZIP is corrupt at {bad}")
290
+ names = set(archive.namelist())
291
+ required = {"word/document.xml", "docProps/custom.xml", "[Content_Types].xml", "_rels/.rels"}
292
+ missing = required - names
293
+ if missing:
294
+ raise ValueError(f"labeled DOCX is missing required parts: {sorted(missing)}")
295
+
296
+ document = _xml(archive.read("word/document.xml"))
297
+ body = document.find(f"{{{_W_NS}}}body")
298
+ if body is None:
299
+ raise ValueError("labeled DOCX has no w:body")
300
+ content_children = [child for child in body if child.tag != f"{{{_W_NS}}}sectPr"]
301
+ if not content_children:
302
+ raise ValueError("labeled DOCX has no body content")
303
+ last = content_children[-1]
304
+ if last.tag != f"{{{_W_NS}}}p" or _paragraph_text(last) != expected_visible_label:
305
+ raise ValueError("visible AI label is not the final editable body paragraph")
306
+
307
+ content_types = _xml(archive.read("[Content_Types].xml"))
308
+ overrides = [
309
+ item for item in content_types.findall(f"{{{_CT_NS}}}Override")
310
+ if item.get("PartName") == "/docProps/custom.xml"
311
+ ]
312
+ if len(overrides) != 1 or overrides[0].get("ContentType") != _CUSTOM_CONTENT_TYPE:
313
+ raise ValueError("AIGC custom-property content type is missing or invalid")
314
+
315
+ rels = _xml(archive.read("_rels/.rels"))
316
+ relationships = [
317
+ rel for rel in rels.findall(f"{{{_REL_NS}}}Relationship")
318
+ if rel.get("Type") == _CUSTOM_REL_TYPE and rel.get("Target") == "docProps/custom.xml"
319
+ ]
320
+ if len(relationships) != 1:
321
+ raise ValueError("AIGC custom-property package relationship is missing or duplicated")
322
+
323
+ payload = read_aigc_metadata(docx_bytes)
324
+ required_fields = {
325
+ "Label", "ContentProducer", "ProduceID", "ReservedCode1",
326
+ "ContentPropagator", "PropagateID", "ReservedCode2",
327
+ }
328
+ if set(payload) != required_fields:
329
+ raise ValueError(f"AIGC metadata fields mismatch: {sorted(payload)}")
330
+ if payload["Label"] != "1":
331
+ raise ValueError("AIGC Label must be '1' for generated/synthetic content")
332
+ if not payload["ContentProducer"] or not payload["ProduceID"]:
333
+ raise ValueError("AIGC ContentProducer and ProduceID must be nonempty")
334
+ if payload["ContentPropagator"] != payload["ContentProducer"]:
335
+ raise ValueError("initial AIGC ContentPropagator must match ContentProducer")
336
+ if payload["PropagateID"] != payload["ProduceID"]:
337
+ raise ValueError("initial AIGC PropagateID must match ProduceID")
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # Delivery-First deterministic fallback
342
+ # ---------------------------------------------------------------------------
343
+ # The primary implementation above intentionally uses lxml because it gives
344
+ # strict namespace-aware OOXML handling. This fallback is deliberately built
345
+ # with Python's standard-library ElementTree + zipfile so a defect in the
346
+ # primary XML path cannot, by itself, prevent delivery of an otherwise valid
347
+ # rendered DOCX.
348
+
349
+
350
+ def _et_qn(namespace: str, local: str) -> str:
351
+ return f"{{{namespace}}}{local}"
352
+
353
+
354
+ def _fallback_paragraph_text(element: ET.Element) -> str:
355
+ values: list[str] = []
356
+ for item in element.iter(_et_qn(_W_NS, "t")):
357
+ if item.text:
358
+ values.append(item.text)
359
+ return "".join(values).strip()
360
+
361
+
362
+ def _fallback_append_visible_label(document_xml: bytes, label: str) -> bytes:
363
+ root = ET.fromstring(document_xml)
364
+ body = root.find(_et_qn(_W_NS, "body"))
365
+ if body is None:
366
+ raise ValueError("DOCX word/document.xml has no w:body")
367
+ children = list(body)
368
+ content = [child for child in children if child.tag != _et_qn(_W_NS, "sectPr")]
369
+ if content:
370
+ last = content[-1]
371
+ if last.tag == _et_qn(_W_NS, "p"):
372
+ last_text = _fallback_paragraph_text(last)
373
+ if last_text == label:
374
+ return document_xml
375
+ if last_text in set(VISIBLE_LABELS.values()):
376
+ body.remove(last)
377
+ paragraph = ET.Element(_et_qn(_W_NS, "p"))
378
+ run = ET.SubElement(paragraph, _et_qn(_W_NS, "r"))
379
+ text = ET.SubElement(run, _et_qn(_W_NS, "t"))
380
+ text.text = label
381
+ section_index = next(
382
+ (idx for idx, child in enumerate(children) if child.tag == _et_qn(_W_NS, "sectPr")),
383
+ None,
384
+ )
385
+ if section_index is None:
386
+ body.append(paragraph)
387
+ else:
388
+ body.insert(section_index, paragraph)
389
+ return ET.tostring(root, encoding="utf-8", xml_declaration=True)
390
+
391
+
392
+ def _fallback_upsert_custom_property(existing: bytes | None, value: str) -> bytes:
393
+ if existing:
394
+ root = ET.fromstring(existing)
395
+ else:
396
+ root = ET.Element(_et_qn(_CP_NS, "Properties"))
397
+ props = list(root.findall(_et_qn(_CP_NS, "property")))
398
+ for prop in props:
399
+ if prop.get("name") == AIGC_PROPERTY_NAME:
400
+ root.remove(prop)
401
+ pids: list[int] = []
402
+ for prop in root.findall(_et_qn(_CP_NS, "property")):
403
+ try:
404
+ pids.append(int(prop.get("pid") or "0"))
405
+ except ValueError:
406
+ pass
407
+ prop = ET.SubElement(
408
+ root,
409
+ _et_qn(_CP_NS, "property"),
410
+ {"fmtid": _CUSTOM_FMTID, "pid": str(max([1, *pids]) + 1), "name": AIGC_PROPERTY_NAME},
411
+ )
412
+ node = ET.SubElement(prop, _et_qn(_VT_NS, "lpwstr"))
413
+ node.text = value
414
+ return ET.tostring(root, encoding="utf-8", xml_declaration=True)
415
+
416
+
417
+ def _fallback_ensure_content_type(content_types_xml: bytes) -> bytes:
418
+ root = ET.fromstring(content_types_xml)
419
+ matches = [
420
+ node for node in root.findall(_et_qn(_CT_NS, "Override"))
421
+ if node.get("PartName") == "/docProps/custom.xml"
422
+ ]
423
+ if matches:
424
+ matches[0].set("ContentType", _CUSTOM_CONTENT_TYPE)
425
+ for extra in matches[1:]:
426
+ root.remove(extra)
427
+ else:
428
+ ET.SubElement(
429
+ root,
430
+ _et_qn(_CT_NS, "Override"),
431
+ {"PartName": "/docProps/custom.xml", "ContentType": _CUSTOM_CONTENT_TYPE},
432
+ )
433
+ return ET.tostring(root, encoding="utf-8", xml_declaration=True)
434
+
435
+
436
+ def _fallback_ensure_relationship(rels_xml: bytes) -> bytes:
437
+ root = ET.fromstring(rels_xml)
438
+ matches = [
439
+ node for node in root.findall(_et_qn(_REL_NS, "Relationship"))
440
+ if node.get("Type") == _CUSTOM_REL_TYPE or node.get("Target") == "docProps/custom.xml"
441
+ ]
442
+ if matches:
443
+ matches[0].set("Type", _CUSTOM_REL_TYPE)
444
+ matches[0].set("Target", "docProps/custom.xml")
445
+ for extra in matches[1:]:
446
+ root.remove(extra)
447
+ else:
448
+ used = {node.get("Id") for node in root.findall(_et_qn(_REL_NS, "Relationship"))}
449
+ index = 1
450
+ while f"rId{index}" in used:
451
+ index += 1
452
+ ET.SubElement(
453
+ root,
454
+ _et_qn(_REL_NS, "Relationship"),
455
+ {"Id": f"rId{index}", "Type": _CUSTOM_REL_TYPE, "Target": "docProps/custom.xml"},
456
+ )
457
+ return ET.tostring(root, encoding="utf-8", xml_declaration=True)
458
+
459
+
460
+ def apply_aigc_output_label_fallback(
461
+ docx_bytes: bytes,
462
+ *,
463
+ visible_label: str = VISIBLE_LABEL,
464
+ content_producer: str | None = None,
465
+ ) -> bytes:
466
+ """Independent stdlib-only OOXML fallback for an already-rendered DOCX."""
467
+ if not docx_bytes:
468
+ raise ValueError("DOCX bytes are empty")
469
+ label = str(visible_label or "").strip()
470
+ producer = str(
471
+ content_producer
472
+ or os.getenv(CONTENT_PRODUCER_ENV)
473
+ or os.getenv("SMARTPAPER_OPERATOR_NAME")
474
+ or CONTENT_PRODUCER
475
+ ).strip()
476
+ if not label or not producer:
477
+ raise ValueError("AI label/producer is empty")
478
+ with zipfile.ZipFile(io.BytesIO(docx_bytes), "r") as source:
479
+ bad = source.testzip()
480
+ if bad:
481
+ raise ValueError(f"source DOCX ZIP is corrupt at {bad}")
482
+ infos = {info.filename: info for info in source.infolist()}
483
+ required = {"word/document.xml", "[Content_Types].xml", "_rels/.rels"}
484
+ missing = required - set(infos)
485
+ if missing:
486
+ raise ValueError(f"source DOCX is missing required OOXML parts: {sorted(missing)}")
487
+ contents = {name: source.read(name) for name in infos}
488
+ contents["word/document.xml"] = _fallback_append_visible_label(contents["word/document.xml"], label)
489
+ contents["[Content_Types].xml"] = _fallback_ensure_content_type(contents["[Content_Types].xml"])
490
+ contents["_rels/.rels"] = _fallback_ensure_relationship(contents["_rels/.rels"])
491
+ contents["docProps/custom.xml"] = _fallback_upsert_custom_property(
492
+ contents.get("docProps/custom.xml"), _aigc_json(docx_bytes, producer)
493
+ )
494
+ buffer = io.BytesIO()
495
+ with zipfile.ZipFile(buffer, "w") as target:
496
+ for name, info in infos.items():
497
+ target.writestr(_new_zipinfo(name, info), contents[name])
498
+ if "docProps/custom.xml" not in infos:
499
+ template = infos.get("docProps/core.xml") or next(iter(infos.values()), None)
500
+ target.writestr(_new_zipinfo("docProps/custom.xml", template), contents["docProps/custom.xml"])
501
+ labeled = buffer.getvalue()
502
+ validate_aigc_output_label_fallback(labeled, expected_visible_label=label)
503
+ return labeled
504
+
505
+
506
+ def validate_aigc_output_label_fallback(
507
+ docx_bytes: bytes, *, expected_visible_label: str = VISIBLE_LABEL
508
+ ) -> None:
509
+ """Stdlib-only validation paired with the deterministic fallback writer."""
510
+ with zipfile.ZipFile(io.BytesIO(docx_bytes), "r") as archive:
511
+ bad = archive.testzip()
512
+ if bad:
513
+ raise ValueError(f"labeled DOCX ZIP is corrupt at {bad}")
514
+ names = set(archive.namelist())
515
+ required = {"word/document.xml", "docProps/custom.xml", "[Content_Types].xml", "_rels/.rels"}
516
+ missing = required - names
517
+ if missing:
518
+ raise ValueError(f"labeled DOCX is missing required parts: {sorted(missing)}")
519
+ document = ET.fromstring(archive.read("word/document.xml"))
520
+ body = document.find(_et_qn(_W_NS, "body"))
521
+ if body is None:
522
+ raise ValueError("labeled DOCX has no w:body")
523
+ content = [child for child in list(body) if child.tag != _et_qn(_W_NS, "sectPr")]
524
+ if not content or content[-1].tag != _et_qn(_W_NS, "p"):
525
+ raise ValueError("visible AI label is not the final editable body paragraph")
526
+ if _fallback_paragraph_text(content[-1]) != expected_visible_label:
527
+ raise ValueError("visible AI label text mismatch")
528
+ custom = ET.fromstring(archive.read("docProps/custom.xml"))
529
+ props = [
530
+ node for node in custom.findall(_et_qn(_CP_NS, "property"))
531
+ if node.get("name") == AIGC_PROPERTY_NAME
532
+ ]
533
+ if len(props) != 1:
534
+ raise ValueError(f"DOCX must contain exactly one AIGC custom property, found {len(props)}")
535
+ value = props[0].find(_et_qn(_VT_NS, "lpwstr"))
536
+ if value is None or not (value.text or "").strip():
537
+ raise ValueError("AIGC custom property has no JSON value")
538
+ payload = json.loads(value.text)
539
+ content_types = ET.fromstring(archive.read("[Content_Types].xml"))
540
+ overrides = [
541
+ node for node in content_types.findall(_et_qn(_CT_NS, "Override"))
542
+ if node.get("PartName") == "/docProps/custom.xml"
543
+ and node.get("ContentType") == _CUSTOM_CONTENT_TYPE
544
+ ]
545
+ if len(overrides) != 1:
546
+ raise ValueError("AIGC custom-property content type is missing or duplicated")
547
+ rels = ET.fromstring(archive.read("_rels/.rels"))
548
+ relationships = [
549
+ node for node in rels.findall(_et_qn(_REL_NS, "Relationship"))
550
+ if node.get("Type") == _CUSTOM_REL_TYPE and node.get("Target") == "docProps/custom.xml"
551
+ ]
552
+ if len(relationships) != 1:
553
+ raise ValueError("AIGC custom-property package relationship is missing or duplicated")
554
+ required_fields = {
555
+ "Label", "ContentProducer", "ProduceID", "ReservedCode1",
556
+ "ContentPropagator", "PropagateID", "ReservedCode2",
557
+ }
558
+ if not isinstance(payload, dict) or set(payload) != required_fields:
559
+ raise ValueError("AIGC metadata fields mismatch")
560
+ if str(payload.get("Label")) != "1":
561
+ raise ValueError("AIGC Label must be '1'")
562
+ if not str(payload.get("ContentProducer") or "") or not str(payload.get("ProduceID") or ""):
563
+ raise ValueError("AIGC ContentProducer and ProduceID must be nonempty")
564
+ if payload.get("ContentPropagator") != payload.get("ContentProducer"):
565
+ raise ValueError("initial AIGC ContentPropagator must match ContentProducer")
566
+ if payload.get("PropagateID") != payload.get("ProduceID"):
567
+ raise ValueError("initial AIGC PropagateID must match ProduceID")
568
+
569
+
570
+ def ensure_aigc_output_label(
571
+ docx_bytes: bytes,
572
+ *,
573
+ visible_label: str = VISIBLE_LABEL,
574
+ content_producer: str | None = None,
575
+ ) -> bytes:
576
+ """Primary label path with an independent deterministic fallback.
577
+
578
+ A failure in the primary lxml writer/validator is treated as an OUTPUT_BUILD
579
+ recovery event, not as an immediate delivery failure. Only if both the
580
+ primary and the stdlib fallback fail is the error allowed to propagate to
581
+ the existing safe-DOCX/recovery machinery.
582
+ """
583
+ try:
584
+ labeled = apply_aigc_output_label(
585
+ docx_bytes, visible_label=visible_label, content_producer=content_producer
586
+ )
587
+ validate_aigc_output_label(labeled, expected_visible_label=visible_label)
588
+ return labeled
589
+ except Exception as primary_error:
590
+ try:
591
+ labeled = apply_aigc_output_label_fallback(
592
+ docx_bytes, visible_label=visible_label, content_producer=content_producer
593
+ )
594
+ validate_aigc_output_label_fallback(labeled, expected_visible_label=visible_label)
595
+ return labeled
596
+ except Exception as fallback_error:
597
+ raise ValueError(
598
+ "AI output labeling failed in primary and deterministic fallback paths: "
599
+ f"primary={primary_error}; fallback={fallback_error}"
600
+ ) from fallback_error
601
+
602
+
603
+ def validate_aigc_output_label_resilient(
604
+ docx_bytes: bytes, *, expected_visible_label: str = VISIBLE_LABEL
605
+ ) -> None:
606
+ """Validate with independent primary/fallback XML implementations."""
607
+ try:
608
+ validate_aigc_output_label(docx_bytes, expected_visible_label=expected_visible_label)
609
+ return
610
+ except Exception as primary_error:
611
+ try:
612
+ validate_aigc_output_label_fallback(
613
+ docx_bytes, expected_visible_label=expected_visible_label
614
+ )
615
+ return
616
+ except Exception as fallback_error:
617
+ raise ValueError(
618
+ "AI output label validation failed in primary and fallback paths: "
619
+ f"primary={primary_error}; fallback={fallback_error}"
620
+ ) from fallback_error
core/production_guard.py CHANGED
@@ -14,6 +14,11 @@ 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
 
19
 
@@ -838,6 +843,11 @@ def _build_zip_bytes(entries: dict[str, bytes]) -> bytes:
838
  return buffer.getvalue()
839
 
840
 
 
 
 
 
 
841
  def _validate_zip_bytes(
842
  zip_bytes: bytes,
843
  *,
@@ -864,6 +874,12 @@ def _validate_zip_bytes(
864
  for name in (english_filename, translated_filename):
865
  archived_docx = archive.read(name)
866
  validate_docx_bytes(archived_docx)
 
 
 
 
 
 
867
  if quantitative_manifest:
868
  archived_markdown = english_markdown if name == english_filename else translated_markdown
869
  validate_quantitative_table_contract(
@@ -904,8 +920,22 @@ def _validated_package_attempt(
904
  translated_docx = _render_delivery_docx(
905
  translated_markdown, qualitative_mode=qualitative_mode, safe_mode=safe_mode,
906
  )
 
 
 
 
 
 
 
 
 
907
  validate_docx_bytes(english_docx)
908
  validate_docx_bytes(translated_docx)
 
 
 
 
 
909
 
910
  quantitative_manifest = (
911
  (data_metadata or {}).get("quantitative_table_manifest")
@@ -1088,21 +1118,33 @@ def build_recovery_package(
1088
  raise OutputValidationError("Recovery package requires a materially complete frozen English manuscript.")
1089
  try:
1090
  english_docx = _render_delivery_docx(english, qualitative_mode=False, safe_mode=True)
 
 
1091
  validate_docx_bytes(english_docx)
 
1092
  except Exception:
1093
  english_docx = _plain_recovery_docx(english)
 
 
1094
  validate_docx_bytes(english_docx)
 
1095
 
1096
  entries: dict[str, bytes] = {english_filename: english_docx}
1097
  target = str(translated_markdown or "").strip()
1098
  if len(target) >= 800:
1099
  try:
1100
  target_docx = _render_delivery_docx(target, qualitative_mode=False, safe_mode=True)
 
 
1101
  validate_docx_bytes(target_docx)
 
1102
  except Exception:
1103
  try:
1104
  target_docx = _plain_recovery_docx(target)
 
 
1105
  validate_docx_bytes(target_docx)
 
1106
  except Exception:
1107
  target_docx = b""
1108
  if target_docx:
@@ -1127,7 +1169,17 @@ def build_recovery_package(
1127
  bad = archive.testzip()
1128
  if bad:
1129
  raise OutputValidationError(f"Recovery ZIP integrity failed at {bad}.")
1130
- validate_docx_bytes(archive.read(english_filename))
 
 
 
 
 
 
 
 
 
 
1131
  return PackageResult(
1132
  zip_bytes=zip_bytes,
1133
  file_names=tuple(sorted(entries)),
 
14
  from docx.enum.section import WD_ORIENT
15
  from docx.oxml.ns import qn
16
 
17
+ from core.aigc_output_label import (
18
+ ensure_aigc_output_label,
19
+ validate_aigc_output_label_resilient,
20
+ visible_label_for_language,
21
+ )
22
  from utils.docx_builder import DocxBuildError, text_to_docx
23
 
24
 
 
843
  return buffer.getvalue()
844
 
845
 
846
+ def _aigc_visible_label_for_docx(filename: str) -> str:
847
+ """Resolve the fixed reader-facing marker from final DOCX identity only."""
848
+ return visible_label_for_language(filename)
849
+
850
+
851
  def _validate_zip_bytes(
852
  zip_bytes: bytes,
853
  *,
 
874
  for name in (english_filename, translated_filename):
875
  archived_docx = archive.read(name)
876
  validate_docx_bytes(archived_docx)
877
+ try:
878
+ validate_aigc_output_label_resilient(
879
+ archived_docx, expected_visible_label=_aigc_visible_label_for_docx(name)
880
+ )
881
+ except Exception as exc:
882
+ raise OutputValidationError(f"Archived DOCX AI output label invalid: {exc}") from exc
883
  if quantitative_manifest:
884
  archived_markdown = english_markdown if name == english_filename else translated_markdown
885
  validate_quantitative_table_contract(
 
920
  translated_docx = _render_delivery_docx(
921
  translated_markdown, qualitative_mode=qualitative_mode, safe_mode=safe_mode,
922
  )
923
+ # E3-B: label only the already-rendered delivery artifact. Manuscript
924
+ # Markdown, generation, translation and academic-content gates stay frozen.
925
+ try:
926
+ english_label = _aigc_visible_label_for_docx(english_filename)
927
+ translated_label = _aigc_visible_label_for_docx(translated_filename)
928
+ english_docx = ensure_aigc_output_label(english_docx, visible_label=english_label)
929
+ translated_docx = ensure_aigc_output_label(translated_docx, visible_label=translated_label)
930
+ except Exception as exc:
931
+ raise OutputValidationError(f"Post-generation AI output labeling failed: {exc}") from exc
932
  validate_docx_bytes(english_docx)
933
  validate_docx_bytes(translated_docx)
934
+ try:
935
+ validate_aigc_output_label_resilient(english_docx, expected_visible_label=english_label)
936
+ validate_aigc_output_label_resilient(translated_docx, expected_visible_label=translated_label)
937
+ except Exception as exc:
938
+ raise OutputValidationError(f"Post-generation AI output label validation failed: {exc}") from exc
939
 
940
  quantitative_manifest = (
941
  (data_metadata or {}).get("quantitative_table_manifest")
 
1118
  raise OutputValidationError("Recovery package requires a materially complete frozen English manuscript.")
1119
  try:
1120
  english_docx = _render_delivery_docx(english, qualitative_mode=False, safe_mode=True)
1121
+ english_label = _aigc_visible_label_for_docx(english_filename)
1122
+ english_docx = ensure_aigc_output_label(english_docx, visible_label=english_label)
1123
  validate_docx_bytes(english_docx)
1124
+ validate_aigc_output_label_resilient(english_docx, expected_visible_label=english_label)
1125
  except Exception:
1126
  english_docx = _plain_recovery_docx(english)
1127
+ english_label = _aigc_visible_label_for_docx(english_filename)
1128
+ english_docx = ensure_aigc_output_label(english_docx, visible_label=english_label)
1129
  validate_docx_bytes(english_docx)
1130
+ validate_aigc_output_label_resilient(english_docx, expected_visible_label=english_label)
1131
 
1132
  entries: dict[str, bytes] = {english_filename: english_docx}
1133
  target = str(translated_markdown or "").strip()
1134
  if len(target) >= 800:
1135
  try:
1136
  target_docx = _render_delivery_docx(target, qualitative_mode=False, safe_mode=True)
1137
+ target_label = _aigc_visible_label_for_docx(translated_filename)
1138
+ target_docx = ensure_aigc_output_label(target_docx, visible_label=target_label)
1139
  validate_docx_bytes(target_docx)
1140
+ validate_aigc_output_label_resilient(target_docx, expected_visible_label=target_label)
1141
  except Exception:
1142
  try:
1143
  target_docx = _plain_recovery_docx(target)
1144
+ target_label = _aigc_visible_label_for_docx(translated_filename)
1145
+ target_docx = ensure_aigc_output_label(target_docx, visible_label=target_label)
1146
  validate_docx_bytes(target_docx)
1147
+ validate_aigc_output_label_resilient(target_docx, expected_visible_label=target_label)
1148
  except Exception:
1149
  target_docx = b""
1150
  if target_docx:
 
1169
  bad = archive.testzip()
1170
  if bad:
1171
  raise OutputValidationError(f"Recovery ZIP integrity failed at {bad}.")
1172
+ archived_english = archive.read(english_filename)
1173
+ validate_docx_bytes(archived_english)
1174
+ validate_aigc_output_label_resilient(
1175
+ archived_english, expected_visible_label=_aigc_visible_label_for_docx(english_filename)
1176
+ )
1177
+ if translated_filename in archive.namelist():
1178
+ archived_target = archive.read(translated_filename)
1179
+ validate_docx_bytes(archived_target)
1180
+ validate_aigc_output_label_resilient(
1181
+ archived_target, expected_visible_label=_aigc_visible_label_for_docx(translated_filename)
1182
+ )
1183
  return PackageResult(
1184
  zip_bytes=zip_bytes,
1185
  file_names=tuple(sorted(entries)),