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,213 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """ToolSpec-bound allow-listed primitive dispatcher."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from hwpx_automation.tool_contract import expected_tool_names
11
+
12
+ from .models import WorkFamily, WorkflowEvent, content_hash
13
+ from .policy import ActionRequest, PolicyViolation, WorkflowPolicyEngine
14
+ from .store import WorkflowStore
15
+
16
+
17
+ FAMILY_TOOL_ALLOWLISTS: dict[WorkFamily, frozenset[str]] = {
18
+ WorkFamily.READ_EXTRACT: frozenset(
19
+ {
20
+ "get_document_text",
21
+ "get_document_info",
22
+ "get_document_outline",
23
+ "get_document_map",
24
+ "hwpx_to_markdown",
25
+ "hwpx_extract_json",
26
+ }
27
+ ),
28
+ WorkFamily.TRANSACTIONAL_EDIT: frozenset(
29
+ {
30
+ "get_document_info",
31
+ "apply_edits",
32
+ "undo_last_edit",
33
+ "doc_diff",
34
+ }
35
+ ),
36
+ WorkFamily.KNOWN_TEMPLATE_FILL: frozenset(
37
+ {
38
+ "list_form_fields", "fill_form_field", "analyze_template_formfit",
39
+ "apply_template_formfit", "inspect_fill_residue", "verify_form_fill",
40
+ "scan_form_guidance", "apply_table_ops",
41
+ }
42
+ ),
43
+ WorkFamily.UNKNOWN_FORM_FILL: frozenset(
44
+ {"scan_form_guidance", "apply_table_ops", "apply_body_ops", "inspect_fill_residue", "verify_form_fill"}
45
+ ),
46
+ WorkFamily.STRUCTURAL_TABLE_EDIT: frozenset(
47
+ {"scan_form_guidance", "apply_table_ops", "doc_diff"}
48
+ ),
49
+ WorkFamily.TYPED_AUTHORING: frozenset(
50
+ {
51
+ "get_document_plan_schema",
52
+ "validate_document_plan",
53
+ "analyze_document_plan",
54
+ "create_document_from_plan",
55
+ "inspect_document_authoring_quality",
56
+ "inspect_official_document_style",
57
+ }
58
+ ),
59
+ WorkFamily.MUST_ABSTAIN: frozenset(),
60
+ }
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class DispatchOutcome:
65
+ result: Any
66
+ replayed: bool
67
+ receipt: WorkflowEvent
68
+
69
+
70
+ def _result_evidence(result: Any) -> dict[str, Any]:
71
+ """Extract only policy booleans; never persist tool result bodies."""
72
+
73
+ if not isinstance(result, Mapping):
74
+ return {"ok": True, "openSafetyOk": None}
75
+ ok = result.get("ok")
76
+ if ok is None:
77
+ ok = result.get("created")
78
+ if ok is None and isinstance(result.get("verificationReport"), Mapping):
79
+ ok = result["verificationReport"].get("ok")
80
+ open_safety = result.get("openSafety")
81
+ if not isinstance(open_safety, Mapping) and isinstance(result.get("verificationReport"), Mapping):
82
+ open_safety = result["verificationReport"].get("openSafety")
83
+ if not isinstance(open_safety, Mapping) and isinstance(result.get("verification"), Mapping):
84
+ open_safety = result["verification"].get("openSafety")
85
+ open_safety_ok = open_safety.get("ok") if isinstance(open_safety, Mapping) else None
86
+ return {"ok": ok is not False, "openSafetyOk": open_safety_ok}
87
+
88
+
89
+ class AllowlistedDispatcher:
90
+ def __init__(self, namespace: Mapping[str, Callable[..., Any]]) -> None:
91
+ contract_names = expected_tool_names(advanced=True)
92
+ invalid = set().union(*FAMILY_TOOL_ALLOWLISTS.values()) - contract_names
93
+ if invalid:
94
+ raise RuntimeError(f"workflow allowlist contains tools absent from ToolSpec: {sorted(invalid)}")
95
+ self._namespace = namespace
96
+
97
+ def resolve(self, family: WorkFamily, action: ActionRequest) -> Callable[..., Any]:
98
+ if action.tool_name not in FAMILY_TOOL_ALLOWLISTS[family]:
99
+ raise PolicyViolation(
100
+ "TOOL_NOT_ALLOWED",
101
+ f"{action.tool_name} is not allowed for {family.value}",
102
+ details={"family": family.value, "tool": action.tool_name},
103
+ )
104
+ function = self._namespace.get(action.tool_name)
105
+ if not callable(function):
106
+ raise PolicyViolation(
107
+ "TOOL_UNAVAILABLE",
108
+ f"ToolSpec tool is not callable in the active server: {action.tool_name}",
109
+ )
110
+ return function
111
+
112
+ def dispatch(self, family: WorkFamily, action: ActionRequest) -> Any:
113
+ return self.resolve(family, action)(**dict(action.arguments))
114
+
115
+ def dispatch_durable(
116
+ self,
117
+ store: WorkflowStore,
118
+ workflow_id: str,
119
+ action: ActionRequest,
120
+ *,
121
+ expected_version: int,
122
+ capability_ok: bool,
123
+ policy: WorkflowPolicyEngine | None = None,
124
+ ) -> DispatchOutcome:
125
+ """Execute once behind durable started/completed/failed receipts.
126
+
127
+ A recovered completed action is not executed again. A recovered
128
+ ``started`` action without a terminal receipt fails as in-doubt because
129
+ executing it again could duplicate a mutation.
130
+ """
131
+
132
+ engine = policy or WorkflowPolicyEngine()
133
+ record = store.get(workflow_id)
134
+ events = store.events(workflow_id)
135
+ action_events = [event for event in events if event.payload.get("actionHash") == action.action_hash]
136
+ completed = next((event for event in reversed(action_events) if event.event_type == "dispatch.completed"), None)
137
+ if completed is not None:
138
+ # The primitive result is encrypted outside the append-only ledger.
139
+ # Replays must return the exact prior result so workflow planning and
140
+ # verification never depend on an in-memory first execution.
141
+ return DispatchOutcome(
142
+ result=store.get_action_result(workflow_id, action.action_hash),
143
+ replayed=True,
144
+ receipt=completed,
145
+ )
146
+ started_attempts = [event for event in action_events if event.event_type == "dispatch.started"]
147
+ terminal_attempts = {
148
+ int(event.payload["attempt"])
149
+ for event in action_events
150
+ if event.event_type in {"dispatch.completed", "dispatch.failed"} and "attempt" in event.payload
151
+ }
152
+ unresolved = next(
153
+ (event for event in reversed(started_attempts) if int(event.payload["attempt"]) not in terminal_attempts),
154
+ None,
155
+ )
156
+ if unresolved is not None:
157
+ raise PolicyViolation(
158
+ "DISPATCH_IN_DOUBT",
159
+ "action has a durable started receipt but no terminal receipt; automatic re-execution is unsafe",
160
+ details={"actionHash": action.action_hash, "attempt": unresolved.payload["attempt"]},
161
+ )
162
+
163
+ if record.state_version != expected_version:
164
+ from .store import WorkflowConflict
165
+
166
+ raise WorkflowConflict(
167
+ f"stale workflow state: expected {record.state.value}@{expected_version}, "
168
+ f"actual {record.state.value}@{record.state_version}"
169
+ )
170
+
171
+ engine.authorize_action(record, action, events, capability_ok=capability_ok)
172
+ function = self.resolve(record.work_order.family, action)
173
+ attempt = len(started_attempts) + 1
174
+ common = {
175
+ "actionHash": action.action_hash,
176
+ "argumentsHash": content_hash(dict(action.arguments)),
177
+ "attempt": attempt,
178
+ "tool": action.tool_name,
179
+ }
180
+ running, started, _ = store.append_event(
181
+ workflow_id,
182
+ "dispatch.started",
183
+ expected_state=record.state,
184
+ expected_version=record.state_version,
185
+ payload=common,
186
+ event_key=f"dispatch:{action.action_hash}:{attempt}:started",
187
+ )
188
+ try:
189
+ result = function(**dict(action.arguments))
190
+ except Exception as error:
191
+ store.append_event(
192
+ workflow_id,
193
+ "dispatch.failed",
194
+ expected_state=running.state,
195
+ expected_version=running.state_version,
196
+ payload={**common, "errorType": type(error).__name__},
197
+ event_key=f"dispatch:{action.action_hash}:{attempt}:failed",
198
+ )
199
+ raise
200
+ store.put_action_result(workflow_id, action.action_hash, result)
201
+ completed_record, completed_receipt, _ = store.append_event(
202
+ workflow_id,
203
+ "dispatch.completed",
204
+ expected_state=running.state,
205
+ expected_version=running.state_version,
206
+ payload={**common, "resultHash": content_hash(result), **_result_evidence(result)},
207
+ event_key=f"dispatch:{action.action_hash}:{attempt}:completed",
208
+ )
209
+ del completed_record, started
210
+ return DispatchOutcome(result=result, replayed=False, receipt=completed_receipt)
211
+
212
+
213
+ __all__ = ["AllowlistedDispatcher", "DispatchOutcome", "FAMILY_TOOL_ALLOWLISTS"]
@@ -0,0 +1,243 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Versioned contracts for durable server-side HWPX workflows.
3
+
4
+ These models deliberately accept typed work rather than a free-form prompt. Prompt
5
+ bodies belong to the MCP request boundary, not the durable event ledger.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from datetime import datetime, timezone
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import Any, Mapping
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
18
+
19
+
20
+ WORKFLOW_SCHEMA_VERSION = "hwpx.workflow.v1"
21
+
22
+
23
+ def utc_now() -> datetime:
24
+ return datetime.now(timezone.utc)
25
+
26
+
27
+ def canonical_json(value: Any) -> str:
28
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
29
+
30
+
31
+ def content_hash(value: Any) -> str:
32
+ raw = value if isinstance(value, bytes) else canonical_json(value).encode("utf-8")
33
+ return f"sha256:{hashlib.sha256(raw).hexdigest()}"
34
+
35
+
36
+ _SENSITIVE_LEDGER_KEY_PARTS = ("prompt", "instruction", "body", "text", "content", "bytes", "binary")
37
+
38
+
39
+ def sanitize_ledger_payload(value: Any, *, key: str = "value") -> Any:
40
+ """Remove prompt/document content before an event reaches durable storage."""
41
+
42
+ normalized_key = "".join(character for character in key.lower() if character.isalnum())
43
+ already_a_hash = normalized_key.endswith("hash") or normalized_key.endswith("revision")
44
+ if not already_a_hash and any(part in normalized_key for part in _SENSITIVE_LEDGER_KEY_PARTS):
45
+ return {"redacted": True, "hash": content_hash(value)}
46
+ if isinstance(value, (bytes, bytearray, memoryview)):
47
+ return {"redacted": True, "hash": content_hash(bytes(value))}
48
+ if isinstance(value, Mapping):
49
+ return {str(nested_key): sanitize_ledger_payload(nested, key=str(nested_key)) for nested_key, nested in value.items()}
50
+ if isinstance(value, (list, tuple)):
51
+ return [sanitize_ledger_payload(nested, key=key) for nested in value]
52
+ return value
53
+
54
+
55
+ class WorkFamily(str, Enum):
56
+ READ_EXTRACT = "read_extract"
57
+ TRANSACTIONAL_EDIT = "transactional_edit"
58
+ KNOWN_TEMPLATE_FILL = "known_template_fill"
59
+ UNKNOWN_FORM_FILL = "unknown_form_fill"
60
+ STRUCTURAL_TABLE_EDIT = "structural_table_edit"
61
+ TYPED_AUTHORING = "typed_authoring"
62
+ MUST_ABSTAIN = "must_abstain"
63
+
64
+
65
+ class WorkflowState(str, Enum):
66
+ INTAKE = "intake"
67
+ RECON = "recon"
68
+ PLAN = "plan"
69
+ DECISION = "decision"
70
+ EXECUTE = "execute"
71
+ VERIFY = "verify"
72
+ REPAIR = "repair"
73
+ COMPLETED = "completed"
74
+ NEEDS_REVIEW = "needs_review"
75
+ FAILED = "failed"
76
+ CANCELLED = "cancelled"
77
+
78
+
79
+ TERMINAL_STATES = frozenset(
80
+ {
81
+ WorkflowState.COMPLETED,
82
+ WorkflowState.NEEDS_REVIEW,
83
+ WorkflowState.FAILED,
84
+ WorkflowState.CANCELLED,
85
+ }
86
+ )
87
+
88
+
89
+ class WorkflowBudget(BaseModel):
90
+ model_config = ConfigDict(extra="forbid", frozen=True)
91
+
92
+ max_attempts: int = Field(default=3, ge=1, le=20)
93
+ max_tool_calls: int = Field(default=50, ge=1, le=1000)
94
+ max_elapsed_seconds: int = Field(default=900, ge=1, le=86_400)
95
+ max_repair_rounds: int = Field(default=3, ge=0, le=10)
96
+
97
+
98
+ class WorkflowPolicy(BaseModel):
99
+ model_config = ConfigDict(extra="forbid", frozen=True)
100
+
101
+ preserve_original: bool = True
102
+ require_expected_revision: bool = True
103
+ require_decision_for_destructive: bool = True
104
+ require_open_safety: bool = True
105
+ require_verified_completion: bool = True
106
+ require_real_hancom_render: bool = False
107
+
108
+ @field_validator("preserve_original")
109
+ @classmethod
110
+ def original_preservation_cannot_be_disabled(cls, value: bool) -> bool:
111
+ if not value:
112
+ raise ValueError("server workflows always preserve the original document")
113
+ return value
114
+
115
+
116
+ class WorkOrder(BaseModel):
117
+ """Typed, restartable request accepted by the workflow engine.
118
+
119
+ ``parameters`` may contain structured adapter input but never raw bytes. Event
120
+ records persist only its hash and key list, not its values.
121
+ """
122
+
123
+ model_config = ConfigDict(extra="forbid", frozen=True)
124
+
125
+ schema_version: str = WORKFLOW_SCHEMA_VERSION
126
+ family: WorkFamily
127
+ source_path: str | None = None
128
+ output_path: str | None = None
129
+ expected_revision: str | None = None
130
+ idempotency_key: str = Field(min_length=8, max_length=200)
131
+ parameters: dict[str, Any] = Field(default_factory=dict)
132
+ budget: WorkflowBudget = Field(default_factory=WorkflowBudget)
133
+ policy: WorkflowPolicy = Field(default_factory=WorkflowPolicy)
134
+
135
+ @field_validator("schema_version")
136
+ @classmethod
137
+ def supported_schema(cls, value: str) -> str:
138
+ if value != WORKFLOW_SCHEMA_VERSION:
139
+ raise ValueError(f"unsupported workflow schema: {value}")
140
+ return value
141
+
142
+ @field_validator("source_path", "output_path")
143
+ @classmethod
144
+ def normalize_path(cls, value: str | None) -> str | None:
145
+ return str(Path(value).expanduser().resolve()) if value else None
146
+
147
+ @field_validator("parameters")
148
+ @classmethod
149
+ def reject_binary_parameters(cls, value: dict[str, Any]) -> dict[str, Any]:
150
+ def walk(item: Any) -> None:
151
+ if isinstance(item, (bytes, bytearray, memoryview)):
152
+ raise ValueError("document bytes are not accepted in workflow parameters")
153
+ if isinstance(item, Mapping):
154
+ for nested in item.values():
155
+ walk(nested)
156
+ elif isinstance(item, (list, tuple)):
157
+ for nested in item:
158
+ walk(nested)
159
+
160
+ walk(value)
161
+ return value
162
+
163
+ def fingerprint(self) -> str:
164
+ return content_hash(self.model_dump(mode="json"))
165
+
166
+ def ledger_summary(self) -> dict[str, Any]:
167
+ """Return the non-content request summary safe for append-only events."""
168
+
169
+ return {
170
+ "schemaVersion": self.schema_version,
171
+ "family": self.family.value,
172
+ "sourcePathHash": content_hash(self.source_path) if self.source_path else None,
173
+ "outputPathHash": content_hash(self.output_path) if self.output_path else None,
174
+ "expectedRevision": self.expected_revision,
175
+ "parameterKeys": sorted(self.parameters),
176
+ "parametersHash": content_hash(self.parameters),
177
+ "workOrderHash": self.fingerprint(),
178
+ }
179
+
180
+
181
+ class WorkflowEvent(BaseModel):
182
+ model_config = ConfigDict(extra="forbid", frozen=True)
183
+
184
+ workflow_id: str
185
+ index: int = Field(ge=0)
186
+ event_type: str
187
+ from_state: WorkflowState | None
188
+ to_state: WorkflowState
189
+ occurred_at: datetime
190
+ payload: dict[str, Any]
191
+ previous_hash: str | None
192
+ event_hash: str
193
+ event_key: str | None = None
194
+
195
+
196
+ class WorkflowRecord(BaseModel):
197
+ model_config = ConfigDict(extra="forbid", frozen=True)
198
+
199
+ workflow_id: str
200
+ work_order: WorkOrder
201
+ state: WorkflowState
202
+ state_version: int = Field(ge=1)
203
+ created_at: datetime
204
+ updated_at: datetime
205
+ original_content_hash: str | None = None
206
+ output_content_hash: str | None = None
207
+ stop_reason: str | None = None
208
+
209
+ @property
210
+ def terminal(self) -> bool:
211
+ return self.state in TERMINAL_STATES
212
+
213
+
214
+ class ActionResult(BaseModel):
215
+ """Decrypted durable result plus non-sensitive retention metadata."""
216
+
217
+ model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
218
+
219
+ workflow_id: str = Field(serialization_alias="workflowId")
220
+ action_hash: str = Field(serialization_alias="actionHash")
221
+ result: Any
222
+ content_hash: str = Field(serialization_alias="contentHash")
223
+ size_bytes: int = Field(ge=0, serialization_alias="sizeBytes")
224
+ created_at: datetime = Field(serialization_alias="createdAt")
225
+ expires_at: datetime = Field(serialization_alias="expiresAt")
226
+
227
+
228
+ __all__ = [
229
+ "TERMINAL_STATES",
230
+ "WORKFLOW_SCHEMA_VERSION",
231
+ "ActionResult",
232
+ "WorkFamily",
233
+ "WorkOrder",
234
+ "WorkflowBudget",
235
+ "WorkflowEvent",
236
+ "WorkflowPolicy",
237
+ "WorkflowRecord",
238
+ "WorkflowState",
239
+ "canonical_json",
240
+ "content_hash",
241
+ "sanitize_ledger_payload",
242
+ "utc_now",
243
+ ]
@@ -0,0 +1,198 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Fail-closed policy decisions for durable HWPX workflows."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Any, Iterable, Mapping
8
+
9
+ from hwpx_automation.document_state import document_revision
10
+
11
+ from .models import WorkFamily, WorkflowEvent, WorkflowRecord, WorkflowState, content_hash, utc_now
12
+
13
+
14
+ MUTATING_FAMILIES = frozenset(
15
+ {
16
+ WorkFamily.TRANSACTIONAL_EDIT,
17
+ WorkFamily.KNOWN_TEMPLATE_FILL,
18
+ WorkFamily.UNKNOWN_FORM_FILL,
19
+ WorkFamily.STRUCTURAL_TABLE_EDIT,
20
+ WorkFamily.TYPED_AUTHORING,
21
+ }
22
+ )
23
+
24
+
25
+ class PolicyViolation(RuntimeError):
26
+ def __init__(self, code: str, message: str, *, details: Mapping[str, Any] | None = None) -> None:
27
+ self.code = code
28
+ self.details = dict(details or {})
29
+ super().__init__(message)
30
+
31
+ def to_dict(self) -> dict[str, Any]:
32
+ return {"ok": False, "code": self.code, "message": str(self), "details": self.details}
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class ActionRequest:
37
+ tool_name: str
38
+ arguments: Mapping[str, Any]
39
+ destructive: bool = False
40
+
41
+ @property
42
+ def action_hash(self) -> str:
43
+ return content_hash(
44
+ {"tool": self.tool_name, "arguments": self.arguments, "destructive": self.destructive}
45
+ )
46
+
47
+
48
+ class WorkflowPolicyEngine:
49
+ """Evaluate invariants using durable record/event evidence only."""
50
+
51
+ def validate_intake(self, record: WorkflowRecord) -> None:
52
+ order = record.work_order
53
+ if order.family not in MUTATING_FAMILIES:
54
+ return
55
+ if order.source_path and order.output_path and order.source_path == order.output_path:
56
+ raise PolicyViolation(
57
+ "ORIGINAL_OVERWRITE_FORBIDDEN",
58
+ "workflow output must be a distinct copy; the original cannot be overwritten",
59
+ )
60
+ if order.family != WorkFamily.TYPED_AUTHORING and not order.source_path:
61
+ raise PolicyViolation("SOURCE_REQUIRED", "this workflow family requires a source document")
62
+ if not order.output_path:
63
+ raise PolicyViolation("OUTPUT_COPY_REQUIRED", "mutating workflows require a distinct output path")
64
+ if order.policy.require_expected_revision and order.source_path and not order.expected_revision:
65
+ raise PolicyViolation("EXPECTED_REVISION_REQUIRED", "expected_revision is required before mutation")
66
+
67
+ def authorize_action(
68
+ self,
69
+ record: WorkflowRecord,
70
+ action: ActionRequest,
71
+ events: Iterable[WorkflowEvent],
72
+ *,
73
+ capability_ok: bool,
74
+ ) -> None:
75
+ self.validate_intake(record)
76
+ if not capability_ok:
77
+ raise PolicyViolation("CAPABILITY_SKEW", "workflow dispatch is disabled by capability/version skew")
78
+ if record.terminal:
79
+ raise PolicyViolation("WORKFLOW_TERMINAL", "terminal workflows cannot dispatch more tools")
80
+
81
+ event_list = list(events)
82
+ elapsed = (utc_now() - record.created_at).total_seconds()
83
+ if elapsed >= record.work_order.budget.max_elapsed_seconds:
84
+ raise PolicyViolation(
85
+ "ELAPSED_TIME_BUDGET_EXCEEDED",
86
+ "workflow elapsed-time budget is exhausted",
87
+ details={"elapsedSeconds": int(elapsed), "maximum": record.work_order.budget.max_elapsed_seconds},
88
+ )
89
+ calls = sum(event.event_type == "dispatch.started" for event in event_list)
90
+ if calls >= record.work_order.budget.max_tool_calls:
91
+ raise PolicyViolation(
92
+ "TOOL_CALL_BUDGET_EXCEEDED",
93
+ "workflow tool-call budget is exhausted",
94
+ details={"used": calls, "maximum": record.work_order.budget.max_tool_calls},
95
+ )
96
+ attempts = sum(
97
+ event.event_type == "dispatch.started" and event.payload.get("actionHash") == action.action_hash
98
+ for event in event_list
99
+ )
100
+ if attempts >= record.work_order.budget.max_attempts:
101
+ raise PolicyViolation(
102
+ "ACTION_ATTEMPT_BUDGET_EXCEEDED",
103
+ "action attempt budget is exhausted",
104
+ details={"used": attempts, "maximum": record.work_order.budget.max_attempts},
105
+ )
106
+ repair_rounds = sum(
107
+ event.from_state != WorkflowState.REPAIR and event.to_state == WorkflowState.REPAIR
108
+ for event in event_list
109
+ )
110
+ if repair_rounds > record.work_order.budget.max_repair_rounds:
111
+ raise PolicyViolation(
112
+ "REPAIR_BUDGET_EXCEEDED",
113
+ "workflow repair-round budget is exhausted",
114
+ details={"used": repair_rounds, "maximum": record.work_order.budget.max_repair_rounds},
115
+ )
116
+
117
+ order = record.work_order
118
+ if order.source_path and order.expected_revision:
119
+ actual = document_revision(order.source_path)
120
+ if actual != order.expected_revision:
121
+ raise PolicyViolation(
122
+ "STALE_DOCUMENT_REVISION",
123
+ "source document changed after workflow intake",
124
+ details={"expected": order.expected_revision, "actual": actual},
125
+ )
126
+
127
+ if action.destructive and order.policy.require_decision_for_destructive:
128
+ approved = any(
129
+ event.event_type == "decision.approved"
130
+ and event.from_state == WorkflowState.DECISION
131
+ and event.to_state == WorkflowState.DECISION
132
+ and event.payload.get("actionHash") == action.action_hash
133
+ for event in event_list
134
+ )
135
+ if not approved:
136
+ raise PolicyViolation(
137
+ "DECISION_REQUIRED",
138
+ "this action requires an explicit matching decision receipt",
139
+ details={"actionHash": action.action_hash},
140
+ )
141
+
142
+ def authorize_completion(self, record: WorkflowRecord, verification: Mapping[str, Any]) -> None:
143
+ if record.state != WorkflowState.VERIFY:
144
+ raise PolicyViolation("VERIFY_STATE_REQUIRED", "completion is only allowed from verify state")
145
+ if record.work_order.policy.require_open_safety and verification.get("openSafetyOk") is not True:
146
+ raise PolicyViolation("OPEN_SAFETY_REQUIRED", "openSafety evidence is required for completion")
147
+ if record.work_order.policy.require_verified_completion and verification.get("verified") is not True:
148
+ raise PolicyViolation("VERIFIED_COMPLETION_REQUIRED", "unverified work cannot be marked completed")
149
+ if verification.get("domainVerified") is not True:
150
+ raise PolicyViolation(
151
+ "DOMAIN_VERIFICATION_REQUIRED",
152
+ "family-specific result and quality verification are required for completion",
153
+ )
154
+
155
+ def record_decision(
156
+ self,
157
+ store: Any,
158
+ record: WorkflowRecord,
159
+ action: ActionRequest,
160
+ *,
161
+ approved: bool,
162
+ ) -> WorkflowRecord:
163
+ if record.state != WorkflowState.DECISION:
164
+ raise PolicyViolation("DECISION_STATE_REQUIRED", "decision receipts are only valid in decision state")
165
+ event_type = "decision.approved" if approved else "decision.rejected"
166
+ updated, _, _ = store.append_event(
167
+ record.workflow_id,
168
+ event_type,
169
+ expected_state=record.state,
170
+ expected_version=record.state_version,
171
+ payload={"actionHash": action.action_hash, "approved": approved},
172
+ event_key=f"decision:{action.action_hash}",
173
+ )
174
+ return updated
175
+
176
+ def complete(
177
+ self,
178
+ store: Any,
179
+ record: WorkflowRecord,
180
+ verification: Mapping[str, Any],
181
+ *,
182
+ output_content_hash: str | None = None,
183
+ ) -> WorkflowRecord:
184
+ """Policy-gated only path from VERIFY to COMPLETED."""
185
+
186
+ self.authorize_completion(record, verification)
187
+ return store.transition(
188
+ record.workflow_id,
189
+ WorkflowState.COMPLETED,
190
+ expected_state=record.state,
191
+ expected_version=record.state_version,
192
+ event_type="workflow.completed",
193
+ payload={"verification": dict(verification)},
194
+ output_content_hash=output_content_hash,
195
+ )
196
+
197
+
198
+ __all__ = ["ActionRequest", "MUTATING_FAMILIES", "PolicyViolation", "WorkflowPolicyEngine"]