python-hwpx 3.6.0__py3-none-any.whl → 3.8.0__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/__init__.py +12 -0
- hwpx/_document/persistence.py +196 -15
- hwpx/agent/_batch_verification.py +383 -0
- hwpx/agent/blueprint/replay.py +429 -259
- hwpx/agent/commands.py +269 -483
- hwpx/agent/form_plan.py +167 -92
- hwpx/agent/model.py +56 -0
- hwpx/authoring.py +206 -113
- hwpx/body_patch.py +16 -0
- hwpx/document.py +102 -8
- hwpx/equation/__init__.py +46 -0
- hwpx/equation/eqedit.py +317 -0
- hwpx/equation/mathml.py +69 -0
- hwpx/equation/render.py +83 -0
- hwpx/equation/tokens.py +256 -0
- hwpx/evalplan_fill.py +347 -173
- hwpx/fill_residue.py +73 -36
- hwpx/mutation_report.py +524 -0
- hwpx/opc/package.py +7 -0
- hwpx/oxml/table.py +216 -128
- hwpx/patch.py +24 -0
- hwpx/table_patch.py +16 -0
- hwpx/tools/__init__.py +8 -0
- hwpx/tools/document_viewer.py +182 -0
- hwpx/tools/fuzz/catalog.py +105 -84
- hwpx/tools/layout_preview.py +95 -16
- hwpx/tools/markdown_export.py +76 -54
- hwpx/tools/object_finder.py +100 -84
- hwpx/tools/toc_fidelity.py +87 -56
- hwpx/visual/fixture_corpus.py +106 -71
- {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/METADATA +101 -11
- {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/RECORD +37 -29
- {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/licenses/NOTICE +13 -1
- {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/WHEEL +0 -0
- {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/top_level.txt +0 -0
hwpx/__init__.py
CHANGED
|
@@ -75,6 +75,10 @@ from .tools.layout_preview import (
|
|
|
75
75
|
PreviewPage,
|
|
76
76
|
render_layout_preview,
|
|
77
77
|
)
|
|
78
|
+
from .tools.document_viewer import (
|
|
79
|
+
DocumentViewer,
|
|
80
|
+
render_document_viewer,
|
|
81
|
+
)
|
|
78
82
|
from .ingest import (
|
|
79
83
|
ConversionAttempt,
|
|
80
84
|
DocumentConverter,
|
|
@@ -85,6 +89,10 @@ from .ingest import (
|
|
|
85
89
|
HwpxMarkdownConverter,
|
|
86
90
|
UnsupportedDocumentFormat,
|
|
87
91
|
)
|
|
92
|
+
from .mutation_report import (
|
|
93
|
+
MutationReport,
|
|
94
|
+
PreservationDowngradeError,
|
|
95
|
+
)
|
|
88
96
|
from .patch import (
|
|
89
97
|
BytePreservingPatchResult,
|
|
90
98
|
ParagraphTextPatch,
|
|
@@ -146,6 +154,8 @@ __all__ = [
|
|
|
146
154
|
"ParagraphInfo",
|
|
147
155
|
"PackageValidationReport",
|
|
148
156
|
"BytePreservingPatchResult",
|
|
157
|
+
"MutationReport",
|
|
158
|
+
"PreservationDowngradeError",
|
|
149
159
|
"PlanValidationReport",
|
|
150
160
|
"ParagraphTextPatch",
|
|
151
161
|
"PatchApplied",
|
|
@@ -200,6 +210,8 @@ __all__ = [
|
|
|
200
210
|
"validate_package",
|
|
201
211
|
"paragraph_patch",
|
|
202
212
|
"render_layout_preview",
|
|
213
|
+
"render_document_viewer",
|
|
214
|
+
"DocumentViewer",
|
|
203
215
|
"register_template",
|
|
204
216
|
"table_compute",
|
|
205
217
|
]
|
hwpx/_document/persistence.py
CHANGED
|
@@ -5,8 +5,18 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import warnings
|
|
7
7
|
from os import PathLike
|
|
8
|
-
from typing import TYPE_CHECKING, Any, BinaryIO, Sequence
|
|
9
|
-
|
|
8
|
+
from typing import TYPE_CHECKING, Any, BinaryIO, Literal, Sequence
|
|
9
|
+
|
|
10
|
+
from ..mutation_report import (
|
|
11
|
+
Fallback,
|
|
12
|
+
Mode,
|
|
13
|
+
MutationReport,
|
|
14
|
+
PreservationDowngradeError,
|
|
15
|
+
PreservationMeasurement,
|
|
16
|
+
VerificationSummary,
|
|
17
|
+
VerificationValue,
|
|
18
|
+
measure_save,
|
|
19
|
+
)
|
|
10
20
|
from ..opc.package import _UNCHECKED_SAVE_TOKEN
|
|
11
21
|
from ..quality import QualityPolicy
|
|
12
22
|
from ..quality.report import OpenSafetyReport
|
|
@@ -120,27 +130,178 @@ def _gate_and_write(
|
|
|
120
130
|
return report
|
|
121
131
|
|
|
122
132
|
|
|
123
|
-
def
|
|
124
|
-
""
|
|
133
|
+
def _build_measured(
|
|
134
|
+
doc: "HwpxDocument", *, reset_dirty: bool
|
|
135
|
+
) -> tuple[bytes, PreservationMeasurement]:
|
|
136
|
+
"""Serialize + open-safety validate exactly like ``_to_bytes_raw`` and measure
|
|
137
|
+
the built archive's preservation against the pre-save part payloads.
|
|
138
|
+
|
|
139
|
+
The pre-save snapshot is captured *before* the ``serialize()`` updates are
|
|
140
|
+
applied so a per-save normalizer touching a part the editor never declared
|
|
141
|
+
dirty surfaces as an ``unexpected`` change (Safe Write Contract §2).
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
package = doc._package
|
|
145
|
+
# Measure against the OPEN-TIME baseline, not the current package state: a
|
|
146
|
+
# low-level ``set_part`` applied before this save already lives in
|
|
147
|
+
# ``_files``, and measuring against it would silently launder that change
|
|
148
|
+
# through a ``mode="patch"`` grade (Safe Write Contract §1, fail-closed).
|
|
149
|
+
source_members = dict(
|
|
150
|
+
getattr(package, "_opened_members", None) or package._files
|
|
151
|
+
)
|
|
152
|
+
source_infos = dict(
|
|
153
|
+
getattr(package, "_opened_zip_infos", None) or package._zip_infos
|
|
154
|
+
)
|
|
155
|
+
updates = doc._root.serialize()
|
|
156
|
+
predeclared = set(updates)
|
|
157
|
+
if updates:
|
|
158
|
+
for part_name, payload in updates.items():
|
|
159
|
+
package.set_part(part_name, payload)
|
|
160
|
+
result = package._save_to_bytes(verify_open_safety=True, mark_clean=False)
|
|
161
|
+
if not isinstance(result, bytes):
|
|
162
|
+
raise TypeError("package.save(None) must return bytes")
|
|
163
|
+
doc._run_open_safety_validation(result)
|
|
164
|
+
if reset_dirty:
|
|
165
|
+
_mark_save_clean(doc)
|
|
166
|
+
measurement = measure_save(source_members, source_infos, result, predeclared)
|
|
167
|
+
return result, measurement
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _resolve_grade(
|
|
171
|
+
mode: Mode,
|
|
172
|
+
fallback: Fallback,
|
|
173
|
+
measurement: PreservationMeasurement,
|
|
174
|
+
) -> tuple[Literal["patch", "rebuild"], bool]:
|
|
175
|
+
"""Return ``(actual_mode, fallback_used)``; raise before any write when a
|
|
176
|
+
requested ``patch`` grade is unmet and ``fallback="error"`` (§1)."""
|
|
177
|
+
|
|
178
|
+
if mode == "patch" and not measurement.patch_grade_ok:
|
|
179
|
+
if fallback == "error":
|
|
180
|
+
raise PreservationDowngradeError(
|
|
181
|
+
requested_mode=mode,
|
|
182
|
+
achieved_grade=measurement.achieved_grade,
|
|
183
|
+
offending_parts=measurement.offending_parts,
|
|
184
|
+
suggestion=(
|
|
185
|
+
"route these edits through a byte-preserving primitive "
|
|
186
|
+
"(hwpx.patch/table_patch/body_patch) or pass fallback='rebuild'."
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
return "rebuild", True
|
|
190
|
+
if mode == "auto":
|
|
191
|
+
return measurement.achieved_grade, False
|
|
192
|
+
if mode == "patch":
|
|
193
|
+
return "patch", False
|
|
194
|
+
return "rebuild", False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _verification_summary(report: "VisualCompleteReport") -> VerificationSummary:
|
|
198
|
+
"""Reflect what the pipeline actually ran; never fabricate a pass (§2)."""
|
|
199
|
+
|
|
200
|
+
package: VerificationValue = "passed" if report.ok else "failed"
|
|
201
|
+
open_safety: VerificationValue = "passed" if report.open_safety.ok else "failed"
|
|
202
|
+
# Open-safety validation (which includes the reopen probe) already ran during
|
|
203
|
+
# serialize and raised on failure, so reaching here means reopen passed.
|
|
204
|
+
reopen: VerificationValue = "passed"
|
|
205
|
+
status = report.visual_complete_status
|
|
206
|
+
visual: VerificationValue = (
|
|
207
|
+
"passed"
|
|
208
|
+
if status == "verified"
|
|
209
|
+
else "failed"
|
|
210
|
+
if status == "failed"
|
|
211
|
+
else "not_performed"
|
|
212
|
+
)
|
|
213
|
+
return VerificationSummary(
|
|
214
|
+
package=package, open_safety=open_safety, reopen=reopen, visual=visual
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _compose_mutation_report(
|
|
219
|
+
*,
|
|
220
|
+
path: str | None,
|
|
221
|
+
requested_mode: Mode,
|
|
222
|
+
actual_mode: Literal["patch", "rebuild"],
|
|
223
|
+
fallback_used: bool,
|
|
224
|
+
measurement: PreservationMeasurement,
|
|
225
|
+
report: "VisualCompleteReport",
|
|
226
|
+
) -> MutationReport:
|
|
227
|
+
return MutationReport(
|
|
228
|
+
requested_mode=requested_mode,
|
|
229
|
+
actual_mode=actual_mode,
|
|
230
|
+
fallback_used=fallback_used,
|
|
231
|
+
changed_parts=measurement.changed_parts,
|
|
232
|
+
preservation=measurement.preservation,
|
|
233
|
+
verification=_verification_summary(report),
|
|
234
|
+
path=path,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def save_to_path(
|
|
239
|
+
doc: "HwpxDocument",
|
|
240
|
+
path: str | PathLike[str],
|
|
241
|
+
*,
|
|
242
|
+
mode: Mode = "auto",
|
|
243
|
+
fallback: Fallback = "error",
|
|
244
|
+
return_report: bool = False,
|
|
245
|
+
) -> str | PathLike[str] | MutationReport:
|
|
246
|
+
"""Persist pending changes to *path*.
|
|
247
|
+
|
|
248
|
+
Returns *path* by default; ``return_report=True`` returns the
|
|
249
|
+
:class:`~hwpx.mutation_report.MutationReport` receipt instead. ``mode`` and
|
|
250
|
+
``fallback`` are the Safe Write Contract grade controls — ``mode="patch"``
|
|
251
|
+
with ``fallback="error"`` raises :class:`PreservationDowngradeError` and
|
|
252
|
+
writes nothing when the save cannot keep every untouched part byte-identical.
|
|
253
|
+
"""
|
|
125
254
|
|
|
126
255
|
_run_pre_save_validation(doc)
|
|
127
|
-
archive_bytes = doc
|
|
128
|
-
|
|
129
|
-
|
|
256
|
+
archive_bytes, measurement = _build_measured(doc, reset_dirty=False)
|
|
257
|
+
actual_mode, fallback_used = _resolve_grade(mode, fallback, measurement)
|
|
258
|
+
report = _gate_and_write(
|
|
259
|
+
doc, archive_bytes, output_path=path, source_label="document.save_to_path"
|
|
130
260
|
)
|
|
131
261
|
_mark_save_clean(doc)
|
|
262
|
+
if return_report:
|
|
263
|
+
return _compose_mutation_report(
|
|
264
|
+
path=str(path),
|
|
265
|
+
requested_mode=mode,
|
|
266
|
+
actual_mode=actual_mode,
|
|
267
|
+
fallback_used=fallback_used,
|
|
268
|
+
measurement=measurement,
|
|
269
|
+
report=report,
|
|
270
|
+
)
|
|
132
271
|
return path
|
|
133
272
|
|
|
134
273
|
|
|
135
|
-
def save_to_stream(
|
|
136
|
-
|
|
274
|
+
def save_to_stream(
|
|
275
|
+
doc: "HwpxDocument",
|
|
276
|
+
stream: BinaryIO,
|
|
277
|
+
*,
|
|
278
|
+
mode: Mode = "auto",
|
|
279
|
+
fallback: Fallback = "error",
|
|
280
|
+
return_report: bool = False,
|
|
281
|
+
) -> BinaryIO | MutationReport:
|
|
282
|
+
"""Persist pending changes to *stream*.
|
|
283
|
+
|
|
284
|
+
Returns *stream* by default; ``return_report=True`` returns the
|
|
285
|
+
:class:`~hwpx.mutation_report.MutationReport` receipt instead. See
|
|
286
|
+
:func:`save_to_path` for the ``mode``/``fallback`` grade semantics.
|
|
287
|
+
"""
|
|
137
288
|
|
|
138
289
|
_run_pre_save_validation(doc)
|
|
139
|
-
archive_bytes = doc
|
|
140
|
-
|
|
141
|
-
|
|
290
|
+
archive_bytes, measurement = _build_measured(doc, reset_dirty=False)
|
|
291
|
+
actual_mode, fallback_used = _resolve_grade(mode, fallback, measurement)
|
|
292
|
+
report = _gate_and_write(
|
|
293
|
+
doc, archive_bytes, output_stream=stream, source_label="document.save_to_stream"
|
|
142
294
|
)
|
|
143
295
|
_mark_save_clean(doc)
|
|
296
|
+
if return_report:
|
|
297
|
+
return _compose_mutation_report(
|
|
298
|
+
path=None,
|
|
299
|
+
requested_mode=mode,
|
|
300
|
+
actual_mode=actual_mode,
|
|
301
|
+
fallback_used=fallback_used,
|
|
302
|
+
measurement=measurement,
|
|
303
|
+
report=report,
|
|
304
|
+
)
|
|
144
305
|
return stream
|
|
145
306
|
|
|
146
307
|
|
|
@@ -193,11 +354,26 @@ def save_report(
|
|
|
193
354
|
return report
|
|
194
355
|
|
|
195
356
|
|
|
196
|
-
def to_bytes(
|
|
197
|
-
|
|
357
|
+
def to_bytes(
|
|
358
|
+
doc: "HwpxDocument",
|
|
359
|
+
*,
|
|
360
|
+
mode: Mode = "auto",
|
|
361
|
+
fallback: Fallback = "error",
|
|
362
|
+
) -> bytes:
|
|
363
|
+
"""Serialize pending changes and return the HWPX archive as bytes.
|
|
364
|
+
|
|
365
|
+
``mode``/``fallback`` enforce the Safe Write Contract grade the same way as
|
|
366
|
+
:func:`save_to_path`: ``mode="patch"`` with ``fallback="error"`` raises
|
|
367
|
+
:class:`PreservationDowngradeError` before returning when the archive is not
|
|
368
|
+
patch-grade. There is no ``return_report`` — the enforcement is via the typed
|
|
369
|
+
exception, so the byte return stays unchanged.
|
|
370
|
+
"""
|
|
198
371
|
|
|
199
372
|
_run_pre_save_validation(doc)
|
|
200
|
-
|
|
373
|
+
archive_bytes, measurement = _build_measured(doc, reset_dirty=False)
|
|
374
|
+
_resolve_grade(mode, fallback, measurement)
|
|
375
|
+
_mark_save_clean(doc)
|
|
376
|
+
return archive_bytes
|
|
201
377
|
|
|
202
378
|
|
|
203
379
|
def _to_bytes_raw(
|
|
@@ -239,6 +415,11 @@ def _to_bytes_for_validation(doc: "HwpxDocument") -> bytes:
|
|
|
239
415
|
def _mark_save_clean(doc: "HwpxDocument") -> None:
|
|
240
416
|
doc._root.reset_dirty()
|
|
241
417
|
doc._package.version_info.mark_clean()
|
|
418
|
+
# The published archive is the new preservation baseline for any further
|
|
419
|
+
# save on this document instance.
|
|
420
|
+
package = doc._package
|
|
421
|
+
package._opened_members = dict(package._files)
|
|
422
|
+
package._opened_zip_infos = dict(package._zip_infos)
|
|
242
423
|
|
|
243
424
|
|
|
244
425
|
def save(
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Verification/orchestration primitives for :func:`hwpx.agent.commands.apply_document_commands`.
|
|
3
|
+
|
|
4
|
+
Split out of ``commands.py`` (S-088 P3, agent/commands.py:apply_document_commands
|
|
5
|
+
complexity decomposition) to keep that module under its enforced line-count
|
|
6
|
+
ratchet. Everything here is self-contained -- it depends only on
|
|
7
|
+
``hwpx.agent.model``/``hwpx.agent.document`` and absolute ``hwpx`` imports, never
|
|
8
|
+
on ``commands.py`` itself, so ``commands.py`` can import from this module
|
|
9
|
+
without creating an import cycle.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from collections.abc import Callable, Mapping, MutableMapping
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from hwpx.document import HwpxDocument
|
|
21
|
+
from hwpx.mutation_report import member_diff_bytes
|
|
22
|
+
from hwpx.quality import QualityPolicy, SavePipeline
|
|
23
|
+
from hwpx.tools.package_validator import validate_editor_open_safety
|
|
24
|
+
|
|
25
|
+
from .document import HwpxAgentDocument
|
|
26
|
+
from .model import AgentBatchResult, AgentContractError, AgentError
|
|
27
|
+
|
|
28
|
+
_EMPTY_REVISION = "sha256:" + hashlib.sha256(b"").hexdigest()
|
|
29
|
+
|
|
30
|
+
IdempotencyStore = MutableMapping[str, Any]
|
|
31
|
+
FaultInjector = Callable[[str, int | None], None]
|
|
32
|
+
DomainVerifier = Callable[[bytes, Mapping[str, Any]], Mapping[str, Any]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _revision(data: bytes) -> str:
|
|
36
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _error_from_exception(exc: BaseException, *, target: str | None = None) -> AgentError:
|
|
40
|
+
if isinstance(exc, AgentContractError):
|
|
41
|
+
code = exc.code
|
|
42
|
+
message = str(exc)
|
|
43
|
+
target = exc.target or target
|
|
44
|
+
elif isinstance(exc, (KeyError, IndexError, TypeError, ValueError)):
|
|
45
|
+
code = "invariant_violation"
|
|
46
|
+
message = str(exc) or type(exc).__name__
|
|
47
|
+
else:
|
|
48
|
+
code = "verification_failed"
|
|
49
|
+
message = f"{type(exc).__name__}: {exc}"
|
|
50
|
+
recoverability = "retryable" if code in {"stale_revision", "idempotency_conflict"} else "terminal"
|
|
51
|
+
if code in {"ambiguous_target", "volatile_target", "unsupported_content"}:
|
|
52
|
+
recoverability = "needs-review"
|
|
53
|
+
suggestions = {
|
|
54
|
+
"stale_revision": "Read the document again and retry with its current revision.",
|
|
55
|
+
"ambiguous_target": "Resolve a unique canonical path before mutation.",
|
|
56
|
+
"volatile_target": "Refresh the positional path from the current document revision.",
|
|
57
|
+
"unknown_property": "Use the node capability catalog to choose an editable property.",
|
|
58
|
+
"incompatible_parent": "Choose a compatible semantic parent and position.",
|
|
59
|
+
"verification_failed": "Inspect verificationReport and do not publish the candidate.",
|
|
60
|
+
}
|
|
61
|
+
return AgentError(
|
|
62
|
+
code=code,
|
|
63
|
+
message=message[:4096],
|
|
64
|
+
target=target,
|
|
65
|
+
recoverability=recoverability,
|
|
66
|
+
suggestion=suggestions.get(code),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _failure_result(
|
|
71
|
+
*,
|
|
72
|
+
exc: BaseException,
|
|
73
|
+
batch: Mapping[str, Any] | None,
|
|
74
|
+
input_revision: str = _EMPTY_REVISION,
|
|
75
|
+
command_results: list[Mapping[str, Any]] | None = None,
|
|
76
|
+
verification: Mapping[str, Any] | None = None,
|
|
77
|
+
) -> AgentBatchResult:
|
|
78
|
+
raw = batch or {}
|
|
79
|
+
output = raw.get("output") if isinstance(raw, Mapping) else None
|
|
80
|
+
output_filename = ""
|
|
81
|
+
if isinstance(output, Mapping):
|
|
82
|
+
output_filename = str(output.get("filename") or "")
|
|
83
|
+
return AgentBatchResult(
|
|
84
|
+
ok=False,
|
|
85
|
+
rolled_back=True,
|
|
86
|
+
dry_run=bool(raw.get("dryRun", False)),
|
|
87
|
+
input_revision=input_revision,
|
|
88
|
+
document_revision=input_revision,
|
|
89
|
+
output_filename=output_filename,
|
|
90
|
+
command_results=tuple(command_results or ()),
|
|
91
|
+
verification_report=dict(verification or {}),
|
|
92
|
+
error=_error_from_exception(exc),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _quality_policy(value: str | Mapping[str, Any] | None) -> QualityPolicy:
|
|
97
|
+
if value is None or value == "transparent":
|
|
98
|
+
return QualityPolicy.transparent()
|
|
99
|
+
if value == "strict":
|
|
100
|
+
return QualityPolicy.strict()
|
|
101
|
+
if not isinstance(value, Mapping): # already contract-validated
|
|
102
|
+
raise AgentContractError("invalid_syntax", "quality is invalid", target="batch.quality")
|
|
103
|
+
mode = value.get("mode", "transparent")
|
|
104
|
+
policy = QualityPolicy.strict() if mode == "strict" else QualityPolicy.transparent()
|
|
105
|
+
mapping = {
|
|
106
|
+
"renderCheck": "render_check",
|
|
107
|
+
"xsdMode": "xsd_mode",
|
|
108
|
+
"overflowPolicy": "overflow_policy",
|
|
109
|
+
"layoutLint": "layout_lint",
|
|
110
|
+
"preserveUnmodifiedParts": "preserve_unmodified_parts",
|
|
111
|
+
"requireReferenceIntegrity": "require_reference_integrity",
|
|
112
|
+
}
|
|
113
|
+
changes = {mapping[name]: setting for name, setting in value.items() if name in mapping}
|
|
114
|
+
return policy.with_(**changes)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _member_diff(before: bytes, after: bytes) -> dict[str, Any]:
|
|
118
|
+
# Shared with the Safe Write Contract's MutationReport spine: one uncompressed
|
|
119
|
+
# member comparison, one home for the diff shape (mutation_report.py).
|
|
120
|
+
return member_diff_bytes(before, after)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _verify_header_story_candidates(
|
|
124
|
+
candidate_data: bytes,
|
|
125
|
+
candidate_revision: str,
|
|
126
|
+
expectations: Mapping[str, Mapping[str, str]],
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
receipts: list[dict[str, Any]] = []
|
|
129
|
+
if expectations:
|
|
130
|
+
with HwpxDocument.open(candidate_data) as reopened:
|
|
131
|
+
view = HwpxAgentDocument.from_document(
|
|
132
|
+
reopened, revision=candidate_revision
|
|
133
|
+
)
|
|
134
|
+
for expectation in expectations.values():
|
|
135
|
+
path = expectation["path"]
|
|
136
|
+
binding = view._resolve_header_story(path)
|
|
137
|
+
if (
|
|
138
|
+
binding.stable_id != expectation["stableId"]
|
|
139
|
+
or binding.page_type != expectation["pageType"]
|
|
140
|
+
or binding.text != expectation["text"]
|
|
141
|
+
):
|
|
142
|
+
raise AgentContractError(
|
|
143
|
+
"verification_failed",
|
|
144
|
+
"reopened header story does not match the committed binding",
|
|
145
|
+
target=path,
|
|
146
|
+
)
|
|
147
|
+
receipts.append(
|
|
148
|
+
{
|
|
149
|
+
"commandId": expectation["commandId"],
|
|
150
|
+
"path": binding.path,
|
|
151
|
+
"stableId": binding.stable_id,
|
|
152
|
+
"pageType": binding.page_type,
|
|
153
|
+
"textMatched": True,
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
return {
|
|
157
|
+
"schemaVersion": "hwpx.agent-story-preservation/v1",
|
|
158
|
+
"ok": True,
|
|
159
|
+
"storyCount": len(receipts),
|
|
160
|
+
"stories": receipts,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _request_hash(batch: Mapping[str, Any]) -> str:
|
|
165
|
+
payload = json.dumps(
|
|
166
|
+
batch,
|
|
167
|
+
ensure_ascii=False,
|
|
168
|
+
sort_keys=True,
|
|
169
|
+
separators=(",", ":"),
|
|
170
|
+
).encode("utf-8")
|
|
171
|
+
return _revision(payload)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _call_fault(injector: FaultInjector | None, stage: str, index: int | None = None) -> None:
|
|
175
|
+
if injector is not None:
|
|
176
|
+
injector(stage, index)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _apply_commands_idempotency_lookup(
|
|
180
|
+
verification: dict[str, Any],
|
|
181
|
+
idempotency_store: IdempotencyStore | None,
|
|
182
|
+
*,
|
|
183
|
+
key: str | None,
|
|
184
|
+
request_hash: str,
|
|
185
|
+
) -> AgentBatchResult | None:
|
|
186
|
+
verification["idempotency"] = {
|
|
187
|
+
"keyProvided": key is not None,
|
|
188
|
+
"requestHash": request_hash,
|
|
189
|
+
"replayed": False,
|
|
190
|
+
"store": "caller-owned" if idempotency_store is not None else "none",
|
|
191
|
+
}
|
|
192
|
+
if key is not None and idempotency_store is not None and key in idempotency_store:
|
|
193
|
+
cached = idempotency_store[key]
|
|
194
|
+
if not isinstance(cached, Mapping) or cached.get("requestHash") != request_hash:
|
|
195
|
+
raise AgentContractError(
|
|
196
|
+
"idempotency_conflict",
|
|
197
|
+
"idempotency key was used for a different request",
|
|
198
|
+
target="batch.idempotencyKey",
|
|
199
|
+
)
|
|
200
|
+
prior = cached.get("result")
|
|
201
|
+
if not isinstance(prior, AgentBatchResult):
|
|
202
|
+
raise AgentContractError(
|
|
203
|
+
"invariant_violation", "idempotency store contains an invalid result", target="batch.idempotencyKey"
|
|
204
|
+
)
|
|
205
|
+
replay_report = dict(prior.verification_report)
|
|
206
|
+
replay_idempotency = dict(replay_report.get("idempotency", {}))
|
|
207
|
+
replay_idempotency["replayed"] = True
|
|
208
|
+
replay_report["idempotency"] = replay_idempotency
|
|
209
|
+
return AgentBatchResult(
|
|
210
|
+
ok=prior.ok,
|
|
211
|
+
rolled_back=prior.rolled_back,
|
|
212
|
+
dry_run=prior.dry_run,
|
|
213
|
+
input_revision=prior.input_revision,
|
|
214
|
+
document_revision=prior.document_revision,
|
|
215
|
+
output_filename=prior.output_filename,
|
|
216
|
+
command_results=prior.command_results,
|
|
217
|
+
semantic_diff=prior.semantic_diff,
|
|
218
|
+
verification_report=replay_report,
|
|
219
|
+
error=prior.error,
|
|
220
|
+
)
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _validate_apply_commands_input(
|
|
225
|
+
normalized: Mapping[str, Any],
|
|
226
|
+
verification: dict[str, Any],
|
|
227
|
+
input_data: bytes,
|
|
228
|
+
input_revision: str,
|
|
229
|
+
output_path: Path,
|
|
230
|
+
) -> None:
|
|
231
|
+
verification["revision"] = {
|
|
232
|
+
"expected": normalized["expectedRevision"],
|
|
233
|
+
"actual": input_revision,
|
|
234
|
+
"matched": normalized["expectedRevision"] in {None, input_revision},
|
|
235
|
+
}
|
|
236
|
+
if normalized["expectedRevision"] not in {None, input_revision}:
|
|
237
|
+
raise AgentContractError(
|
|
238
|
+
"stale_revision", "expectedRevision does not match input bytes", target="batch.expectedRevision"
|
|
239
|
+
)
|
|
240
|
+
input_safety = validate_editor_open_safety(input_data)
|
|
241
|
+
verification["inputOpenSafety"] = input_safety.to_dict()
|
|
242
|
+
if not input_safety.ok:
|
|
243
|
+
raise AgentContractError(
|
|
244
|
+
"verification_failed",
|
|
245
|
+
"input failed package/reopen/openSafety verification",
|
|
246
|
+
target="batch.input.filename",
|
|
247
|
+
)
|
|
248
|
+
if output_path.exists() and not normalized["output"]["overwrite"] and not normalized["dryRun"]:
|
|
249
|
+
raise AgentContractError(
|
|
250
|
+
"invariant_violation", "output exists and overwrite is false", target="batch.output.filename"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _apply_commands_build_candidate_report(
|
|
255
|
+
input_data: bytes,
|
|
256
|
+
candidate_data: bytes,
|
|
257
|
+
input_revision: str,
|
|
258
|
+
semantic_changes: list[Mapping[str, Any]],
|
|
259
|
+
identity_changes: list[Mapping[str, str]],
|
|
260
|
+
story_expectations: Mapping[str, Mapping[str, str]],
|
|
261
|
+
verification: dict[str, Any],
|
|
262
|
+
) -> tuple[str, dict[str, Any], Any, dict[str, Any]]:
|
|
263
|
+
"""Compute the candidate revision/semantic diff and write byte/package safety
|
|
264
|
+
into ``verification``.
|
|
265
|
+
|
|
266
|
+
Does not raise on unsafe candidates -- the original ordering runs domain
|
|
267
|
+
verification before that check, so callers must still inspect the
|
|
268
|
+
returned ``safety``/``byte_report``.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
candidate_revision = _revision(candidate_data)
|
|
272
|
+
if story_expectations:
|
|
273
|
+
verification["storyPreservation"] = _verify_header_story_candidates(
|
|
274
|
+
candidate_data,
|
|
275
|
+
candidate_revision,
|
|
276
|
+
story_expectations,
|
|
277
|
+
)
|
|
278
|
+
semantic_diff = {
|
|
279
|
+
"schemaVersion": "hwpx.agent-semantic-diff/v1",
|
|
280
|
+
"inputRevision": input_revision,
|
|
281
|
+
"candidateRevision": candidate_revision,
|
|
282
|
+
"changes": semantic_changes,
|
|
283
|
+
"identityMap": identity_changes,
|
|
284
|
+
}
|
|
285
|
+
byte_report = _member_diff(input_data, candidate_data)
|
|
286
|
+
safety = validate_editor_open_safety(candidate_data)
|
|
287
|
+
safety_dict = safety.to_dict()
|
|
288
|
+
verification.update(
|
|
289
|
+
{
|
|
290
|
+
"candidateRevision": candidate_revision,
|
|
291
|
+
"package": safety_dict["validatePackage"],
|
|
292
|
+
"reopen": safety_dict["reopen"],
|
|
293
|
+
"openSafety": safety_dict,
|
|
294
|
+
"semanticDiff": {"ok": True, "changeCount": len(semantic_changes)},
|
|
295
|
+
"bytePreservation": byte_report,
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
return candidate_revision, semantic_diff, safety, byte_report
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _apply_commands_domain_verification(
|
|
302
|
+
candidate_data: bytes,
|
|
303
|
+
normalized: Mapping[str, Any],
|
|
304
|
+
requirements: set[str],
|
|
305
|
+
domain_verifier: DomainVerifier | None,
|
|
306
|
+
verification: dict[str, Any],
|
|
307
|
+
) -> None:
|
|
308
|
+
if "domain" in requirements:
|
|
309
|
+
if domain_verifier is None:
|
|
310
|
+
verification["domain"] = {"ok": False, "error": "required verifier unavailable"}
|
|
311
|
+
raise AgentContractError(
|
|
312
|
+
"verification_failed", "required domain verifier is unavailable", target="domain"
|
|
313
|
+
)
|
|
314
|
+
domain_report = dict(domain_verifier(candidate_data, normalized))
|
|
315
|
+
try:
|
|
316
|
+
json.dumps(domain_report, ensure_ascii=False, sort_keys=True)
|
|
317
|
+
except (TypeError, ValueError) as exc:
|
|
318
|
+
raise AgentContractError(
|
|
319
|
+
"verification_failed",
|
|
320
|
+
"domain verifier returned a non-JSON report",
|
|
321
|
+
target="domain",
|
|
322
|
+
) from exc
|
|
323
|
+
verification["domain"] = domain_report
|
|
324
|
+
if not domain_report.get("ok"):
|
|
325
|
+
raise AgentContractError("verification_failed", "domain verification failed", target="domain")
|
|
326
|
+
else:
|
|
327
|
+
verification["domain"] = {"ok": None, "status": "not-requested"}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _require_candidate_structural_safety(safety: Any, byte_report: Mapping[str, Any]) -> None:
|
|
331
|
+
if not safety.ok or not byte_report.get("ok"):
|
|
332
|
+
raise AgentContractError(
|
|
333
|
+
"verification_failed", "candidate failed package/reopen/openSafety verification"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _apply_commands_run_save_pipeline(
|
|
338
|
+
candidate_data: bytes,
|
|
339
|
+
input_path: Path,
|
|
340
|
+
output_path: Path,
|
|
341
|
+
document: HwpxDocument,
|
|
342
|
+
normalized: Mapping[str, Any],
|
|
343
|
+
requirements: set[str],
|
|
344
|
+
save_pipeline: SavePipeline | None,
|
|
345
|
+
verification: dict[str, Any],
|
|
346
|
+
) -> Any:
|
|
347
|
+
pipeline = save_pipeline or SavePipeline()
|
|
348
|
+
quality_policy = _quality_policy(normalized["quality"])
|
|
349
|
+
if "realHancom" in requirements:
|
|
350
|
+
# Make the required oracle part of the atomic gate itself. A
|
|
351
|
+
# post-publication provenance check would be too late to roll
|
|
352
|
+
# back an otherwise structurally valid file.
|
|
353
|
+
quality_policy = quality_policy.with_(
|
|
354
|
+
require_visual_complete=True,
|
|
355
|
+
render_check="required",
|
|
356
|
+
)
|
|
357
|
+
quality_report = pipeline.run(
|
|
358
|
+
candidate_data,
|
|
359
|
+
output_path=None if normalized["dryRun"] else output_path,
|
|
360
|
+
quality=quality_policy,
|
|
361
|
+
before=input_path,
|
|
362
|
+
reference_document=document,
|
|
363
|
+
publish="never" if normalized["dryRun"] else "on_pass",
|
|
364
|
+
source_label="agent.apply_document_commands",
|
|
365
|
+
)
|
|
366
|
+
verification["savePipeline"] = quality_report.to_dict()
|
|
367
|
+
verification["realHancom"] = {
|
|
368
|
+
"required": "realHancom" in requirements,
|
|
369
|
+
"ok": quality_report.visual_complete,
|
|
370
|
+
"status": quality_report.visual_complete_status,
|
|
371
|
+
"renderChecked": quality_report.render_checked,
|
|
372
|
+
}
|
|
373
|
+
if "realHancom" in requirements and not quality_report.visual_complete:
|
|
374
|
+
raise AgentContractError(
|
|
375
|
+
"verification_failed",
|
|
376
|
+
"required real-Hancom visual verification is unavailable or failed",
|
|
377
|
+
target="realHancom",
|
|
378
|
+
)
|
|
379
|
+
if not quality_report.ok:
|
|
380
|
+
raise AgentContractError(
|
|
381
|
+
"verification_failed", "SavePipeline rejected the candidate", target="savePipeline"
|
|
382
|
+
)
|
|
383
|
+
return quality_report
|