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,589 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Tracked-change automation handlers for the optional MCP adapter."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import tempfile
8
+ from datetime import date, datetime
9
+ from pathlib import Path
10
+ from typing import Any, cast
11
+
12
+ from .. import quality as quality_contract
13
+ from ..core.document import open_doc
14
+ from ..office.document_ops import verify_redline as verify_hwpx_redline
15
+ from ..storage import (
16
+ build_hwpx_verification_report,
17
+ )
18
+ from ..utils.helpers import resolve_path
19
+
20
+ _REDLINE_RECEIPT_FIELDS = (
21
+ "changeCount",
22
+ "marksLinked",
23
+ "displayEnabled",
24
+ "opensClean",
25
+ "render_checked",
26
+ )
27
+
28
+
29
+ class _RedlineVerificationError(RuntimeError):
30
+ """Carry typed redline verification failures and their receipt."""
31
+
32
+ def __init__(self, failures: list[dict[str, Any]], receipt: dict[str, Any]) -> None:
33
+ self.failures = failures
34
+ self.receipt = receipt
35
+ messages = "; ".join(
36
+ str(item.get("message", ""))
37
+ for item in failures
38
+ if item.get("message")
39
+ )
40
+ super().__init__(messages or "redline verification failed")
41
+
42
+
43
+ def _tracked_edit_format_error(
44
+ filename: str,
45
+ *,
46
+ field: str,
47
+ role: str,
48
+ dry_run: bool,
49
+ ) -> dict[str, Any] | None:
50
+ suffix = Path(str(filename or "")).suffix.lower()
51
+ if suffix == ".hwpx":
52
+ return None
53
+ return {
54
+ "ok": False,
55
+ "applied": False,
56
+ "dryRun": dry_run,
57
+ field: filename,
58
+ "error": f"unsupported {role} format {suffix!r}; only .hwpx is supported",
59
+ "handoff_status": "unsupported_format",
60
+ "next_action": "use a .hwpx filename (ODT 기안문 등 비-HWPX 포맷은 별도 트랙)",
61
+ }
62
+
63
+
64
+ def _tracked_paragraph_text(paragraph: Any) -> str:
65
+ value = getattr(paragraph, "text", "")
66
+ return value if isinstance(value, str) else str(value or "")
67
+
68
+
69
+ def _tracked_edit_error(
70
+ index: int,
71
+ code: str,
72
+ message: str,
73
+ *,
74
+ edit: Any,
75
+ **extra: Any,
76
+ ) -> dict[str, Any]:
77
+ payload = {
78
+ "editIndex": index,
79
+ "code": code,
80
+ "message": message,
81
+ "edit": edit,
82
+ }
83
+ payload.update(extra)
84
+ return payload
85
+
86
+
87
+ def _tracked_edit_paragraph_index(edit: dict[str, Any]) -> int:
88
+ raw = edit.get("paragraph_index", edit.get("paragraphIndex"))
89
+ if isinstance(raw, bool):
90
+ raise ValueError("paragraph_index must be an integer")
91
+ return int(cast(Any, raw))
92
+
93
+
94
+ def _validate_tracked_edits(edits: Any, paragraphs: list[Any]) -> dict[str, Any]:
95
+ errors: list[dict[str, Any]] = []
96
+ previews: list[dict[str, Any]] = []
97
+ paragraph_count = len(paragraphs)
98
+
99
+ if not isinstance(edits, list):
100
+ return {
101
+ "ok": False,
102
+ "errors": [
103
+ {
104
+ "code": "invalid_edits",
105
+ "message": "edits must be a list",
106
+ "edit": edits,
107
+ }
108
+ ],
109
+ "previews": [],
110
+ "wouldChangeCount": 0,
111
+ }
112
+ if not edits:
113
+ return {
114
+ "ok": False,
115
+ "errors": [
116
+ {
117
+ "code": "empty_edits",
118
+ "message": "edits must contain at least one tracked edit",
119
+ "edit": edits,
120
+ }
121
+ ],
122
+ "previews": [],
123
+ "wouldChangeCount": 0,
124
+ }
125
+
126
+ for index, edit in enumerate(edits):
127
+ edit_error_count = len(errors)
128
+ if not isinstance(edit, dict):
129
+ errors.append(
130
+ _tracked_edit_error(
131
+ index,
132
+ "invalid_edit",
133
+ "each edit must be an object",
134
+ edit=edit,
135
+ )
136
+ )
137
+ continue
138
+
139
+ edit_type = str(edit.get("type", edit.get("op", ""))).strip().lower()
140
+ try:
141
+ paragraph_index = _tracked_edit_paragraph_index(edit)
142
+ except (TypeError, ValueError):
143
+ errors.append(
144
+ _tracked_edit_error(
145
+ index,
146
+ "invalid_paragraph_index",
147
+ "paragraph_index must be an integer",
148
+ edit=edit,
149
+ )
150
+ )
151
+ continue
152
+
153
+ paragraph_text = ""
154
+ if paragraph_index < 0 or paragraph_index >= paragraph_count:
155
+ errors.append(
156
+ _tracked_edit_error(
157
+ index,
158
+ "paragraph_index_out_of_range",
159
+ f"paragraph_index {paragraph_index} is outside 0..{max(paragraph_count - 1, 0)}",
160
+ edit=edit,
161
+ paragraphIndex=paragraph_index,
162
+ paragraphCount=paragraph_count,
163
+ )
164
+ )
165
+ else:
166
+ paragraph_text = _tracked_paragraph_text(paragraphs[paragraph_index])
167
+
168
+ change_count = 0
169
+ if edit_type == "insert":
170
+ text = edit.get("text")
171
+ if not isinstance(text, str) or text == "":
172
+ errors.append(
173
+ _tracked_edit_error(
174
+ index,
175
+ "insert_text_required",
176
+ "insert edits require non-empty text",
177
+ edit=edit,
178
+ )
179
+ )
180
+ change_count = 1
181
+ elif edit_type == "delete":
182
+ match = edit.get("match")
183
+ if match is not None and (not isinstance(match, str) or match == ""):
184
+ errors.append(
185
+ _tracked_edit_error(
186
+ index,
187
+ "delete_match_invalid",
188
+ "delete match must be a non-empty string when provided",
189
+ edit=edit,
190
+ )
191
+ )
192
+ elif paragraph_index >= 0 and paragraph_index < paragraph_count:
193
+ if match is None and not paragraph_text:
194
+ errors.append(
195
+ _tracked_edit_error(
196
+ index,
197
+ "delete_target_empty",
198
+ "delete edits without match require a paragraph with text",
199
+ edit=edit,
200
+ paragraphIndex=paragraph_index,
201
+ )
202
+ )
203
+ elif match is not None and match not in paragraph_text:
204
+ errors.append(
205
+ _tracked_edit_error(
206
+ index,
207
+ "delete_match_not_found",
208
+ "delete match text was not found in the paragraph; no output was written",
209
+ edit=edit,
210
+ paragraphIndex=paragraph_index,
211
+ match=match,
212
+ warning=True,
213
+ )
214
+ )
215
+ change_count = 1
216
+ elif edit_type == "replace":
217
+ old = edit.get("old")
218
+ new = edit.get("new")
219
+ if not isinstance(old, str) or old == "":
220
+ errors.append(
221
+ _tracked_edit_error(
222
+ index,
223
+ "replace_old_required",
224
+ "replace edits require non-empty old text",
225
+ edit=edit,
226
+ )
227
+ )
228
+ elif (
229
+ paragraph_index >= 0
230
+ and paragraph_index < paragraph_count
231
+ and old not in paragraph_text
232
+ ):
233
+ errors.append(
234
+ _tracked_edit_error(
235
+ index,
236
+ "replace_old_not_found",
237
+ "replace old text was not found in the paragraph; no output was written",
238
+ edit=edit,
239
+ paragraphIndex=paragraph_index,
240
+ old=old,
241
+ )
242
+ )
243
+ if not isinstance(new, str) or new == "":
244
+ errors.append(
245
+ _tracked_edit_error(
246
+ index,
247
+ "replace_new_required",
248
+ "replace edits require non-empty new text",
249
+ edit=edit,
250
+ )
251
+ )
252
+ change_count = 2
253
+ else:
254
+ errors.append(
255
+ _tracked_edit_error(
256
+ index,
257
+ "unknown_edit_type",
258
+ "edit type must be one of: insert, delete, replace",
259
+ edit=edit,
260
+ type=edit_type,
261
+ )
262
+ )
263
+
264
+ if len(errors) == edit_error_count:
265
+ previews.append(
266
+ {
267
+ "editIndex": index,
268
+ "type": edit_type,
269
+ "paragraphIndex": paragraph_index,
270
+ "targetParagraphText": paragraph_text,
271
+ "changeCount": change_count,
272
+ }
273
+ )
274
+
275
+ return {
276
+ "ok": not errors,
277
+ "errors": errors,
278
+ "previews": previews,
279
+ "wouldChangeCount": sum(item["changeCount"] for item in previews),
280
+ }
281
+
282
+
283
+ def _normalize_tracked_date(value: Any) -> str | None:
284
+ if value is None:
285
+ return None
286
+ if isinstance(value, datetime):
287
+ return value.isoformat()
288
+ if isinstance(value, date):
289
+ return value.isoformat()
290
+ return str(value)
291
+
292
+
293
+ def _redline_receipt(report: dict[str, Any]) -> dict[str, Any]:
294
+ return {key: report.get(key) for key in _REDLINE_RECEIPT_FIELDS}
295
+
296
+
297
+ def _redline_receipt_failures(
298
+ report: dict[str, Any],
299
+ *,
300
+ expected_change_count: int,
301
+ ) -> list[dict[str, Any]]:
302
+ failures: list[dict[str, Any]] = []
303
+ actual_change_count = int(report.get("changeCount") or 0)
304
+ if actual_change_count < expected_change_count:
305
+ failures.append(
306
+ {
307
+ "code": "redline_change_count_mismatch",
308
+ "message": (
309
+ f"verify_redline reported {actual_change_count} changes; "
310
+ f"expected at least {expected_change_count}"
311
+ ),
312
+ "expectedChangeCount": expected_change_count,
313
+ "actualChangeCount": actual_change_count,
314
+ }
315
+ )
316
+ if report.get("marksLinked") is not True:
317
+ failures.append(
318
+ {
319
+ "code": "redline_marks_unlinked",
320
+ "message": "verify_redline reported unlinked body/header track-change marks",
321
+ }
322
+ )
323
+ if report.get("displayEnabled") is not True:
324
+ failures.append(
325
+ {
326
+ "code": "redline_display_disabled",
327
+ "message": "verify_redline reported track-change display is not enabled",
328
+ }
329
+ )
330
+ if report.get("opensClean") is False:
331
+ failures.append(
332
+ {
333
+ "code": "redline_does_not_open_clean",
334
+ "message": "verify_redline reported the destination does not open cleanly",
335
+ }
336
+ )
337
+ return failures
338
+
339
+
340
+ def _save_verified_redline_document(
341
+ doc: Any,
342
+ *,
343
+ source_path: str,
344
+ destination_path: str,
345
+ expected_change_count: int,
346
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
347
+ if verify_hwpx_redline is None:
348
+ raise RuntimeError(
349
+ "installed python-hwpx does not provide hwpx.tools.redline.verify_redline"
350
+ )
351
+
352
+ destination = Path(destination_path)
353
+ destination.parent.mkdir(parents=True, exist_ok=True)
354
+ fd, tmp_name = tempfile.mkstemp(
355
+ prefix=f".{destination.stem}.",
356
+ suffix=".hwpx",
357
+ dir=str(destination.parent),
358
+ )
359
+ tmp_path = Path(tmp_name)
360
+ try:
361
+ os.close(fd)
362
+ save_report = quality_contract.save_through_pipeline(doc, tmp_path)
363
+ verification = build_hwpx_verification_report(tmp_path)
364
+ if not verification["openSafety"]["ok"]:
365
+ raise RuntimeError(
366
+ "tracked-edit HWPX failed open-safety verification: "
367
+ + verification["openSafety"]["summary"]
368
+ )
369
+ redline_report = verify_hwpx_redline(source_path, tmp_path)
370
+ failures = _redline_receipt_failures(
371
+ redline_report,
372
+ expected_change_count=expected_change_count,
373
+ )
374
+ if failures:
375
+ raise _RedlineVerificationError(failures, redline_report)
376
+ os.replace(tmp_path, destination)
377
+ verification["filePath"] = str(destination)
378
+ verification["visualComplete"] = quality_contract.visual_complete_block(
379
+ save_report
380
+ )
381
+ return verification, redline_report
382
+ except BaseException:
383
+ tmp_path.unlink(missing_ok=True)
384
+ raise
385
+
386
+
387
+ def add_tracked_edit(
388
+ source_filename: str,
389
+ destination_filename: str,
390
+ edits: list[dict[str, Any]],
391
+ author: str = "AI Agent",
392
+ date: Any = None,
393
+ dry_run: bool = False,
394
+ ) -> dict:
395
+ """원본 HWPX를 보존하고 destination에 추적 삽입/삭제/치환(redline)을 작성합니다."""
396
+ source_path = resolve_path(source_filename)
397
+ guard = _tracked_edit_format_error(
398
+ source_filename,
399
+ field="source_filename",
400
+ role="source",
401
+ dry_run=dry_run,
402
+ )
403
+ if guard is not None:
404
+ return guard
405
+
406
+ destination_path = resolve_path(destination_filename)
407
+ guard = _tracked_edit_format_error(
408
+ destination_filename,
409
+ field="destination_filename",
410
+ role="output",
411
+ dry_run=dry_run,
412
+ )
413
+ if guard is not None:
414
+ return guard
415
+
416
+ if Path(source_path).resolve(strict=False) == Path(destination_path).resolve(
417
+ strict=False
418
+ ):
419
+ return {
420
+ "ok": False,
421
+ "applied": False,
422
+ "dryRun": dry_run,
423
+ "error": "add_tracked_edit refuses source-in-place edits; destination_filename must differ from source",
424
+ "reason": "source equals destination",
425
+ "source": {"filename": source_filename, "path": source_path},
426
+ "destination": {"filename": destination_filename, "path": destination_path},
427
+ "errors": [
428
+ {
429
+ "code": "source_destination_same",
430
+ "message": "destination_filename must differ from source_filename",
431
+ }
432
+ ],
433
+ }
434
+
435
+ validation_doc = open_doc(source_path)
436
+ try:
437
+ validation = _validate_tracked_edits(
438
+ edits, list(getattr(validation_doc, "paragraphs", []))
439
+ )
440
+ finally:
441
+ validation_doc.close()
442
+
443
+ if not validation["ok"]:
444
+ return {
445
+ "ok": False,
446
+ "applied": False,
447
+ "dryRun": dry_run,
448
+ "error": "tracked edit validation failed",
449
+ "handoff_status": "blocked",
450
+ "source": {"filename": source_filename, "path": source_path},
451
+ "destination": {"filename": destination_filename, "path": destination_path},
452
+ "errors": validation["errors"],
453
+ "editPreviews": validation["previews"],
454
+ "written": False,
455
+ }
456
+
457
+ if dry_run:
458
+ return {
459
+ "ok": True,
460
+ "applied": False,
461
+ "dryRun": True,
462
+ "handoff_status": "dry_run",
463
+ "source": {"filename": source_filename, "path": source_path},
464
+ "destination": {
465
+ "filename": destination_filename,
466
+ "path": destination_path,
467
+ "exists": Path(destination_path).exists(),
468
+ },
469
+ "editsValidated": len(validation["previews"]),
470
+ "wouldChangeCount": validation["wouldChangeCount"],
471
+ "wouldWrite": True,
472
+ "written": False,
473
+ "editPreviews": validation["previews"],
474
+ }
475
+
476
+ normalized_date = _normalize_tracked_date(date)
477
+ doc = open_doc(source_path)
478
+ edit_results: list[dict[str, Any]] = []
479
+ try:
480
+ for index, edit in enumerate(edits):
481
+ edit_type = str(edit.get("type", edit.get("op", ""))).strip().lower()
482
+ paragraph_index = _tracked_edit_paragraph_index(edit)
483
+ paragraph = doc.paragraphs[paragraph_index]
484
+ before_text = _tracked_paragraph_text(paragraph)
485
+ if edit_type == "insert":
486
+ change_id = doc.add_tracked_insert(
487
+ paragraph,
488
+ edit["text"],
489
+ author=author,
490
+ date=normalized_date,
491
+ )
492
+ change_ids = [change_id]
493
+ elif edit_type == "delete":
494
+ change_id = doc.add_tracked_delete(
495
+ paragraph,
496
+ match=edit.get("match"),
497
+ author=author,
498
+ date=normalized_date,
499
+ )
500
+ change_ids = [change_id]
501
+ elif edit_type == "replace":
502
+ replace_ids = doc.add_tracked_replace(
503
+ paragraph,
504
+ edit["old"],
505
+ edit["new"],
506
+ author=author,
507
+ date=normalized_date,
508
+ )
509
+ change_ids = list(replace_ids)
510
+ else: # pragma: no cover - validation prevents this branch
511
+ raise ValueError(f"unsupported tracked edit type: {edit_type}")
512
+ edit_results.append(
513
+ {
514
+ "editIndex": index,
515
+ "type": edit_type,
516
+ "paragraphIndex": paragraph_index,
517
+ "beforeText": before_text,
518
+ "changeIds": change_ids,
519
+ "changeCount": len(change_ids),
520
+ }
521
+ )
522
+ except Exception as exc:
523
+ return {
524
+ "ok": False,
525
+ "applied": False,
526
+ "dryRun": False,
527
+ "error": "tracked edit application failed before save",
528
+ "handoff_status": "blocked",
529
+ "source": {"filename": source_filename, "path": source_path},
530
+ "destination": {"filename": destination_filename, "path": destination_path},
531
+ "errors": [
532
+ {
533
+ "code": "tracked_edit_apply_failed",
534
+ "message": str(exc),
535
+ "failedOperationIndex": len(edit_results),
536
+ }
537
+ ],
538
+ "editResults": edit_results,
539
+ "written": False,
540
+ }
541
+
542
+ expected_change_count = sum(item["changeCount"] for item in edit_results)
543
+ try:
544
+ verification, redline_report = _save_verified_redline_document(
545
+ doc,
546
+ source_path=source_path,
547
+ destination_path=destination_path,
548
+ expected_change_count=expected_change_count,
549
+ )
550
+ except _RedlineVerificationError as exc:
551
+ return {
552
+ "ok": False,
553
+ "applied": False,
554
+ "dryRun": False,
555
+ "error": "redline verification failed; destination was not written",
556
+ "handoff_status": "blocked",
557
+ "source": {"filename": source_filename, "path": source_path},
558
+ "destination": {"filename": destination_filename, "path": destination_path},
559
+ "errors": exc.failures,
560
+ "editResults": edit_results,
561
+ "redlineReceipt": _redline_receipt(exc.receipt),
562
+ "redlineVerification": exc.receipt,
563
+ "written": False,
564
+ }
565
+ finally:
566
+ doc.close()
567
+
568
+ return {
569
+ "ok": True,
570
+ "applied": True,
571
+ "dryRun": False,
572
+ "handoff_status": "ready",
573
+ "source": {"filename": source_filename, "path": source_path},
574
+ "destination": {"filename": destination_filename, "path": destination_path},
575
+ "author": author,
576
+ "date": normalized_date,
577
+ "editsApplied": len(edit_results),
578
+ "changeCount": expected_change_count,
579
+ "editResults": edit_results,
580
+ "redlineReceipt": _redline_receipt(redline_report),
581
+ "redlineVerification": redline_report,
582
+ "verification": verification,
583
+ "openSafety": verification.get("openSafety"),
584
+ "visualComplete": verification.get("visualComplete"),
585
+ "written": True,
586
+ }
587
+
588
+
589
+ __all__ = ["add_tracked_edit"]
@@ -0,0 +1,105 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Workflow automation handlers for the optional MCP adapter."""
3
+
4
+ from __future__ import annotations
5
+
6
+
7
+ from ..workflow.service import WorkflowService
8
+ from ..runtime_services import RUNTIME_SERVICES
9
+ from ..tool_contract import contract_hash
10
+ from ._shared import (
11
+ _render_client,
12
+ )
13
+
14
+
15
+ def _workflow_service() -> WorkflowService:
16
+ """Build a request-scoped service over the durable configured store."""
17
+
18
+ capability = RUNTIME_SERVICES.require_tool_bindings()["mcp_server_health"]()[
19
+ "capability"
20
+ ]
21
+ return WorkflowService(
22
+ RUNTIME_SERVICES.require_tool_bindings(),
23
+ capability_ok=bool(capability["ok"]),
24
+ render_client=_render_client(),
25
+ tool_spec_hash=contract_hash(),
26
+ )
27
+
28
+
29
+ def start_workflow(
30
+ family: str,
31
+ idempotency_key: str,
32
+ source_path: str = None,
33
+ output_path: str = None,
34
+ expected_revision: str = None,
35
+ parameters: dict = None,
36
+ budget: dict = None,
37
+ policy: dict = None,
38
+ ) -> dict:
39
+ """타입화된 작업을 서버 강제 durable workflow로 시작합니다."""
40
+
41
+ return _workflow_service().start(
42
+ family=family,
43
+ idempotency_key=idempotency_key,
44
+ source_path=source_path,
45
+ output_path=output_path,
46
+ expected_revision=expected_revision,
47
+ parameters=parameters,
48
+ budget=budget,
49
+ policy=policy,
50
+ )
51
+
52
+
53
+ def get_workflow(workflow_id: str) -> dict:
54
+ """현재 상태와 증거를 구조화된 workflow receipt로 조회합니다."""
55
+
56
+ return _workflow_service().get(workflow_id)
57
+
58
+
59
+ def get_workflow_result(workflow_id: str, action_hash: str = None) -> dict:
60
+ """암호화 저장된 workflow primitive 결과를 content hash와 함께 조회합니다."""
61
+
62
+ return _workflow_service().workflow_result(workflow_id, action_hash=action_hash)
63
+
64
+
65
+ def continue_workflow(workflow_id: str) -> dict:
66
+ """서버 정책에 따라 workflow를 다음 durable 경계까지 진행합니다."""
67
+
68
+ return _workflow_service().continue_workflow(workflow_id)
69
+
70
+
71
+ def approve_workflow_decision(
72
+ workflow_id: str,
73
+ approved: bool,
74
+ action_hash: str = None,
75
+ ) -> dict:
76
+ """decision 상태의 정확한 계획 action을 승인하거나 거절합니다."""
77
+
78
+ return _workflow_service().approve_decision(
79
+ workflow_id,
80
+ approved=approved,
81
+ action_hash=action_hash,
82
+ )
83
+
84
+
85
+ def cancel_workflow(workflow_id: str, reason: str = "CLIENT_CANCELLED") -> dict:
86
+ """아직 terminal이 아닌 workflow를 취소합니다."""
87
+
88
+ return _workflow_service().cancel(workflow_id, reason=reason)
89
+
90
+
91
+ def resume_workflow(workflow_id: str) -> dict:
92
+ """재시작 후 durable receipt를 기준으로 안전하게 workflow를 재개합니다."""
93
+
94
+ return _workflow_service().resume(workflow_id)
95
+
96
+
97
+ __all__ = [
98
+ "start_workflow",
99
+ "get_workflow",
100
+ "get_workflow_result",
101
+ "continue_workflow",
102
+ "approve_workflow_decision",
103
+ "cancel_workflow",
104
+ "resume_workflow",
105
+ ]