python-hwpx-automation 6.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,616 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Sequence, cast
10
+
11
+ from ..core.content import (
12
+ add_heading_to_doc,
13
+ add_page_break_to_doc,
14
+ add_paragraph_to_doc,
15
+ add_table_to_doc,
16
+ delete_paragraph_from_doc,
17
+ insert_paragraph_to_doc,
18
+ set_cell_text,
19
+ )
20
+ from ..storage import LocalDocumentStorage, build_hwpx_verification_report
21
+ from ..workspace import (
22
+ WorkspaceOutputGuard,
23
+ )
24
+ from ..core.search import batch_replace_in_doc, replace_in_doc
25
+ from ..core.transactions import (
26
+ backup_path_for,
27
+ rotate_and_backup,
28
+ save_dry_run,
29
+ semantic_diff,
30
+ undo_last_backup,
31
+ )
32
+ from ..upstream import (
33
+ HwpxDocument,
34
+ )
35
+
36
+ from .context import DocumentContext
37
+ from .save_policy import SavePolicy
38
+ from .save_policy import _ExactBackupResult, _ExactRecoveryPublication
39
+
40
+ logger = logging.getLogger("hwpx_automation.hwpx_ops")
41
+
42
+
43
+ class TransactionService:
44
+ def __init__(self, context: DocumentContext, save: SavePolicy) -> None:
45
+ self._context = context
46
+ self._save = save
47
+
48
+ def _with_transaction_verification(
49
+ self,
50
+ result: Dict[str, Any],
51
+ document: HwpxDocument,
52
+ target: Path,
53
+ *,
54
+ dry_run: bool,
55
+ quality: Any = None,
56
+ ) -> Dict[str, Any]:
57
+ payload = dict(result)
58
+ payload.setdefault("dryRun", dry_run)
59
+ if dry_run:
60
+ payload.update(save_dry_run(document, target, quality=quality))
61
+ return payload
62
+
63
+ verification = self._save._save_transaction_document(
64
+ document, target, quality=quality
65
+ )
66
+ payload["verificationReport"] = verification
67
+ payload["openSafety"] = verification.get("openSafety")
68
+ if "visualComplete" in verification:
69
+ payload["visualComplete"] = verification["visualComplete"]
70
+ if "backup" in verification:
71
+ payload["backup"] = verification["backup"]
72
+ if "semanticDiff" in verification:
73
+ payload["semanticDiff"] = verification["semanticDiff"]
74
+ return payload
75
+
76
+ def _operation_value(
77
+ self, operation: Dict[str, Any], *names: str, default: Any = None
78
+ ) -> Any:
79
+ for name in names:
80
+ if name in operation:
81
+ return operation[name]
82
+ return default
83
+
84
+ def _apply_transaction_operation(
85
+ self,
86
+ document: HwpxDocument,
87
+ operation: Dict[str, Any],
88
+ index: int,
89
+ ) -> Dict[str, Any]:
90
+ if not isinstance(operation, dict):
91
+ raise TypeError(f"operation {index} must be an object")
92
+ raw_type = self._operation_value(operation, "type", "op", "operation")
93
+ if not isinstance(raw_type, str) or not raw_type.strip():
94
+ raise ValueError(f"operation {index} must include a type")
95
+ op_type = raw_type.strip().replace("-", "_")
96
+
97
+ if op_type == "replace_text":
98
+ find = self._operation_value(operation, "findText", "find_text", "find")
99
+ replace = self._operation_value(
100
+ operation, "replaceText", "replace_text", "replace", default=""
101
+ )
102
+ if find is None:
103
+ raise ValueError("replace_text requires findText")
104
+ count = replace_in_doc(
105
+ document, find_text=str(find), replace_text=str(replace)
106
+ )
107
+ return {"type": op_type, "replaced_count": count}
108
+
109
+ if op_type == "batch_replace":
110
+ replacements = self._operation_value(operation, "replacements")
111
+ if not isinstance(replacements, list):
112
+ raise ValueError("batch_replace requires a replacements list")
113
+ result = batch_replace_in_doc(document, replacements)
114
+ return {"type": op_type, **result}
115
+
116
+ if op_type == "add_heading":
117
+ text = self._operation_value(operation, "text", default="")
118
+ level = int(self._operation_value(operation, "level", default=1))
119
+ paragraph_index = add_heading_to_doc(document, str(text), level)
120
+ return {"type": op_type, "paragraph_index": paragraph_index}
121
+
122
+ if op_type == "add_paragraph":
123
+ text = self._operation_value(operation, "text", default="")
124
+ style = self._operation_value(operation, "style")
125
+ paragraph_index = add_paragraph_to_doc(document, str(text), style)
126
+ return {"type": op_type, "paragraph_index": paragraph_index}
127
+
128
+ if op_type == "insert_paragraph":
129
+ paragraph_index = self._operation_value(
130
+ operation, "paragraphIndex", "paragraph_index"
131
+ )
132
+ if paragraph_index is None:
133
+ raise ValueError("insert_paragraph requires paragraphIndex")
134
+ text = self._operation_value(operation, "text", default="")
135
+ style = self._operation_value(operation, "style")
136
+ inserted = insert_paragraph_to_doc(
137
+ document, int(paragraph_index), str(text), style
138
+ )
139
+ return {"type": op_type, "inserted_index": inserted}
140
+
141
+ if op_type == "delete_paragraph":
142
+ paragraph_index = self._operation_value(
143
+ operation, "paragraphIndex", "paragraph_index"
144
+ )
145
+ if paragraph_index is None:
146
+ raise ValueError("delete_paragraph requires paragraphIndex")
147
+ remaining = delete_paragraph_from_doc(document, int(paragraph_index))
148
+ return {
149
+ "type": op_type,
150
+ "deleted_index": int(paragraph_index),
151
+ "remaining_paragraphs": remaining,
152
+ }
153
+
154
+ if op_type == "add_table":
155
+ rows = self._operation_value(operation, "rows")
156
+ cols = self._operation_value(operation, "cols", "columns")
157
+ if rows is None or cols is None:
158
+ raise ValueError("add_table requires rows and cols")
159
+ data = self._operation_value(operation, "data")
160
+ table_index = add_table_to_doc(document, int(rows), int(cols), data)
161
+ return {"type": op_type, "table_index": table_index}
162
+
163
+ if op_type == "set_table_cell_text":
164
+ table_index = self._operation_value(
165
+ operation, "tableIndex", "table_index", default=0
166
+ )
167
+ row = self._operation_value(operation, "row")
168
+ col = self._operation_value(operation, "col", "column")
169
+ text = self._operation_value(operation, "text", default="")
170
+ if row is None or col is None:
171
+ raise ValueError("set_table_cell_text requires row and col")
172
+ preserve_format = bool(
173
+ self._operation_value(
174
+ operation, "preserveFormat", "preserve_format", default=True
175
+ )
176
+ )
177
+ split_paragraphs = bool(
178
+ self._operation_value(
179
+ operation, "splitParagraphs", "split_paragraphs", default=False
180
+ )
181
+ )
182
+ set_cell_text(
183
+ document,
184
+ int(table_index),
185
+ int(row),
186
+ int(col),
187
+ str(text),
188
+ preserve_format=preserve_format,
189
+ split_paragraphs=split_paragraphs,
190
+ )
191
+ return {
192
+ "type": op_type,
193
+ "table_index": int(table_index),
194
+ "row": int(row),
195
+ "col": int(col),
196
+ }
197
+
198
+ if op_type == "fill_by_path":
199
+ mappings = self._operation_value(operation, "mappings")
200
+ if not isinstance(mappings, dict):
201
+ raise ValueError("fill_by_path requires mappings")
202
+ result = document.fill_by_path(mappings)
203
+ return {"type": op_type, **result}
204
+
205
+ if op_type == "add_page_break":
206
+ add_page_break_to_doc(document)
207
+ return {"type": op_type, "success": True}
208
+
209
+ raise ValueError(f"unsupported operation type: {raw_type}")
210
+
211
+ def apply_edits(
212
+ self,
213
+ path: str,
214
+ operations: Sequence[Dict[str, Any]],
215
+ *,
216
+ dry_run: bool = False,
217
+ quality: Any = None,
218
+ ) -> Dict[str, Any]:
219
+ document, resolved = self._context._open_document(path)
220
+ operation_results: List[Dict[str, Any]] = []
221
+ try:
222
+ for index, operation in enumerate(operations):
223
+ operation_result = self._apply_transaction_operation(document, operation, index)
224
+ operation_result["operationIndex"] = index
225
+ operation_results.append(operation_result)
226
+ except Exception as exc:
227
+ return {
228
+ "ok": False,
229
+ "rolledBack": True,
230
+ "dryRun": dry_run,
231
+ "filename": path,
232
+ "failedOperationIndex": len(operation_results),
233
+ "error": str(exc),
234
+ "operationsApplied": 0,
235
+ }
236
+
237
+ result: Dict[str, Any] = {
238
+ "ok": True,
239
+ "rolledBack": False,
240
+ "dryRun": dry_run,
241
+ "filename": path,
242
+ "operationsApplied": len(operation_results),
243
+ "operationResults": operation_results,
244
+ }
245
+ if dry_run:
246
+ result.update(save_dry_run(document, resolved, quality=quality))
247
+ return result
248
+ verification = self._save._save_transaction_document(
249
+ document, resolved, quality=quality
250
+ )
251
+ result["verificationReport"] = verification
252
+ result["openSafety"] = verification.get("openSafety")
253
+ if "visualComplete" in verification:
254
+ result["visualComplete"] = verification["visualComplete"]
255
+ if "backup" in verification:
256
+ result["backup"] = verification["backup"]
257
+ if "semanticDiff" in verification:
258
+ result["semanticDiff"] = verification["semanticDiff"]
259
+ return result
260
+
261
+ def undo_last_edit(self, path: str) -> Dict[str, Any]:
262
+ resolved = self._context._resolve_path(path)
263
+ if not isinstance(self._context.storage, LocalDocumentStorage):
264
+ return undo_last_backup(resolved)
265
+
266
+ backup_path = backup_path_for(resolved)
267
+ target_guard = self._context.storage.capture_output_guard(resolved)
268
+ if not target_guard.target_existed:
269
+ raise FileNotFoundError(f"target document does not exist: {resolved}")
270
+ backup_guard = self._save._capture_exact_sidecar_guard(backup_path)
271
+ if not backup_guard.target_existed:
272
+ raise FileNotFoundError(f"backup document does not exist: {backup_path}")
273
+ target_before = self._context.storage.read_guarded_bytes(target_guard)
274
+ backup_before = self._context.storage.read_guarded_bytes(backup_guard)
275
+ target_report = self._save._report_for_bytes(target_before, file_path=resolved)
276
+ backup_report = self._save._report_for_bytes(
277
+ backup_before,
278
+ file_path=backup_path,
279
+ )
280
+ if not target_report["openSafety"]["ok"]:
281
+ raise self._context._new_error(
282
+ "UNDO_TARGET_OPEN_SAFETY_FAILED",
283
+ "current document failed open-safety verification",
284
+ )
285
+ if not backup_report["openSafety"]["ok"]:
286
+ raise self._context._new_error(
287
+ "UNDO_BACKUP_OPEN_SAFETY_FAILED",
288
+ "backup document failed open-safety verification",
289
+ )
290
+
291
+ recoveries = self._save._preserve_exact_preimages(
292
+ [
293
+ (
294
+ resolved,
295
+ target_before,
296
+ target_guard.target_mode,
297
+ ),
298
+ (
299
+ backup_path,
300
+ backup_before,
301
+ backup_guard.target_mode,
302
+ ),
303
+ ],
304
+ marker="undo-recovery",
305
+ )
306
+ if recoveries is None:
307
+ raise RuntimeError("undo preimages could not be preserved")
308
+ target_publication: WorkspaceOutputGuard | None = None
309
+ backup_publication: WorkspaceOutputGuard | None = None
310
+ rollback_recoveries_ready = True
311
+ try:
312
+ target_publication = self._context.storage.atomic_publish_bytes(
313
+ target_guard,
314
+ backup_before,
315
+ mode=backup_guard.target_mode,
316
+ )
317
+ backup_publication = self._context.storage.atomic_publish_bytes(
318
+ backup_guard,
319
+ target_before,
320
+ mode=target_guard.target_mode,
321
+ )
322
+ self._context.storage.read_guarded_bytes(target_publication)
323
+ self._context.storage.read_guarded_bytes(backup_publication)
324
+ verification = self._save._report_for_bytes(
325
+ backup_before,
326
+ file_path=resolved,
327
+ )
328
+ if not verification["openSafety"]["ok"]:
329
+ raise RuntimeError("undo HWPX failed open-safety verification")
330
+ diff = self._save._semantic_diff_bytes(target_before, backup_before)
331
+ # Evidence was computed from immutable bytes; bind the success
332
+ # response to the exact two publications immediately before return.
333
+ self._context.storage.read_guarded_bytes(target_publication)
334
+ self._context.storage.read_guarded_bytes(backup_publication)
335
+ payload = {
336
+ "restored": True,
337
+ "filename": str(resolved),
338
+ "backupPath": str(backup_path),
339
+ "verificationReport": verification,
340
+ "openSafety": verification.get("openSafety"),
341
+ "semanticDiff": diff,
342
+ }
343
+ cleaned, rollback_recoveries_ready = self._save._cleanup_exact_recoveries(
344
+ recoveries
345
+ )
346
+ if not cleaned:
347
+ raise RuntimeError("undo recovery cleanup lost its exact claim")
348
+ try:
349
+ self._context.storage.read_guarded_bytes(target_publication)
350
+ self._context.storage.read_guarded_bytes(backup_publication)
351
+ except (FileNotFoundError, OSError, RuntimeError):
352
+ rollback_recoveries_ready = self._save._republish_exact_recoveries(
353
+ recoveries
354
+ )
355
+ raise
356
+ return payload
357
+ except BaseException:
358
+ if not rollback_recoveries_ready:
359
+ raise
360
+ if target_publication is not None and backup_publication is None:
361
+ try:
362
+ self._context.storage.read_guarded_bytes(target_publication)
363
+ self._context.storage.atomic_publish_bytes(
364
+ target_publication,
365
+ target_before,
366
+ mode=target_guard.target_mode,
367
+ )
368
+ except (FileNotFoundError, OSError, RuntimeError):
369
+ pass
370
+ raise
371
+ target_owned = False
372
+ backup_owned = False
373
+ if target_publication is not None:
374
+ try:
375
+ self._context.storage.read_guarded_bytes(target_publication)
376
+ target_owned = True
377
+ except (FileNotFoundError, OSError, RuntimeError):
378
+ pass
379
+ if backup_publication is not None:
380
+ try:
381
+ self._context.storage.read_guarded_bytes(backup_publication)
382
+ backup_owned = True
383
+ except (FileNotFoundError, OSError, RuntimeError):
384
+ pass
385
+ # Roll back only while both swap candidates are still ours. The
386
+ # A/B recovery sidecars above remain available regardless of how
387
+ # either publication changes during the following writes.
388
+ # target_owned/backup_owned are only ever set once the matching
389
+ # publication is non-None, so the explicit checks below never alter
390
+ # the branch — they re-express that invariant for the type checker.
391
+ if (
392
+ target_owned
393
+ and backup_owned
394
+ and target_publication is not None
395
+ and backup_publication is not None
396
+ ):
397
+ try:
398
+ self._context.storage.atomic_publish_bytes(
399
+ target_publication,
400
+ target_before,
401
+ mode=target_guard.target_mode,
402
+ )
403
+ self._context.storage.atomic_publish_bytes(
404
+ backup_publication,
405
+ backup_before,
406
+ mode=backup_guard.target_mode,
407
+ )
408
+ except (FileNotFoundError, OSError, RuntimeError):
409
+ # The exact A/B recovery sidecars are intentionally retained
410
+ # on every failure path, including cross-step CAS loss.
411
+ pass
412
+ raise
413
+
414
+ def byte_preserving_patch(
415
+ self,
416
+ path: str,
417
+ patches: Sequence[Dict[str, Any]],
418
+ *,
419
+ output: Optional[str] = None,
420
+ ) -> Dict[str, Any]:
421
+ try:
422
+ from hwpx.patch import paragraph_patch
423
+ except Exception as exc: # pragma: no cover - dependency compatibility
424
+ raise self._context._new_error(
425
+ "BYTE_PATCH_UNAVAILABLE",
426
+ "installed python-hwpx does not provide hwpx.patch.paragraph_patch",
427
+ ) from exc
428
+
429
+ source_path = self._context._resolve_path(path)
430
+ output_precondition = None
431
+ if isinstance(self._context.storage, LocalDocumentStorage):
432
+ output_precondition = self._context.storage.capture_output_precondition(
433
+ output if output else source_path
434
+ )
435
+ target_path = output_precondition.path
436
+ local_guard = (
437
+ output_precondition
438
+ if isinstance(output_precondition, WorkspaceOutputGuard)
439
+ else None
440
+ )
441
+ else:
442
+ target_path = (
443
+ self._context._resolve_output_path(output) if output else source_path
444
+ )
445
+ local_guard = None
446
+ target_before = (
447
+ self._context._local_storage().read_guarded_bytes(local_guard)
448
+ if local_guard is not None and local_guard.target_existed
449
+ else None
450
+ )
451
+ result = paragraph_patch(source_path, patches)
452
+ payload = result.to_dict()
453
+ payload["outputPath"] = str(target_path)
454
+ verification_report = {
455
+ "ok": bool(payload["openSafety"]["ok"]) and not payload["skipped"],
456
+ "filePath": str(target_path),
457
+ "openSafety": payload["openSafety"],
458
+ "byteIdentical": payload["byteIdentical"],
459
+ "changedParts": payload["changedParts"],
460
+ "skipped": payload["skipped"],
461
+ }
462
+ if payload["skipped"]:
463
+ payload["verificationReport"] = verification_report
464
+ return payload
465
+
466
+ candidate_bytes = bytes(result.data)
467
+ verification_report = build_hwpx_verification_report(
468
+ candidate_bytes,
469
+ file_path=target_path,
470
+ )
471
+ if not verification_report["openSafety"]["ok"]:
472
+ raise self._context._new_error(
473
+ "BYTE_PATCH_OPEN_SAFETY_FAILED",
474
+ "patched HWPX failed open-safety verification: "
475
+ + verification_report["openSafety"]["summary"],
476
+ )
477
+
478
+ target_recoveries: tuple[_ExactRecoveryPublication, ...] = ()
479
+ if target_before is not None:
480
+ preserved_target = self._save._preserve_exact_preimages(
481
+ [
482
+ (
483
+ target_path,
484
+ target_before,
485
+ (local_guard.target_mode if local_guard is not None else None),
486
+ )
487
+ ],
488
+ marker="rollback-recovery",
489
+ )
490
+ if preserved_target is None:
491
+ raise RuntimeError("byte patch target preimage could not be preserved")
492
+ target_recoveries = preserved_target
493
+ publication: WorkspaceOutputGuard | None = None
494
+ exact_backup: _ExactBackupResult | None = None
495
+ materialized_guard: WorkspaceOutputGuard | None = None
496
+ rollback_recoveries_ready = True
497
+ try:
498
+ if output_precondition is not None:
499
+ local_storage = self._context._local_storage()
500
+ local_guard = local_storage.materialize_output_guard(
501
+ output_precondition
502
+ )
503
+ materialized_guard = local_guard
504
+ # Preserve the retained public backup contract, but bind the
505
+ # sidecar to the exact preimage captured with the output guard.
506
+ exact_backup = self._save._rotate_and_backup_exact(
507
+ target_path,
508
+ target_guard=local_guard,
509
+ target_bytes=target_before,
510
+ )
511
+ backup = exact_backup.report
512
+ publication = local_storage.atomic_publish_bytes(
513
+ local_guard,
514
+ candidate_bytes,
515
+ )
516
+ else:
517
+ backup = rotate_and_backup(target_path)
518
+ target_path.parent.mkdir(parents=True, exist_ok=True)
519
+ fd, tmp_name = tempfile.mkstemp(
520
+ prefix=f".{target_path.stem}.",
521
+ suffix=target_path.suffix or ".hwpx",
522
+ dir=str(target_path.parent),
523
+ )
524
+ tmp_path = Path(tmp_name)
525
+ try:
526
+ os.close(fd)
527
+ tmp_path.write_bytes(candidate_bytes)
528
+ os.replace(tmp_path, target_path)
529
+ finally:
530
+ tmp_path.unlink(missing_ok=True)
531
+ verification_report["backup"] = backup.to_dict()
532
+ if backup.backup_path is not None:
533
+ try:
534
+ verification_report["semanticDiff"] = (
535
+ self._save._semantic_diff_bytes(
536
+ target_before or b"",
537
+ candidate_bytes,
538
+ )
539
+ if local_guard is not None
540
+ else semantic_diff(backup.backup_path, target_path)
541
+ )
542
+ except Exception as exc: # pragma: no cover - diagnostic fallback
543
+ verification_report["semanticDiff"] = {
544
+ "schemaVersion": "hwpx.semantic-diff.v1",
545
+ "changed": True,
546
+ "summary": f"Semantic diff unavailable: {exc}",
547
+ "items": [],
548
+ "error": str(exc),
549
+ }
550
+ if publication is not None:
551
+ # Receipt evidence is valid only while the exact published
552
+ # candidate and reported backup sidecars still occupy their
553
+ # authorized names with the exact published identities.
554
+ for mutation in cast(_ExactBackupResult, exact_backup).mutations:
555
+ self._save._assert_exact_sidecar_publication(mutation.publication)
556
+ self._context._local_storage().read_guarded_bytes(publication)
557
+ all_recoveries = target_recoveries + (
558
+ exact_backup.recoveries if exact_backup is not None else ()
559
+ )
560
+ cleaned, rollback_recoveries_ready = self._save._cleanup_exact_recoveries(
561
+ all_recoveries
562
+ )
563
+ if not cleaned:
564
+ raise RuntimeError("byte patch recovery cleanup lost its exact claim")
565
+ try:
566
+ if publication is not None:
567
+ for mutation in cast(_ExactBackupResult, exact_backup).mutations:
568
+ self._save._assert_exact_sidecar_publication(
569
+ mutation.publication
570
+ )
571
+ self._context._local_storage().read_guarded_bytes(publication)
572
+ except (FileNotFoundError, OSError, RuntimeError):
573
+ rollback_recoveries_ready = self._save._republish_exact_recoveries(
574
+ all_recoveries
575
+ )
576
+ raise
577
+ except BaseException:
578
+ target_preimage_preserved = (
579
+ target_before is None or rollback_recoveries_ready
580
+ )
581
+ if target_preimage_preserved and publication is not None:
582
+ local_storage = self._context._local_storage()
583
+ try:
584
+ if local_guard is not None and local_guard.target_existed:
585
+ if target_before is not None:
586
+ restored_publication = (
587
+ local_storage.atomic_publish_bytes(
588
+ publication,
589
+ target_before,
590
+ mode=local_guard.target_mode,
591
+ )
592
+ )
593
+ local_storage.read_guarded_bytes(
594
+ restored_publication
595
+ )
596
+ else:
597
+ local_storage.remove_guarded_output(publication)
598
+ except (OSError, RuntimeError):
599
+ pass
600
+ if exact_backup is not None and target_preimage_preserved:
601
+ self._save._rollback_exact_backup_mutations(
602
+ exact_backup.mutations,
603
+ preimages_preserved=rollback_recoveries_ready,
604
+ )
605
+ if materialized_guard is not None:
606
+ self._context._local_storage().cleanup_owned_parent_directories(
607
+ materialized_guard
608
+ )
609
+ raise
610
+ verification_report["filePath"] = str(target_path)
611
+ verification_report["byteIdentical"] = payload["byteIdentical"]
612
+ verification_report["changedParts"] = payload["changedParts"]
613
+ verification_report["skipped"] = payload["skipped"]
614
+ payload["verificationReport"] = verification_report
615
+ payload["openSafety"] = verification_report["openSafety"]
616
+ return payload
@@ -0,0 +1,69 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Typed structured output for the layout preview tool."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+
11
+ class _StrictOutput(BaseModel):
12
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
13
+
14
+
15
+ class RenderPreviewScreenshotEngine(_StrictOutput):
16
+ requested: bool
17
+ available: bool
18
+ backend: str | None
19
+ executable: str | None = None
20
+ message: str
21
+
22
+
23
+ class RenderPreviewViewer(_StrictOutput):
24
+ """Self-contained scrollable document viewer, emitted only when ``viewer=True``.
25
+
26
+ The viewer HTML is always written to ``viewerPath`` inside the workspace; the
27
+ inline ``html`` is a convenience copy that is dropped (``htmlOmitted``) when it
28
+ exceeds the inline byte cap so the response can never balloon.
29
+ ``equationRendering`` reports the honest fidelity tier as MathML/latex/script
30
+ fallback counts (Constitution IX).
31
+ """
32
+
33
+ viewer_path: str = Field(alias="viewerPath")
34
+ byte_size: int = Field(alias="byteSize", ge=0)
35
+ page_count: int = Field(alias="pageCount", ge=0)
36
+ warnings: list[str]
37
+ fidelity_tier: str = Field(alias="fidelityTier")
38
+ equation_library: Literal["latex2mathml", "absent"] = Field(alias="equationLibrary")
39
+ equation_rendering: dict[str, int] = Field(alias="equationRendering")
40
+ html: str | None = None
41
+ html_omitted: str | None = Field(alias="htmlOmitted", default=None)
42
+
43
+
44
+ class RenderPreviewOutput(_StrictOutput):
45
+ """Manifest carried in ``CallToolResult.structuredContent``."""
46
+
47
+ schema_version: Literal["hwpx.render-preview.v1"] = Field(alias="schemaVersion")
48
+ status: Literal["ok", "partial", "blocked", "html_only"]
49
+ generated_at: str = Field(alias="generatedAt")
50
+ source_path: str = Field(alias="sourcePath")
51
+ output_dir: str = Field(alias="outputDir")
52
+ html_path: str = Field(alias="htmlPath")
53
+ manifest_path: str = Field(alias="manifestPath")
54
+ visual_review_path: str = Field(alias="visualReviewPath")
55
+ mode: Literal["pages", "long"]
56
+ page_count: int = Field(alias="pageCount", ge=0)
57
+ pages: list[dict[str, Any]]
58
+ screenshots: list[dict[str, Any]]
59
+ screenshot_engine: RenderPreviewScreenshotEngine = Field(alias="screenshotEngine")
60
+ warnings: list[str]
61
+ suggestion: str | None
62
+ viewer: RenderPreviewViewer | None = None
63
+
64
+
65
+ __all__ = [
66
+ "RenderPreviewOutput",
67
+ "RenderPreviewScreenshotEngine",
68
+ "RenderPreviewViewer",
69
+ ]