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.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- 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
|
+
]
|