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,1760 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Strict mixed-anchor form-fill planning over the semantic agent facade.
3
+
4
+ The module deliberately owns *planning only*. It resolves four public locator
5
+ types against one immutable document revision, compiles them to ordinary
6
+ ``hwpx.agent-batch/v1`` ``set`` commands, and delegates execution to
7
+ ``apply_document_commands``. There is no second mutation or save engine here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ from collections.abc import Iterator, Mapping, MutableMapping, Sequence
15
+ from copy import deepcopy
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from hwpx.quality import SavePipeline
21
+ from hwpx.table_patch import ResolvedCellTarget, resolve_cell_target
22
+
23
+ from .catalog import agent_json_schemas
24
+ from .commands import (
25
+ DomainVerifier,
26
+ FaultInjector,
27
+ IdempotencyStore,
28
+ apply_document_commands,
29
+ )
30
+ from .document import HwpxAgentDocument, NodeRecord
31
+ from .model import (
32
+ AGENT_BATCH_SCHEMA,
33
+ COMMAND_ID_PATTERN,
34
+ MAX_COMMANDS,
35
+ MAX_TEXT_CHARS,
36
+ REVISION_PATTERN,
37
+ STABILITY_LEVELS,
38
+ VERIFICATION_REQUIREMENTS,
39
+ AgentBatchResult,
40
+ AgentContractError,
41
+ _validate_quality,
42
+ validate_agent_batch,
43
+ )
44
+ from .path import parse_path
45
+
46
+ # Public input authored by a client/agent (``operations[].target``).
47
+ MIXED_FORM_PLAN_SCHEMA = "hwpx.mixed-form-plan/v1"
48
+ # Internal normalized form (``operations[].locator``); not an MCP wire promise.
49
+ MIXED_FORM_REQUEST_SCHEMA = "hwpx.mixed-form-request/v1"
50
+ # Revision-bound output containing the single ``hwpx.agent-batch/v1`` request.
51
+ MIXED_FORM_COMPILED_PLAN_SCHEMA = "hwpx.mixed-form-compiled-plan/v1"
52
+ MIXED_FORM_LOCATOR_KINDS = (
53
+ "nativeField",
54
+ "labelCell",
55
+ "canonicalPath",
56
+ "bodyAnchor",
57
+ )
58
+
59
+ _FILLABLE_NODE_KINDS = frozenset({"paragraph", "run", "cell", "form-field"})
60
+ _DIRECTIONS = frozenset({"right", "left", "below", "above"})
61
+ _MIXED_FORM_IDENTITY_SCOPE = "hwpx.mixed-form-idempotency/v1"
62
+ _COMPILED_PLAN_KEYS = frozenset(
63
+ {
64
+ "schemaVersion",
65
+ "inputRevision",
66
+ "requestHash",
67
+ "resolutions",
68
+ "batch",
69
+ "planHash",
70
+ }
71
+ )
72
+ _RESOLUTION_KEYS = frozenset(
73
+ {
74
+ "operationId",
75
+ "locatorKind",
76
+ "path",
77
+ "nodeKind",
78
+ "stability",
79
+ "section",
80
+ "tableIndex",
81
+ "logicalRow",
82
+ "logicalColumn",
83
+ "physicalRow",
84
+ "physicalColumn",
85
+ }
86
+ )
87
+
88
+
89
+ def _object(value: object, name: str) -> dict[str, Any]:
90
+ if not isinstance(value, Mapping):
91
+ raise AgentContractError("invalid_syntax", f"{name} must be an object", target=name)
92
+ return dict(value)
93
+
94
+
95
+ def _array(value: object, name: str) -> list[Any]:
96
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
97
+ raise AgentContractError("invalid_syntax", f"{name} must be an array", target=name)
98
+ return list(value)
99
+
100
+
101
+ def _exact_keys(
102
+ value: Mapping[str, Any],
103
+ *,
104
+ required: set[str] | frozenset[str],
105
+ optional: set[str] | frozenset[str] = frozenset(),
106
+ name: str,
107
+ ) -> None:
108
+ missing = required - set(value)
109
+ extra = set(value) - required - optional
110
+ if missing or extra:
111
+ raise AgentContractError(
112
+ "invalid_syntax",
113
+ f"{name} fields mismatch (missing={sorted(missing)}, extra={sorted(extra)})",
114
+ target=name,
115
+ )
116
+
117
+
118
+ def _nonempty_string(value: object, name: str) -> str:
119
+ if not isinstance(value, str) or not value.strip():
120
+ raise AgentContractError("invalid_syntax", f"{name} must be a non-empty string", target=name)
121
+ if len(value) > MAX_TEXT_CHARS:
122
+ raise AgentContractError("resource_limit", f"{name} is too long", target=name)
123
+ return value
124
+
125
+
126
+ def _value_string(value: object, name: str) -> str:
127
+ if not isinstance(value, str):
128
+ raise AgentContractError("invalid_syntax", f"{name} must be a string", target=name)
129
+ if len(value) > MAX_TEXT_CHARS:
130
+ raise AgentContractError("resource_limit", f"{name} is too long", target=name)
131
+ return value
132
+
133
+
134
+ def _positive_integer(value: object, name: str) -> int:
135
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
136
+ raise AgentContractError(
137
+ "invalid_syntax", f"{name} must be a one-based integer", target=name
138
+ )
139
+ return value
140
+
141
+
142
+ def _nonnegative_integer_or_none(value: object, name: str) -> int | None:
143
+ if value is None:
144
+ return None
145
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
146
+ raise AgentContractError(
147
+ "invalid_syntax", f"{name} must be a non-negative integer or null", target=name
148
+ )
149
+ return value
150
+
151
+
152
+ def _section_index_from_path(value: object, name: str) -> int:
153
+ section_path = _nonempty_string(value, name)
154
+ parsed = parse_path(section_path)
155
+ if (
156
+ len(parsed.segments) != 1
157
+ or parsed.segments[0].kind != "section"
158
+ or parsed.segments[0].index is None
159
+ ):
160
+ raise AgentContractError(
161
+ "invalid_syntax",
162
+ f"{name} must be a positional section path such as /section[1]",
163
+ target=name,
164
+ )
165
+ return parsed.segments[0].index
166
+
167
+
168
+ def _canonical_hash(value: Mapping[str, Any]) -> str:
169
+ try:
170
+ payload = json.dumps(
171
+ value,
172
+ ensure_ascii=False,
173
+ sort_keys=True,
174
+ separators=(",", ":"),
175
+ allow_nan=False,
176
+ ).encode("utf-8")
177
+ except (TypeError, ValueError) as exc:
178
+ raise AgentContractError(
179
+ "invalid_syntax", "mixed-form contract is not canonical JSON", target="mixedForm"
180
+ ) from exc
181
+ return "sha256:" + hashlib.sha256(payload).hexdigest()
182
+
183
+
184
+ def _assert_distinct_source_output(source: str, output: str) -> None:
185
+ """Fail closed when output can name the input document itself.
186
+
187
+ The lexical/real-path comparison catches ``.``/``..`` and symlink aliases,
188
+ while ``samefile`` additionally catches distinct hard-link names. This is
189
+ intentionally run both while planning and immediately before execution so
190
+ an output created or replaced between those phases cannot overwrite input.
191
+ """
192
+
193
+ source_path = Path(source)
194
+ output_path = Path(output)
195
+ try:
196
+ source_resolved = source_path.resolve(strict=False)
197
+ output_resolved = output_path.resolve(strict=False)
198
+ except (OSError, RuntimeError) as exc:
199
+ raise AgentContractError(
200
+ "verification_failed",
201
+ f"source/output identity could not be resolved safely: {exc}",
202
+ target="output.filename",
203
+ ) from exc
204
+ if source_resolved == output_resolved:
205
+ raise AgentContractError(
206
+ "invariant_violation",
207
+ "mixed-form output must not resolve to the input document",
208
+ target="output.filename",
209
+ )
210
+ try:
211
+ same_file = source_path.samefile(output_path)
212
+ except FileNotFoundError:
213
+ same_file = False
214
+ except OSError as exc:
215
+ raise AgentContractError(
216
+ "verification_failed",
217
+ f"source/output identity could not be checked safely: {exc}",
218
+ target="output.filename",
219
+ ) from exc
220
+ if same_file:
221
+ raise AgentContractError(
222
+ "invariant_violation",
223
+ "mixed-form output must not alias the input document",
224
+ target="output.filename",
225
+ )
226
+
227
+
228
+ class _MixedFormIdempotencyStore(MutableMapping[str, Any]):
229
+ """Translate one mixed-form identity to the batch executor's store shape.
230
+
231
+ ``apply_document_commands`` remains the sole executor and continues to see
232
+ its own batch hash. The caller-owned backing store, however, records a
233
+ scoped composite hash, so two distinct public locator requests cannot
234
+ replay merely because they compile to the same canonical batch.
235
+ """
236
+
237
+ def __init__(
238
+ self,
239
+ backing: IdempotencyStore,
240
+ *,
241
+ active_key: str,
242
+ mixed_form_request_hash: str,
243
+ batch_request_hash: str,
244
+ ) -> None:
245
+ self._backing = backing
246
+ self._active_key = active_key
247
+ self._mixed_form_request_hash = mixed_form_request_hash
248
+ self._batch_request_hash = batch_request_hash
249
+ self._composite_hash = _canonical_hash(
250
+ {
251
+ "scope": _MIXED_FORM_IDENTITY_SCOPE,
252
+ "mixedFormRequestHash": mixed_form_request_hash,
253
+ "batchRequestHash": batch_request_hash,
254
+ }
255
+ )
256
+
257
+ def __getitem__(self, key: str) -> Any:
258
+ cached = self._backing[key]
259
+ if key != self._active_key or not isinstance(cached, Mapping):
260
+ return cached
261
+ if (
262
+ cached.get("requestHash") == self._composite_hash
263
+ and cached.get("identityScope") == _MIXED_FORM_IDENTITY_SCOPE
264
+ and cached.get("mixedFormRequestHash") == self._mixed_form_request_hash
265
+ and cached.get("batchRequestHash") == self._batch_request_hash
266
+ ):
267
+ executor_view = dict(cached)
268
+ executor_view["requestHash"] = self._batch_request_hash
269
+ return executor_view
270
+ # A standard batch entry, another mixed-form request, or a malformed
271
+ # entry must conflict inside the existing typed executor path.
272
+ conflict_view = dict(cached)
273
+ conflict_view["requestHash"] = None
274
+ return conflict_view
275
+
276
+ def __setitem__(self, key: str, value: Any) -> None:
277
+ if key != self._active_key or not isinstance(value, Mapping):
278
+ self._backing[key] = value
279
+ return
280
+ stored = dict(value)
281
+ stored.update(
282
+ {
283
+ "requestHash": self._composite_hash,
284
+ "identityScope": _MIXED_FORM_IDENTITY_SCOPE,
285
+ "mixedFormRequestHash": self._mixed_form_request_hash,
286
+ "batchRequestHash": self._batch_request_hash,
287
+ }
288
+ )
289
+ self._backing[key] = stored
290
+
291
+ def __delitem__(self, key: str) -> None:
292
+ del self._backing[key]
293
+
294
+ def __iter__(self) -> Iterator[str]:
295
+ return iter(self._backing)
296
+
297
+ def __len__(self) -> int:
298
+ return len(self._backing)
299
+
300
+
301
+ def _batch_request_shape(normalized: Mapping[str, Any]) -> dict[str, Any]:
302
+ """Detach validator output into the request shape the executor accepts.
303
+
304
+ ``validate_agent_command`` annotates normalized commands with their schema
305
+ version, while an ``hwpx.agent-batch/v1`` request intentionally omits that
306
+ derived field. The executor validates requests again, so compiled plans
307
+ must retain the request shape rather than feed normalized commands back
308
+ through a non-idempotent validator.
309
+ """
310
+
311
+ batch = deepcopy(dict(normalized))
312
+ for command in batch["commands"]:
313
+ command.pop("schemaVersion", None)
314
+ return batch
315
+
316
+
317
+ def _validate_locator(value: object, *, name: str) -> dict[str, Any]:
318
+ locator = _object(value, name)
319
+ kind = str(locator.get("kind", ""))
320
+ if kind not in MIXED_FORM_LOCATOR_KINDS:
321
+ raise AgentContractError(
322
+ "unknown_kind", f"unsupported mixed-form locator: {kind!r}", target=f"{name}.kind"
323
+ )
324
+
325
+ if kind == "nativeField":
326
+ _exact_keys(
327
+ locator,
328
+ required={"kind"},
329
+ optional={"fieldId", "name"},
330
+ name=name,
331
+ )
332
+ selectors = [key for key in ("fieldId", "name") if key in locator]
333
+ if len(selectors) != 1:
334
+ raise AgentContractError(
335
+ "invalid_syntax",
336
+ f"{name} requires exactly one of fieldId or name",
337
+ target=name,
338
+ )
339
+ selector = selectors[0]
340
+ return {"kind": kind, selector: _nonempty_string(locator[selector], f"{name}.{selector}")}
341
+
342
+ if kind == "labelCell":
343
+ _exact_keys(
344
+ locator,
345
+ required={"kind", "section", "cellAnchor"},
346
+ optional={"tableAnchor", "tableIndex"},
347
+ name=name,
348
+ )
349
+ table_selectors = [key for key in ("tableAnchor", "tableIndex") if key in locator]
350
+ if len(table_selectors) != 1:
351
+ raise AgentContractError(
352
+ "invalid_syntax",
353
+ f"{name} requires exactly one of tableAnchor or tableIndex",
354
+ target=name,
355
+ )
356
+ anchor = _object(locator["cellAnchor"], f"{name}.cellAnchor")
357
+ _exact_keys(
358
+ anchor,
359
+ required={"label"},
360
+ optional={"direction"},
361
+ name=f"{name}.cellAnchor",
362
+ )
363
+ direction = str(anchor.get("direction", "right")).lower()
364
+ if direction not in _DIRECTIONS:
365
+ raise AgentContractError(
366
+ "invalid_syntax",
367
+ f"{name}.cellAnchor.direction is unsupported",
368
+ target=f"{name}.cellAnchor.direction",
369
+ )
370
+ normalized: dict[str, Any] = {
371
+ "kind": kind,
372
+ "section": _positive_integer(locator["section"], f"{name}.section"),
373
+ "cellAnchor": {
374
+ "label": _nonempty_string(anchor["label"], f"{name}.cellAnchor.label"),
375
+ "direction": direction,
376
+ },
377
+ }
378
+ table_selector = table_selectors[0]
379
+ if table_selector == "tableAnchor":
380
+ normalized[table_selector] = _nonempty_string(
381
+ locator[table_selector], f"{name}.tableAnchor"
382
+ )
383
+ else:
384
+ index = locator[table_selector]
385
+ if isinstance(index, bool) or not isinstance(index, int) or index < 0:
386
+ raise AgentContractError(
387
+ "invalid_syntax",
388
+ f"{name}.tableIndex must be a zero-based integer",
389
+ target=f"{name}.tableIndex",
390
+ )
391
+ normalized[table_selector] = index
392
+ return normalized
393
+
394
+ if kind == "canonicalPath":
395
+ _exact_keys(locator, required={"kind", "path"}, name=name)
396
+ return {"kind": kind, "path": _nonempty_string(locator["path"], f"{name}.path")}
397
+
398
+ _exact_keys(locator, required={"kind", "section", "anchor"}, name=name)
399
+ return {
400
+ "kind": kind,
401
+ "section": _positive_integer(locator["section"], f"{name}.section"),
402
+ "anchor": _nonempty_string(locator["anchor"], f"{name}.anchor"),
403
+ }
404
+
405
+
406
+ def _validate_plan_target(value: object, *, name: str) -> dict[str, Any]:
407
+ """Validate the public ``hwpx.mixed-form-plan/v1`` target vocabulary."""
408
+
409
+ target = _object(value, name)
410
+ kind = str(target.get("kind", ""))
411
+ if kind not in MIXED_FORM_LOCATOR_KINDS:
412
+ raise AgentContractError(
413
+ "unknown_kind", f"unsupported mixed-form target: {kind!r}", target=f"{name}.kind"
414
+ )
415
+
416
+ if kind == "nativeField":
417
+ _exact_keys(target, required={"kind"}, optional={"fieldId", "name"}, name=name)
418
+ selectors = [key for key in ("fieldId", "name") if key in target]
419
+ if len(selectors) != 1:
420
+ raise AgentContractError(
421
+ "invalid_syntax",
422
+ f"{name} requires exactly one of fieldId or name",
423
+ target=name,
424
+ )
425
+ selector = selectors[0]
426
+ return {
427
+ "kind": kind,
428
+ selector: _nonempty_string(target[selector], f"{name}.{selector}"),
429
+ }
430
+
431
+ if kind == "labelCell":
432
+ _exact_keys(
433
+ target,
434
+ required={"kind", "sectionPath", "cellAnchor"},
435
+ optional={"tableAnchor", "tableIndex"},
436
+ name=name,
437
+ )
438
+ table_selectors = [key for key in ("tableAnchor", "tableIndex") if key in target]
439
+ if len(table_selectors) != 1:
440
+ raise AgentContractError(
441
+ "invalid_syntax",
442
+ f"{name} requires exactly one of tableAnchor or tableIndex",
443
+ target=name,
444
+ )
445
+ anchor = _object(target["cellAnchor"], f"{name}.cellAnchor")
446
+ _exact_keys(
447
+ anchor,
448
+ required={"label", "direction"},
449
+ name=f"{name}.cellAnchor",
450
+ )
451
+ direction = _nonempty_string(
452
+ anchor["direction"], f"{name}.cellAnchor.direction"
453
+ ).lower()
454
+ if direction not in _DIRECTIONS:
455
+ raise AgentContractError(
456
+ "invalid_syntax",
457
+ f"{name}.cellAnchor.direction is unsupported",
458
+ target=f"{name}.cellAnchor.direction",
459
+ )
460
+ normalized: dict[str, Any] = {
461
+ "kind": kind,
462
+ "section": _section_index_from_path(target["sectionPath"], f"{name}.sectionPath"),
463
+ "cellAnchor": {
464
+ "label": _nonempty_string(anchor["label"], f"{name}.cellAnchor.label"),
465
+ "direction": direction,
466
+ },
467
+ }
468
+ table_selector = table_selectors[0]
469
+ if table_selector == "tableAnchor":
470
+ normalized[table_selector] = _nonempty_string(
471
+ target[table_selector], f"{name}.tableAnchor"
472
+ )
473
+ else:
474
+ table_index = target[table_selector]
475
+ if isinstance(table_index, bool) or not isinstance(table_index, int) or table_index < 0:
476
+ raise AgentContractError(
477
+ "invalid_syntax",
478
+ f"{name}.tableIndex must be a zero-based integer",
479
+ target=f"{name}.tableIndex",
480
+ )
481
+ normalized[table_selector] = table_index
482
+ return normalized
483
+
484
+ if kind == "canonicalPath":
485
+ _exact_keys(target, required={"kind", "path"}, name=name)
486
+ return {"kind": kind, "path": _nonempty_string(target["path"], f"{name}.path")}
487
+
488
+ _exact_keys(
489
+ target,
490
+ required={"kind", "sectionPath", "anchor", "expectedCount"},
491
+ name=name,
492
+ )
493
+ expected_count = target["expectedCount"]
494
+ if isinstance(expected_count, bool) or expected_count != 1:
495
+ raise AgentContractError(
496
+ "invalid_syntax",
497
+ f"{name}.expectedCount must be exactly 1",
498
+ target=f"{name}.expectedCount",
499
+ )
500
+ return {
501
+ "kind": kind,
502
+ "section": _section_index_from_path(target["sectionPath"], f"{name}.sectionPath"),
503
+ "anchor": _nonempty_string(target["anchor"], f"{name}.anchor"),
504
+ "expectedCount": 1,
505
+ }
506
+
507
+
508
+ def _validate_public_plan_request(request: Mapping[str, Any]) -> dict[str, Any]:
509
+ """Normalize the P1-frozen public plan into the internal request shape."""
510
+
511
+ required = {
512
+ "schemaVersion",
513
+ "source",
514
+ "output",
515
+ "expectedRevision",
516
+ "idempotencyKey",
517
+ "dryRun",
518
+ "overwrite",
519
+ "quality",
520
+ "verificationRequirements",
521
+ "operations",
522
+ }
523
+ _exact_keys(request, required=required, name="mixedFormPlan")
524
+ source = _nonempty_string(request["source"], "mixedFormPlan.source")
525
+ output = _nonempty_string(request["output"], "mixedFormPlan.output")
526
+ if not isinstance(request["dryRun"], bool):
527
+ raise AgentContractError(
528
+ "invalid_syntax", "mixedFormPlan.dryRun must be boolean", target="mixedFormPlan.dryRun"
529
+ )
530
+ if not isinstance(request["overwrite"], bool):
531
+ raise AgentContractError(
532
+ "invalid_syntax",
533
+ "mixedFormPlan.overwrite must be boolean",
534
+ target="mixedFormPlan.overwrite",
535
+ )
536
+
537
+ expected_revision = request["expectedRevision"]
538
+ if expected_revision is not None and (
539
+ not isinstance(expected_revision, str)
540
+ or REVISION_PATTERN.fullmatch(expected_revision) is None
541
+ ):
542
+ raise AgentContractError(
543
+ "invalid_syntax",
544
+ "mixedFormPlan.expectedRevision must be sha256 or null",
545
+ target="mixedFormPlan.expectedRevision",
546
+ )
547
+ idempotency_key = request["idempotencyKey"]
548
+ if idempotency_key is not None and (
549
+ not isinstance(idempotency_key, str) or not 1 <= len(idempotency_key) <= 128
550
+ ):
551
+ raise AgentContractError(
552
+ "resource_limit",
553
+ "mixedFormPlan.idempotencyKey length is invalid",
554
+ target="mixedFormPlan.idempotencyKey",
555
+ )
556
+
557
+ raw_operations = _array(request["operations"], "mixedFormPlan.operations")
558
+ if not raw_operations or len(raw_operations) > MAX_COMMANDS:
559
+ raise AgentContractError(
560
+ "resource_limit",
561
+ "mixedFormPlan.operations count is out of bounds",
562
+ target="mixedFormPlan.operations",
563
+ )
564
+ operations: list[dict[str, Any]] = []
565
+ operation_ids: list[str] = []
566
+ for index, raw_operation in enumerate(raw_operations):
567
+ name = f"mixedFormPlan.operations[{index}]"
568
+ operation = _object(raw_operation, name)
569
+ _exact_keys(operation, required={"operationId", "target", "value"}, name=name)
570
+ operation_id = operation.get("operationId")
571
+ if not isinstance(operation_id, str) or not COMMAND_ID_PATTERN.fullmatch(operation_id):
572
+ raise AgentContractError(
573
+ "invalid_syntax", f"{name}.operationId is invalid", target=f"{name}.operationId"
574
+ )
575
+ operation_ids.append(operation_id)
576
+ operations.append(
577
+ {
578
+ "operationId": operation_id,
579
+ "locator": _validate_plan_target(operation["target"], name=f"{name}.target"),
580
+ "value": _value_string(operation["value"], f"{name}.value"),
581
+ }
582
+ )
583
+ if len(operation_ids) != len(set(operation_ids)):
584
+ raise AgentContractError(
585
+ "invariant_violation",
586
+ "mixed-form operationId values must be unique",
587
+ target="mixedFormPlan.operations",
588
+ )
589
+
590
+ requirements = _array(
591
+ request["verificationRequirements"], "mixedFormPlan.verificationRequirements"
592
+ )
593
+ if any(not isinstance(item, str) for item in requirements):
594
+ raise AgentContractError(
595
+ "invalid_syntax",
596
+ "mixedFormPlan.verificationRequirements items must be strings",
597
+ target="mixedFormPlan.verificationRequirements",
598
+ )
599
+ unknown_requirements = sorted(set(requirements) - set(VERIFICATION_REQUIREMENTS))
600
+ if unknown_requirements:
601
+ raise AgentContractError(
602
+ "invalid_syntax",
603
+ f"unknown verification requirements: {unknown_requirements}",
604
+ target="mixedFormPlan.verificationRequirements",
605
+ )
606
+ return {
607
+ "schemaVersion": MIXED_FORM_REQUEST_SCHEMA,
608
+ "input": {"filename": source},
609
+ "output": {"filename": output, "overwrite": request["overwrite"]},
610
+ "operations": operations,
611
+ "expectedRevision": expected_revision,
612
+ "idempotencyKey": idempotency_key,
613
+ "dryRun": request["dryRun"],
614
+ "quality": _validate_quality(request["quality"]),
615
+ "verificationRequirements": list(dict.fromkeys(requirements)),
616
+ }
617
+
618
+
619
+ def validate_mixed_form_request(value: Mapping[str, Any]) -> dict[str, Any]:
620
+ """Validate a public plan or internal envelope into the internal shape."""
621
+
622
+ request = _object(value, "mixedFormRequest")
623
+ schema_version = request.get("schemaVersion")
624
+ if schema_version == MIXED_FORM_PLAN_SCHEMA:
625
+ return _validate_public_plan_request(request)
626
+ if schema_version != MIXED_FORM_REQUEST_SCHEMA:
627
+ raise AgentContractError(
628
+ "invalid_syntax", "unsupported mixed-form request schema", target="schemaVersion"
629
+ )
630
+ required = {
631
+ "schemaVersion",
632
+ "input",
633
+ "output",
634
+ "operations",
635
+ "expectedRevision",
636
+ "idempotencyKey",
637
+ "dryRun",
638
+ "quality",
639
+ "verificationRequirements",
640
+ }
641
+ _exact_keys(request, required=required, name="mixedFormRequest")
642
+ input_ref = _object(request["input"], "mixedFormRequest.input")
643
+ _exact_keys(input_ref, required={"filename"}, name="mixedFormRequest.input")
644
+ input_filename = _nonempty_string(
645
+ input_ref["filename"], "mixedFormRequest.input.filename"
646
+ )
647
+
648
+ output_ref = _object(request["output"], "mixedFormRequest.output")
649
+ _exact_keys(
650
+ output_ref,
651
+ required={"filename", "overwrite"},
652
+ name="mixedFormRequest.output",
653
+ )
654
+ output_filename = _nonempty_string(
655
+ output_ref["filename"], "mixedFormRequest.output.filename"
656
+ )
657
+ if not isinstance(output_ref["overwrite"], bool):
658
+ raise AgentContractError(
659
+ "invalid_syntax",
660
+ "mixedFormRequest.output.overwrite must be boolean",
661
+ target="mixedFormRequest.output.overwrite",
662
+ )
663
+
664
+ raw_operations = _array(request["operations"], "mixedFormRequest.operations")
665
+ if not raw_operations or len(raw_operations) > MAX_COMMANDS:
666
+ raise AgentContractError(
667
+ "resource_limit",
668
+ "mixedFormRequest.operations count is out of bounds",
669
+ target="mixedFormRequest.operations",
670
+ )
671
+ operations: list[dict[str, Any]] = []
672
+ operation_ids: list[str] = []
673
+ for index, raw_operation in enumerate(raw_operations):
674
+ name = f"mixedFormRequest.operations[{index}]"
675
+ operation = _object(raw_operation, name)
676
+ _exact_keys(operation, required={"operationId", "locator", "value"}, name=name)
677
+ operation_id = str(operation.get("operationId", ""))
678
+ if not COMMAND_ID_PATTERN.fullmatch(operation_id):
679
+ raise AgentContractError(
680
+ "invalid_syntax", f"{name}.operationId is invalid", target=f"{name}.operationId"
681
+ )
682
+ operation_ids.append(operation_id)
683
+ operations.append(
684
+ {
685
+ "operationId": operation_id,
686
+ "locator": _validate_locator(operation["locator"], name=f"{name}.locator"),
687
+ "value": _value_string(operation["value"], f"{name}.value"),
688
+ }
689
+ )
690
+ if len(operation_ids) != len(set(operation_ids)):
691
+ raise AgentContractError(
692
+ "invariant_violation",
693
+ "mixed-form operationId values must be unique",
694
+ target="mixedFormRequest.operations",
695
+ )
696
+
697
+ expected_revision = request["expectedRevision"]
698
+ if expected_revision is not None and not REVISION_PATTERN.fullmatch(str(expected_revision)):
699
+ raise AgentContractError(
700
+ "invalid_syntax",
701
+ "mixedFormRequest.expectedRevision must be sha256 or null",
702
+ target="mixedFormRequest.expectedRevision",
703
+ )
704
+ idempotency_key = request["idempotencyKey"]
705
+ if idempotency_key is not None and (
706
+ not isinstance(idempotency_key, str) or not 1 <= len(idempotency_key) <= 128
707
+ ):
708
+ raise AgentContractError(
709
+ "resource_limit",
710
+ "mixedFormRequest.idempotencyKey length is invalid",
711
+ target="mixedFormRequest.idempotencyKey",
712
+ )
713
+ if not isinstance(request["dryRun"], bool):
714
+ raise AgentContractError(
715
+ "invalid_syntax",
716
+ "mixedFormRequest.dryRun must be boolean",
717
+ target="mixedFormRequest.dryRun",
718
+ )
719
+
720
+ requirements = _array(
721
+ request["verificationRequirements"],
722
+ "mixedFormRequest.verificationRequirements",
723
+ )
724
+ if any(not isinstance(item, str) for item in requirements):
725
+ raise AgentContractError(
726
+ "invalid_syntax",
727
+ "mixedFormRequest.verificationRequirements items must be strings",
728
+ target="mixedFormRequest.verificationRequirements",
729
+ )
730
+ unknown_requirements = sorted(set(requirements) - set(VERIFICATION_REQUIREMENTS))
731
+ if unknown_requirements:
732
+ raise AgentContractError(
733
+ "invalid_syntax",
734
+ f"unknown verification requirements: {unknown_requirements}",
735
+ target="mixedFormRequest.verificationRequirements",
736
+ )
737
+
738
+ return {
739
+ "schemaVersion": MIXED_FORM_REQUEST_SCHEMA,
740
+ "input": {"filename": input_filename},
741
+ "output": {
742
+ "filename": output_filename,
743
+ "overwrite": output_ref["overwrite"],
744
+ },
745
+ "operations": operations,
746
+ "expectedRevision": None if expected_revision is None else str(expected_revision),
747
+ "idempotencyKey": idempotency_key,
748
+ "dryRun": request["dryRun"],
749
+ "quality": _validate_quality(request["quality"]),
750
+ "verificationRequirements": list(dict.fromkeys(str(item) for item in requirements)),
751
+ }
752
+
753
+
754
+ @dataclass(frozen=True, slots=True)
755
+ class MixedFormResolution:
756
+ operation_id: str
757
+ locator_kind: str
758
+ path: str
759
+ node_kind: str
760
+ stability: str
761
+ section: int | None = None
762
+ table_index: int | None = None
763
+ logical_row: int | None = None
764
+ logical_column: int | None = None
765
+ physical_row: int | None = None
766
+ physical_column: int | None = None
767
+
768
+ def to_dict(self) -> dict[str, Any]:
769
+ return {
770
+ "operationId": self.operation_id,
771
+ "locatorKind": self.locator_kind,
772
+ "path": self.path,
773
+ "nodeKind": self.node_kind,
774
+ "stability": self.stability,
775
+ "section": self.section,
776
+ "tableIndex": self.table_index,
777
+ "logicalRow": self.logical_row,
778
+ "logicalColumn": self.logical_column,
779
+ "physicalRow": self.physical_row,
780
+ "physicalColumn": self.physical_column,
781
+ }
782
+
783
+
784
+ @dataclass(frozen=True, slots=True)
785
+ class MixedFormPlan:
786
+ input_revision: str
787
+ request_hash: str
788
+ resolutions: tuple[MixedFormResolution, ...]
789
+ batch: Mapping[str, Any]
790
+ plan_hash: str
791
+
792
+ def to_dict(self) -> dict[str, Any]:
793
+ return {
794
+ "schemaVersion": MIXED_FORM_COMPILED_PLAN_SCHEMA,
795
+ "inputRevision": self.input_revision,
796
+ "requestHash": self.request_hash,
797
+ "resolutions": [resolution.to_dict() for resolution in self.resolutions],
798
+ "batch": deepcopy(dict(self.batch)),
799
+ "planHash": self.plan_hash,
800
+ }
801
+
802
+
803
+ def _section_record(agent: HwpxAgentDocument, section_index: int) -> NodeRecord:
804
+ matches = [
805
+ record
806
+ for record in agent.records
807
+ if record.kind == "section" and record.summary.get("index") == section_index
808
+ ]
809
+ if not matches:
810
+ raise AgentContractError(
811
+ "not_found", f"section {section_index} was not found", target=f"section[{section_index}]"
812
+ )
813
+ if len(matches) != 1: # pragma: no cover - projection invariant
814
+ raise AgentContractError(
815
+ "invariant_violation",
816
+ f"section {section_index} projected more than once",
817
+ target=f"section[{section_index}]",
818
+ )
819
+ return matches[0]
820
+
821
+
822
+ def _native_field_record(agent: HwpxAgentDocument, locator: Mapping[str, Any]) -> NodeRecord:
823
+ records = [record for record in agent.records if record.kind == "form-field"]
824
+ if "fieldId" in locator:
825
+ wanted = str(locator["fieldId"]).strip()
826
+ matches = [
827
+ record
828
+ for record in records
829
+ if wanted
830
+ in {
831
+ str(record.native.get("field_id") or ""),
832
+ str(record.native.get("id") or ""),
833
+ str(record.native.get("fieldid") or ""),
834
+ }
835
+ ]
836
+ target = f"nativeField.fieldId={wanted!r}"
837
+ else:
838
+ wanted = str(locator["name"]).strip().casefold()
839
+ matches = [
840
+ record
841
+ for record in records
842
+ if wanted
843
+ in {
844
+ str(record.native.get("name") or "").strip().casefold(),
845
+ str(record.native.get("prompt") or "").strip().casefold(),
846
+ str(record.native.get("instruction") or "").strip().casefold(),
847
+ }
848
+ ]
849
+ target = f"nativeField.name={locator['name']!r}"
850
+ if not matches:
851
+ raise AgentContractError("not_found", "native form field was not found", target=target)
852
+ if len(matches) > 1:
853
+ raise AgentContractError(
854
+ "ambiguous_target",
855
+ f"native form-field locator matched {len(matches)} fields",
856
+ target=target,
857
+ )
858
+ return matches[0]
859
+
860
+
861
+ def _table_resolution_error(exc: ValueError, *, target: str) -> AgentContractError:
862
+ message = str(exc)
863
+ lowered = message.casefold()
864
+ if "ambiguous" in lowered:
865
+ code = "ambiguous_target"
866
+ elif "invalid" in lowered:
867
+ code = "unsupported_content"
868
+ else:
869
+ code = "not_found"
870
+ return AgentContractError(code, message, target=target)
871
+
872
+
873
+ def _table_cell_record(
874
+ agent: HwpxAgentDocument,
875
+ section: NodeRecord,
876
+ target: ResolvedCellTarget,
877
+ ) -> NodeRecord:
878
+ part_name = str(section.native.part_name)
879
+ tables = [
880
+ record
881
+ for record in agent.records
882
+ if record.kind == "table"
883
+ and str(record.native.paragraph.section.part_name) == part_name
884
+ ]
885
+ if not 0 <= target.table_index < len(tables):
886
+ raise AgentContractError(
887
+ "invariant_violation",
888
+ "byte table ordinal is absent from the semantic projection",
889
+ target=f"table[{target.table_index}]",
890
+ )
891
+ table = tables[target.table_index]
892
+ matches = [
893
+ record
894
+ for record in agent.records
895
+ if record.kind == "cell"
896
+ and record.native.table.element is table.native.element
897
+ and tuple(record.native.address) == (target.row, target.col)
898
+ ]
899
+ if not matches:
900
+ raise AgentContractError(
901
+ "not_found",
902
+ "resolved physical cell is absent from the semantic projection",
903
+ target=table.path,
904
+ )
905
+ if len(matches) > 1: # pragma: no cover - invalid projection invariant
906
+ raise AgentContractError(
907
+ "ambiguous_target",
908
+ "resolved physical cell projected more than once",
909
+ target=table.path,
910
+ )
911
+ return matches[0]
912
+
913
+
914
+ def _label_cell_record(
915
+ agent: HwpxAgentDocument,
916
+ source_bytes: bytes,
917
+ locator: Mapping[str, Any],
918
+ ) -> tuple[NodeRecord, ResolvedCellTarget]:
919
+ section_index = int(locator["section"])
920
+ section = _section_record(agent, section_index)
921
+ byte_locator: dict[str, Any] = {
922
+ "section_path": str(section.native.part_name),
923
+ "cell_anchor": {
924
+ "label": locator["cellAnchor"]["label"],
925
+ "direction": locator["cellAnchor"]["direction"],
926
+ },
927
+ }
928
+ if "tableAnchor" in locator:
929
+ byte_locator["table_anchor"] = locator["tableAnchor"]
930
+ else:
931
+ byte_locator["table_index"] = locator["tableIndex"]
932
+ try:
933
+ resolved = resolve_cell_target(source_bytes, byte_locator)
934
+ except ValueError as exc:
935
+ raise _table_resolution_error(exc, target="labelCell") from exc
936
+ return _table_cell_record(agent, section, resolved), resolved
937
+
938
+
939
+ def _local_name(element: Any) -> str:
940
+ return str(getattr(element, "tag", "")).rsplit("}", 1)[-1]
941
+
942
+
943
+ def _body_anchor_record(
944
+ agent: HwpxAgentDocument,
945
+ locator: Mapping[str, Any],
946
+ ) -> tuple[NodeRecord, str]:
947
+ section_index = int(locator["section"])
948
+ section = _section_record(agent, section_index)
949
+ anchor = str(locator["anchor"])
950
+ paragraphs = [
951
+ record
952
+ for record in agent.records
953
+ if record.kind == "paragraph" and record.parent_path == section.path
954
+ ]
955
+ occurrences: list[tuple[NodeRecord, int]] = []
956
+ for paragraph in paragraphs:
957
+ count = str(paragraph.native.text).count(anchor)
958
+ if count:
959
+ occurrences.append((paragraph, count))
960
+ total = sum(count for _paragraph, count in occurrences)
961
+ if total == 0:
962
+ raise AgentContractError(
963
+ "not_found", "direct-body anchor was not found", target=f"bodyAnchor={anchor!r}"
964
+ )
965
+ if total != 1:
966
+ raise AgentContractError(
967
+ "ambiguous_target",
968
+ f"direct-body anchor matched {total} times",
969
+ target=f"bodyAnchor={anchor!r}",
970
+ )
971
+ paragraph = occurrences[0][0]
972
+ runs = [
973
+ record
974
+ for record in agent.records
975
+ if record.kind == "run" and record.parent_path == paragraph.path
976
+ ]
977
+ run_hits = [(record, str(record.native.text).count(anchor)) for record in runs]
978
+ run_hits = [(record, count) for record, count in run_hits if count]
979
+ if not run_hits:
980
+ raise AgentContractError(
981
+ "unsupported_content",
982
+ "direct-body anchor crosses run boundaries",
983
+ target=paragraph.path,
984
+ )
985
+ if sum(count for _record, count in run_hits) != 1:
986
+ raise AgentContractError(
987
+ "ambiguous_target",
988
+ "direct-body anchor is ambiguous within its paragraph",
989
+ target=paragraph.path,
990
+ )
991
+ run = run_hits[0][0]
992
+ children = list(run.native.element)
993
+ if not children or any(_local_name(child) != "t" or len(list(child)) for child in children):
994
+ raise AgentContractError(
995
+ "unsupported_content",
996
+ "direct-body anchor run contains non-plain inline content",
997
+ target=run.path,
998
+ )
999
+ return run, str(run.native.text).replace(anchor, str(locator.get("value", "")), 1)
1000
+
1001
+
1002
+ def _compile_operation(
1003
+ agent: HwpxAgentDocument,
1004
+ source_bytes: bytes,
1005
+ operation: Mapping[str, Any],
1006
+ ) -> tuple[dict[str, Any], MixedFormResolution]:
1007
+ operation_id = str(operation["operationId"])
1008
+ locator = operation["locator"]
1009
+ locator_kind = str(locator["kind"])
1010
+ value = str(operation["value"])
1011
+ resolved_table: ResolvedCellTarget | None = None
1012
+
1013
+ if locator_kind == "nativeField":
1014
+ record = _native_field_record(agent, locator)
1015
+ property_name = "value"
1016
+ compiled_value = value
1017
+ elif locator_kind == "labelCell":
1018
+ record, resolved_table = _label_cell_record(agent, source_bytes, locator)
1019
+ property_name = "text"
1020
+ compiled_value = value
1021
+ elif locator_kind == "canonicalPath":
1022
+ record = agent.resolve_record(str(locator["path"]), expected_revision=agent.revision)
1023
+ if record.kind not in _FILLABLE_NODE_KINDS:
1024
+ raise AgentContractError(
1025
+ "unsupported_operation",
1026
+ f"canonical path targets non-fillable kind {record.kind!r}",
1027
+ target=record.path,
1028
+ )
1029
+ property_name = "value" if record.kind == "form-field" else "text"
1030
+ compiled_value = value
1031
+ else:
1032
+ body_locator = dict(locator)
1033
+ body_locator["value"] = value
1034
+ record, compiled_value = _body_anchor_record(agent, body_locator)
1035
+ property_name = "text"
1036
+
1037
+ command = {
1038
+ "commandId": operation_id,
1039
+ "op": "set",
1040
+ "path": record.path,
1041
+ "properties": {property_name: compiled_value},
1042
+ }
1043
+ section = int(locator["section"]) if "section" in locator else None
1044
+ resolution = MixedFormResolution(
1045
+ operation_id=operation_id,
1046
+ locator_kind=locator_kind,
1047
+ path=record.path,
1048
+ node_kind=record.kind,
1049
+ stability=record.stability,
1050
+ section=section,
1051
+ table_index=(resolved_table.table_index if resolved_table is not None else None),
1052
+ logical_row=(resolved_table.logical_row if resolved_table is not None else None),
1053
+ logical_column=(resolved_table.logical_col if resolved_table is not None else None),
1054
+ physical_row=(resolved_table.row if resolved_table is not None else None),
1055
+ physical_column=(resolved_table.col if resolved_table is not None else None),
1056
+ )
1057
+ return command, resolution
1058
+
1059
+
1060
+ def plan_mixed_form_fill(request: Mapping[str, Any]) -> MixedFormPlan:
1061
+ """Resolve four target types and compile one revision-bound agent batch."""
1062
+
1063
+ normalized = validate_mixed_form_request(request)
1064
+ input_path = Path(normalized["input"]["filename"])
1065
+ _assert_distinct_source_output(
1066
+ normalized["input"]["filename"],
1067
+ normalized["output"]["filename"],
1068
+ )
1069
+ try:
1070
+ source_bytes = input_path.read_bytes()
1071
+ except OSError as exc:
1072
+ raise AgentContractError(
1073
+ "not_found", f"mixed-form input could not be read: {exc}", target="input.filename"
1074
+ ) from exc
1075
+
1076
+ actual_revision = "sha256:" + hashlib.sha256(source_bytes).hexdigest()
1077
+ expected_revision = normalized["expectedRevision"]
1078
+ if expected_revision not in {None, actual_revision}:
1079
+ raise AgentContractError(
1080
+ "stale_revision",
1081
+ "mixed-form expectedRevision does not match input bytes",
1082
+ target="expectedRevision",
1083
+ )
1084
+
1085
+ commands: list[dict[str, Any]] = []
1086
+ resolutions: list[MixedFormResolution] = []
1087
+ try:
1088
+ with HwpxAgentDocument.open(source_bytes) as agent:
1089
+ for operation in normalized["operations"]:
1090
+ command, resolution = _compile_operation(agent, source_bytes, operation)
1091
+ commands.append(command)
1092
+ resolutions.append(resolution)
1093
+ except AgentContractError:
1094
+ raise
1095
+ except Exception as exc:
1096
+ raise AgentContractError(
1097
+ "verification_failed",
1098
+ f"mixed-form input could not be projected safely: {type(exc).__name__}: {exc}",
1099
+ target="input.filename",
1100
+ ) from exc
1101
+
1102
+ resolved_paths = [resolution.path for resolution in resolutions]
1103
+ if len(resolved_paths) != len(set(resolved_paths)):
1104
+ raise AgentContractError(
1105
+ "identity_collision",
1106
+ "mixed-form operations resolve to the same canonical target",
1107
+ target="operations",
1108
+ )
1109
+
1110
+ normalized_batch = validate_agent_batch(
1111
+ {
1112
+ "schemaVersion": AGENT_BATCH_SCHEMA,
1113
+ "input": deepcopy(normalized["input"]),
1114
+ "output": deepcopy(normalized["output"]),
1115
+ "commands": commands,
1116
+ # A compiled plan is always revision-bound, including when the
1117
+ # planning request deliberately supplied null.
1118
+ "expectedRevision": actual_revision,
1119
+ "idempotencyKey": normalized["idempotencyKey"],
1120
+ "dryRun": normalized["dryRun"],
1121
+ "quality": deepcopy(normalized["quality"]),
1122
+ "verificationRequirements": list(normalized["verificationRequirements"]),
1123
+ }
1124
+ )
1125
+ batch = _batch_request_shape(normalized_batch)
1126
+ request_hash = _canonical_hash(normalized)
1127
+ plan_payload: dict[str, Any] = {
1128
+ "schemaVersion": MIXED_FORM_COMPILED_PLAN_SCHEMA,
1129
+ "inputRevision": actual_revision,
1130
+ "requestHash": request_hash,
1131
+ "resolutions": [resolution.to_dict() for resolution in resolutions],
1132
+ "batch": deepcopy(batch),
1133
+ "planHash": None,
1134
+ }
1135
+ plan_hash = _canonical_hash(plan_payload)
1136
+ return MixedFormPlan(
1137
+ input_revision=actual_revision,
1138
+ request_hash=request_hash,
1139
+ resolutions=tuple(resolutions),
1140
+ batch=batch,
1141
+ plan_hash=plan_hash,
1142
+ )
1143
+
1144
+
1145
+ def _validate_mixed_form_plan_digests(plan: Mapping[str, Any]) -> tuple[str, str, str]:
1146
+ """Return (input_revision, request_hash, plan_hash) after schema/sha256 checks."""
1147
+
1148
+ if plan["schemaVersion"] != MIXED_FORM_COMPILED_PLAN_SCHEMA:
1149
+ raise AgentContractError(
1150
+ "invalid_syntax", "unsupported mixed-form plan schema", target="schemaVersion"
1151
+ )
1152
+ input_revision = str(plan["inputRevision"])
1153
+ request_hash = str(plan["requestHash"])
1154
+ plan_hash = str(plan["planHash"])
1155
+ for name, digest in (
1156
+ ("inputRevision", input_revision),
1157
+ ("requestHash", request_hash),
1158
+ ("planHash", plan_hash),
1159
+ ):
1160
+ if not REVISION_PATTERN.fullmatch(digest):
1161
+ raise AgentContractError(
1162
+ "invalid_syntax", f"mixedFormPlan.{name} must be sha256", target=name
1163
+ )
1164
+ return input_revision, request_hash, plan_hash
1165
+
1166
+
1167
+ def _parse_mixed_form_resolution_fields(
1168
+ name: str, resolution: Mapping[str, Any]
1169
+ ) -> tuple[str, str, str, int | None, int | None, int | None, int | None, int | None, int | None, str, Any]:
1170
+ """Extract + type/shape-validate one resolution's fields (no locator-kind cross-checks).
1171
+
1172
+ Returns (locator_kind, node_kind, stability, section, table_index, logical_row,
1173
+ logical_column, physical_row, physical_column, path, parsed_path).
1174
+ """
1175
+
1176
+ locator_kind = str(resolution["locatorKind"])
1177
+ node_kind = str(resolution["nodeKind"])
1178
+ stability = str(resolution["stability"])
1179
+ if locator_kind not in MIXED_FORM_LOCATOR_KINDS:
1180
+ raise AgentContractError("unknown_kind", "unknown plan locator kind", target=name)
1181
+ if node_kind not in _FILLABLE_NODE_KINDS:
1182
+ raise AgentContractError("unknown_kind", "plan targets a non-fillable kind", target=name)
1183
+ if stability not in STABILITY_LEVELS:
1184
+ raise AgentContractError("invalid_syntax", "unknown path stability", target=name)
1185
+ section = resolution["section"]
1186
+ if section is not None:
1187
+ section = _positive_integer(section, f"{name}.section")
1188
+ table_index = _nonnegative_integer_or_none(resolution["tableIndex"], f"{name}.tableIndex")
1189
+ logical_row = _nonnegative_integer_or_none(resolution["logicalRow"], f"{name}.logicalRow")
1190
+ logical_column = _nonnegative_integer_or_none(
1191
+ resolution["logicalColumn"], f"{name}.logicalColumn"
1192
+ )
1193
+ physical_row = _nonnegative_integer_or_none(
1194
+ resolution["physicalRow"], f"{name}.physicalRow"
1195
+ )
1196
+ physical_column = _nonnegative_integer_or_none(
1197
+ resolution["physicalColumn"], f"{name}.physicalColumn"
1198
+ )
1199
+ path = _nonempty_string(resolution["path"], f"{name}.path")
1200
+ parsed_path = parse_path(path)
1201
+ if parsed_path.canonical != path or not parsed_path.segments:
1202
+ raise AgentContractError(
1203
+ "invalid_syntax", "resolution path must be canonical and non-root", target=f"{name}.path"
1204
+ )
1205
+ path_kind = parsed_path.segments[-1].kind
1206
+ if node_kind != path_kind:
1207
+ raise AgentContractError(
1208
+ "invariant_violation",
1209
+ "resolution nodeKind does not match its canonical path kind",
1210
+ target=name,
1211
+ )
1212
+ return (
1213
+ locator_kind, node_kind, stability, section, table_index,
1214
+ logical_row, logical_column, physical_row, physical_column, path, parsed_path,
1215
+ )
1216
+
1217
+
1218
+ def _check_label_cell_or_table_coordinates(
1219
+ name: str, locator_kind: str, node_kind: str, section: int | None, coordinates: tuple[Any, ...]
1220
+ ) -> None:
1221
+ if locator_kind == "labelCell":
1222
+ if section is None or any(item is None for item in coordinates) or node_kind != "cell":
1223
+ raise AgentContractError(
1224
+ "invariant_violation", "labelCell plan lacks a physical resolution", target=name
1225
+ )
1226
+ elif any(item is not None for item in coordinates):
1227
+ raise AgentContractError(
1228
+ "invariant_violation", "non-table plan carries table coordinates", target=name
1229
+ )
1230
+
1231
+
1232
+ def _check_native_field_invariant(name: str, locator_kind: str, node_kind: str, section: int | None) -> None:
1233
+ if locator_kind == "nativeField" and (node_kind != "form-field" or section is not None):
1234
+ raise AgentContractError(
1235
+ "invariant_violation",
1236
+ "nativeField plan must resolve only to a form-field without section metadata",
1237
+ target=name,
1238
+ )
1239
+
1240
+
1241
+ def _check_canonical_path_invariant(name: str, locator_kind: str, section: int | None) -> None:
1242
+ if locator_kind == "canonicalPath" and section is not None:
1243
+ raise AgentContractError(
1244
+ "invariant_violation",
1245
+ "canonicalPath plan must not carry locator section metadata",
1246
+ target=name,
1247
+ )
1248
+
1249
+
1250
+ def _check_body_anchor_invariant(name: str, locator_kind: str, node_kind: str, section: int | None) -> None:
1251
+ if locator_kind == "bodyAnchor" and (node_kind != "run" or section is None):
1252
+ raise AgentContractError(
1253
+ "invariant_violation",
1254
+ "bodyAnchor plan must resolve to a run in its declared section",
1255
+ target=name,
1256
+ )
1257
+
1258
+
1259
+ def _check_resolution_section_membership(
1260
+ name: str, locator_kind: str, parsed_path: Any, section: int | None
1261
+ ) -> None:
1262
+ if locator_kind in {"labelCell", "bodyAnchor"}:
1263
+ first = parsed_path.segments[0]
1264
+ if first.kind != "section" or first.index != section:
1265
+ raise AgentContractError(
1266
+ "invariant_violation",
1267
+ "resolution path does not belong to its declared section",
1268
+ target=name,
1269
+ )
1270
+
1271
+
1272
+ def _check_locator_kind_invariants(
1273
+ name: str,
1274
+ locator_kind: str,
1275
+ node_kind: str,
1276
+ section: int | None,
1277
+ coordinates: tuple[Any, ...],
1278
+ parsed_path: Any,
1279
+ ) -> None:
1280
+ _check_label_cell_or_table_coordinates(name, locator_kind, node_kind, section, coordinates)
1281
+ _check_native_field_invariant(name, locator_kind, node_kind, section)
1282
+ _check_canonical_path_invariant(name, locator_kind, section)
1283
+ _check_body_anchor_invariant(name, locator_kind, node_kind, section)
1284
+ _check_resolution_section_membership(name, locator_kind, parsed_path, section)
1285
+
1286
+
1287
+ def _validate_one_mixed_form_resolution(index: int, raw_resolution: Any) -> MixedFormResolution:
1288
+ name = f"mixedFormPlan.resolutions[{index}]"
1289
+ resolution = _object(raw_resolution, name)
1290
+ _exact_keys(resolution, required=_RESOLUTION_KEYS, name=name)
1291
+ (
1292
+ locator_kind, node_kind, stability, section, table_index,
1293
+ logical_row, logical_column, physical_row, physical_column, path, parsed_path,
1294
+ ) = _parse_mixed_form_resolution_fields(name, resolution)
1295
+ coordinates = (table_index, logical_row, logical_column, physical_row, physical_column)
1296
+ _check_locator_kind_invariants(name, locator_kind, node_kind, section, coordinates, parsed_path)
1297
+ operation_id = str(resolution["operationId"])
1298
+ if not COMMAND_ID_PATTERN.fullmatch(operation_id):
1299
+ raise AgentContractError("invalid_syntax", "invalid resolution operationId", target=name)
1300
+ return MixedFormResolution(
1301
+ operation_id=operation_id,
1302
+ locator_kind=locator_kind,
1303
+ path=path,
1304
+ node_kind=node_kind,
1305
+ stability=stability,
1306
+ section=section,
1307
+ table_index=table_index,
1308
+ logical_row=logical_row,
1309
+ logical_column=logical_column,
1310
+ physical_row=physical_row,
1311
+ physical_column=physical_column,
1312
+ )
1313
+
1314
+
1315
+ def _validate_mixed_form_resolutions(plan: Mapping[str, Any]) -> list[MixedFormResolution]:
1316
+ raw_resolutions = _array(plan["resolutions"], "mixedFormPlan.resolutions")
1317
+ return [
1318
+ _validate_one_mixed_form_resolution(index, raw_resolution)
1319
+ for index, raw_resolution in enumerate(raw_resolutions)
1320
+ ]
1321
+
1322
+
1323
+ def _validate_mixed_form_batch_alignment(
1324
+ plan: Mapping[str, Any], input_revision: str, resolutions: list[MixedFormResolution]
1325
+ ) -> Mapping[str, Any]:
1326
+ """Validate the compiled batch matches its resolutions 1:1; return the batch shape."""
1327
+
1328
+ normalized_batch = validate_agent_batch(_object(plan["batch"], "mixedFormPlan.batch"))
1329
+ batch = _batch_request_shape(normalized_batch)
1330
+ if normalized_batch["expectedRevision"] != input_revision:
1331
+ raise AgentContractError(
1332
+ "stale_revision", "compiled batch is not bound to inputRevision", target="batch.expectedRevision"
1333
+ )
1334
+ if len(resolutions) != len(normalized_batch["commands"]):
1335
+ raise AgentContractError(
1336
+ "invariant_violation", "resolution and command counts differ", target="resolutions"
1337
+ )
1338
+ if len({resolution.path for resolution in resolutions}) != len(resolutions):
1339
+ raise AgentContractError(
1340
+ "identity_collision",
1341
+ "compiled plan contains duplicate canonical targets",
1342
+ target="resolutions",
1343
+ )
1344
+ for resolution, command in zip(resolutions, normalized_batch["commands"]):
1345
+ if (
1346
+ command["commandId"] != resolution.operation_id
1347
+ or command["op"] != "set"
1348
+ or command["path"] != resolution.path
1349
+ ):
1350
+ raise AgentContractError(
1351
+ "invariant_violation",
1352
+ "compiled command does not match its resolution",
1353
+ target=resolution.operation_id,
1354
+ )
1355
+ expected_property = "value" if resolution.node_kind == "form-field" else "text"
1356
+ if set(command["properties"]) != {expected_property}:
1357
+ raise AgentContractError(
1358
+ "invariant_violation",
1359
+ "compiled command has a non-fill property",
1360
+ target=resolution.operation_id,
1361
+ )
1362
+ return batch
1363
+
1364
+
1365
+ def validate_mixed_form_plan(value: MixedFormPlan | Mapping[str, Any]) -> MixedFormPlan:
1366
+ """Validate a detached compiled plan, including its content hash."""
1367
+
1368
+ plan = value.to_dict() if isinstance(value, MixedFormPlan) else _object(value, "mixedFormPlan")
1369
+ _exact_keys(plan, required=_COMPILED_PLAN_KEYS, name="mixedFormPlan")
1370
+ input_revision, request_hash, plan_hash = _validate_mixed_form_plan_digests(plan)
1371
+ resolutions = _validate_mixed_form_resolutions(plan)
1372
+ batch = _validate_mixed_form_batch_alignment(plan, input_revision, resolutions)
1373
+
1374
+ hash_payload = deepcopy(plan)
1375
+ hash_payload["planHash"] = None
1376
+ if _canonical_hash(hash_payload) != plan_hash:
1377
+ raise AgentContractError(
1378
+ "verification_failed", "mixed-form plan hash mismatch", target="planHash"
1379
+ )
1380
+ return MixedFormPlan(
1381
+ input_revision=input_revision,
1382
+ request_hash=request_hash,
1383
+ resolutions=tuple(resolutions),
1384
+ batch=batch,
1385
+ plan_hash=plan_hash,
1386
+ )
1387
+
1388
+
1389
+ def apply_mixed_form_plan(
1390
+ plan: MixedFormPlan | Mapping[str, Any],
1391
+ *,
1392
+ idempotency_store: IdempotencyStore | None = None,
1393
+ fault_injector: FaultInjector | None = None,
1394
+ domain_verifier: DomainVerifier | None = None,
1395
+ save_pipeline: SavePipeline | None = None,
1396
+ ) -> AgentBatchResult:
1397
+ """Validate a compiled plan and delegate to the sole agent batch executor."""
1398
+
1399
+ normalized = validate_mixed_form_plan(plan)
1400
+ _assert_distinct_source_output(
1401
+ str(normalized.batch["input"]["filename"]),
1402
+ str(normalized.batch["output"]["filename"]),
1403
+ )
1404
+ executor_store = idempotency_store
1405
+ idempotency_key = normalized.batch["idempotencyKey"]
1406
+ if idempotency_store is not None and idempotency_key is not None:
1407
+ executor_store = _MixedFormIdempotencyStore(
1408
+ idempotency_store,
1409
+ active_key=str(idempotency_key),
1410
+ mixed_form_request_hash=normalized.request_hash,
1411
+ batch_request_hash=_canonical_hash(validate_agent_batch(normalized.batch)),
1412
+ )
1413
+ return apply_document_commands(
1414
+ normalized.batch,
1415
+ idempotency_store=executor_store,
1416
+ fault_injector=fault_injector,
1417
+ domain_verifier=domain_verifier,
1418
+ save_pipeline=save_pipeline,
1419
+ )
1420
+
1421
+
1422
+ def apply_mixed_form_fill(
1423
+ request: Mapping[str, Any],
1424
+ *,
1425
+ idempotency_store: IdempotencyStore | None = None,
1426
+ fault_injector: FaultInjector | None = None,
1427
+ domain_verifier: DomainVerifier | None = None,
1428
+ save_pipeline: SavePipeline | None = None,
1429
+ ) -> AgentBatchResult:
1430
+ """Resolve every locator, then execute the resulting single atomic batch."""
1431
+
1432
+ return apply_mixed_form_plan(
1433
+ plan_mixed_form_fill(request),
1434
+ idempotency_store=idempotency_store,
1435
+ fault_injector=fault_injector,
1436
+ domain_verifier=domain_verifier,
1437
+ save_pipeline=save_pipeline,
1438
+ )
1439
+
1440
+
1441
+ def mixed_form_json_schemas() -> dict[str, Any]:
1442
+ """Return the three explicit mixed-form contract schemas.
1443
+
1444
+ ``plan`` is the public, human/agent-authored input. ``internalRequest`` is
1445
+ the normalized locator envelope used inside the core. ``compiledPlan`` is
1446
+ the revision-bound, hash-protected output consumed by
1447
+ :func:`apply_mixed_form_plan`.
1448
+ """
1449
+
1450
+ agent_schemas = agent_json_schemas()
1451
+ locator_variants = [
1452
+ {
1453
+ "type": "object",
1454
+ "required": ["kind", "fieldId"],
1455
+ "additionalProperties": False,
1456
+ "properties": {
1457
+ "kind": {"const": "nativeField"},
1458
+ "fieldId": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT_CHARS},
1459
+ },
1460
+ },
1461
+ {
1462
+ "type": "object",
1463
+ "required": ["kind", "name"],
1464
+ "additionalProperties": False,
1465
+ "properties": {
1466
+ "kind": {"const": "nativeField"},
1467
+ "name": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT_CHARS},
1468
+ },
1469
+ },
1470
+ {
1471
+ "type": "object",
1472
+ "required": ["kind", "section", "tableAnchor", "cellAnchor"],
1473
+ "additionalProperties": False,
1474
+ "properties": {
1475
+ "kind": {"const": "labelCell"},
1476
+ "section": {"type": "integer", "minimum": 1},
1477
+ "tableAnchor": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT_CHARS},
1478
+ "cellAnchor": {"$ref": "#/$defs/cellAnchor"},
1479
+ },
1480
+ },
1481
+ {
1482
+ "type": "object",
1483
+ "required": ["kind", "section", "tableIndex", "cellAnchor"],
1484
+ "additionalProperties": False,
1485
+ "properties": {
1486
+ "kind": {"const": "labelCell"},
1487
+ "section": {"type": "integer", "minimum": 1},
1488
+ "tableIndex": {"type": "integer", "minimum": 0},
1489
+ "cellAnchor": {"$ref": "#/$defs/cellAnchor"},
1490
+ },
1491
+ },
1492
+ {
1493
+ "type": "object",
1494
+ "required": ["kind", "path"],
1495
+ "additionalProperties": False,
1496
+ "properties": {
1497
+ "kind": {"const": "canonicalPath"},
1498
+ "path": {"type": "string", "minLength": 1},
1499
+ },
1500
+ },
1501
+ {
1502
+ "type": "object",
1503
+ "required": ["kind", "section", "anchor"],
1504
+ "additionalProperties": False,
1505
+ "properties": {
1506
+ "kind": {"const": "bodyAnchor"},
1507
+ "section": {"type": "integer", "minimum": 1},
1508
+ "anchor": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT_CHARS},
1509
+ },
1510
+ },
1511
+ ]
1512
+ request_schema = {
1513
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
1514
+ "title": "HWPX mixed-form request v1",
1515
+ "type": "object",
1516
+ "required": [
1517
+ "schemaVersion",
1518
+ "input",
1519
+ "output",
1520
+ "operations",
1521
+ "expectedRevision",
1522
+ "idempotencyKey",
1523
+ "dryRun",
1524
+ "quality",
1525
+ "verificationRequirements",
1526
+ ],
1527
+ "additionalProperties": False,
1528
+ "$defs": {
1529
+ "cellAnchor": {
1530
+ "type": "object",
1531
+ "required": ["label"],
1532
+ "additionalProperties": False,
1533
+ "properties": {
1534
+ "label": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT_CHARS},
1535
+ "direction": {"enum": sorted(_DIRECTIONS)},
1536
+ },
1537
+ }
1538
+ },
1539
+ "properties": {
1540
+ "schemaVersion": {"const": MIXED_FORM_REQUEST_SCHEMA},
1541
+ "input": {
1542
+ "type": "object",
1543
+ "required": ["filename"],
1544
+ "additionalProperties": False,
1545
+ "properties": {"filename": {"type": "string", "minLength": 1}},
1546
+ },
1547
+ "output": {
1548
+ "type": "object",
1549
+ "required": ["filename", "overwrite"],
1550
+ "additionalProperties": False,
1551
+ "properties": {
1552
+ "filename": {"type": "string", "minLength": 1},
1553
+ "overwrite": {"type": "boolean"},
1554
+ },
1555
+ },
1556
+ "operations": {
1557
+ "type": "array",
1558
+ "minItems": 1,
1559
+ "maxItems": MAX_COMMANDS,
1560
+ "items": {
1561
+ "type": "object",
1562
+ "required": ["operationId", "locator", "value"],
1563
+ "additionalProperties": False,
1564
+ "properties": {
1565
+ "operationId": {
1566
+ "type": "string",
1567
+ "pattern": COMMAND_ID_PATTERN.pattern,
1568
+ },
1569
+ "locator": {"oneOf": locator_variants},
1570
+ "value": {"type": "string", "maxLength": MAX_TEXT_CHARS},
1571
+ },
1572
+ },
1573
+ },
1574
+ "expectedRevision": {
1575
+ "type": ["string", "null"],
1576
+ "pattern": REVISION_PATTERN.pattern,
1577
+ },
1578
+ "idempotencyKey": {"type": ["string", "null"], "minLength": 1, "maxLength": 128},
1579
+ "dryRun": {"type": "boolean"},
1580
+ "quality": deepcopy(agent_schemas["batch"]["properties"]["quality"]),
1581
+ "verificationRequirements": {
1582
+ "type": "array",
1583
+ "items": {"enum": list(VERIFICATION_REQUIREMENTS)},
1584
+ "uniqueItems": True,
1585
+ },
1586
+ },
1587
+ }
1588
+ nullable_coordinate = {"type": ["integer", "null"], "minimum": 0}
1589
+ embedded_batch_schema = deepcopy(agent_schemas["batch"])
1590
+ embedded_batch_schema.pop("$schema", None)
1591
+ embedded_batch_schema.pop("title", None)
1592
+ compiled_plan_schema = {
1593
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
1594
+ "title": "HWPX mixed-form compiled plan v1",
1595
+ "type": "object",
1596
+ "required": sorted(_COMPILED_PLAN_KEYS),
1597
+ "additionalProperties": False,
1598
+ "properties": {
1599
+ "schemaVersion": {"const": MIXED_FORM_COMPILED_PLAN_SCHEMA},
1600
+ "inputRevision": {"type": "string", "pattern": REVISION_PATTERN.pattern},
1601
+ "requestHash": {"type": "string", "pattern": REVISION_PATTERN.pattern},
1602
+ "planHash": {"type": "string", "pattern": REVISION_PATTERN.pattern},
1603
+ "resolutions": {
1604
+ "type": "array",
1605
+ "minItems": 1,
1606
+ "maxItems": MAX_COMMANDS,
1607
+ "items": {
1608
+ "type": "object",
1609
+ "required": sorted(_RESOLUTION_KEYS),
1610
+ "additionalProperties": False,
1611
+ "properties": {
1612
+ "operationId": {"type": "string", "pattern": COMMAND_ID_PATTERN.pattern},
1613
+ "locatorKind": {"enum": list(MIXED_FORM_LOCATOR_KINDS)},
1614
+ "path": {"type": "string", "minLength": 1},
1615
+ "nodeKind": {"enum": sorted(_FILLABLE_NODE_KINDS)},
1616
+ "stability": {"enum": list(STABILITY_LEVELS)},
1617
+ "section": {"type": ["integer", "null"], "minimum": 1},
1618
+ "tableIndex": deepcopy(nullable_coordinate),
1619
+ "logicalRow": deepcopy(nullable_coordinate),
1620
+ "logicalColumn": deepcopy(nullable_coordinate),
1621
+ "physicalRow": deepcopy(nullable_coordinate),
1622
+ "physicalColumn": deepcopy(nullable_coordinate),
1623
+ },
1624
+ },
1625
+ },
1626
+ "batch": embedded_batch_schema,
1627
+ },
1628
+ }
1629
+ public_target_variants = [
1630
+ deepcopy(locator_variants[0]),
1631
+ deepcopy(locator_variants[1]),
1632
+ {
1633
+ "type": "object",
1634
+ "required": ["kind", "sectionPath", "tableAnchor", "cellAnchor"],
1635
+ "additionalProperties": False,
1636
+ "properties": {
1637
+ "kind": {"const": "labelCell"},
1638
+ "sectionPath": {"type": "string", "pattern": r"^/section\[[1-9][0-9]*\]$"},
1639
+ "tableAnchor": {
1640
+ "type": "string",
1641
+ "minLength": 1,
1642
+ "maxLength": MAX_TEXT_CHARS,
1643
+ },
1644
+ "cellAnchor": {"$ref": "#/$defs/requiredCellAnchor"},
1645
+ },
1646
+ },
1647
+ {
1648
+ "type": "object",
1649
+ "required": ["kind", "sectionPath", "tableIndex", "cellAnchor"],
1650
+ "additionalProperties": False,
1651
+ "properties": {
1652
+ "kind": {"const": "labelCell"},
1653
+ "sectionPath": {"type": "string", "pattern": r"^/section\[[1-9][0-9]*\]$"},
1654
+ "tableIndex": {"type": "integer", "minimum": 0},
1655
+ "cellAnchor": {"$ref": "#/$defs/requiredCellAnchor"},
1656
+ },
1657
+ },
1658
+ deepcopy(locator_variants[4]),
1659
+ {
1660
+ "type": "object",
1661
+ "required": ["kind", "sectionPath", "anchor", "expectedCount"],
1662
+ "additionalProperties": False,
1663
+ "properties": {
1664
+ "kind": {"const": "bodyAnchor"},
1665
+ "sectionPath": {"type": "string", "pattern": r"^/section\[[1-9][0-9]*\]$"},
1666
+ "anchor": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT_CHARS},
1667
+ "expectedCount": {"const": 1},
1668
+ },
1669
+ },
1670
+ ]
1671
+ public_plan_schema = {
1672
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
1673
+ "title": "HWPX mixed-form plan v1",
1674
+ "type": "object",
1675
+ "required": [
1676
+ "schemaVersion",
1677
+ "source",
1678
+ "output",
1679
+ "expectedRevision",
1680
+ "idempotencyKey",
1681
+ "dryRun",
1682
+ "overwrite",
1683
+ "quality",
1684
+ "verificationRequirements",
1685
+ "operations",
1686
+ ],
1687
+ "additionalProperties": False,
1688
+ "$defs": {
1689
+ "requiredCellAnchor": {
1690
+ "type": "object",
1691
+ "required": ["label", "direction"],
1692
+ "additionalProperties": False,
1693
+ "properties": {
1694
+ "label": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT_CHARS},
1695
+ "direction": {"enum": sorted(_DIRECTIONS)},
1696
+ },
1697
+ }
1698
+ },
1699
+ "properties": {
1700
+ "schemaVersion": {"const": MIXED_FORM_PLAN_SCHEMA},
1701
+ "source": {"type": "string", "minLength": 1},
1702
+ "output": {"type": "string", "minLength": 1},
1703
+ "expectedRevision": {
1704
+ "type": ["string", "null"],
1705
+ "pattern": REVISION_PATTERN.pattern,
1706
+ },
1707
+ "idempotencyKey": {
1708
+ "type": ["string", "null"],
1709
+ "minLength": 1,
1710
+ "maxLength": 128,
1711
+ },
1712
+ "dryRun": {"type": "boolean"},
1713
+ "overwrite": {"type": "boolean"},
1714
+ "quality": deepcopy(agent_schemas["batch"]["properties"]["quality"]),
1715
+ "verificationRequirements": {
1716
+ "type": "array",
1717
+ "items": {"enum": list(VERIFICATION_REQUIREMENTS)},
1718
+ "uniqueItems": True,
1719
+ },
1720
+ "operations": {
1721
+ "type": "array",
1722
+ "minItems": 1,
1723
+ "maxItems": MAX_COMMANDS,
1724
+ "items": {
1725
+ "type": "object",
1726
+ "required": ["operationId", "target", "value"],
1727
+ "additionalProperties": False,
1728
+ "properties": {
1729
+ "operationId": {
1730
+ "type": "string",
1731
+ "pattern": COMMAND_ID_PATTERN.pattern,
1732
+ },
1733
+ "target": {"oneOf": public_target_variants},
1734
+ "value": {"type": "string", "maxLength": MAX_TEXT_CHARS},
1735
+ },
1736
+ },
1737
+ },
1738
+ },
1739
+ }
1740
+ return {
1741
+ "plan": deepcopy(public_plan_schema),
1742
+ "compiledPlan": deepcopy(compiled_plan_schema),
1743
+ "internalRequest": deepcopy(request_schema),
1744
+ }
1745
+
1746
+
1747
+ __all__ = [
1748
+ "MIXED_FORM_COMPILED_PLAN_SCHEMA",
1749
+ "MIXED_FORM_LOCATOR_KINDS",
1750
+ "MIXED_FORM_PLAN_SCHEMA",
1751
+ "MIXED_FORM_REQUEST_SCHEMA",
1752
+ "MixedFormPlan",
1753
+ "MixedFormResolution",
1754
+ "apply_mixed_form_fill",
1755
+ "apply_mixed_form_plan",
1756
+ "mixed_form_json_schemas",
1757
+ "plan_mixed_form_fill",
1758
+ "validate_mixed_form_plan",
1759
+ "validate_mixed_form_request",
1760
+ ]