python-hwpx 3.7.0__py3-none-any.whl → 4.0.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 +98 -37
- hwpx/_document/persistence.py +27 -31
- hwpx/agent/_batch_verification.py +383 -0
- hwpx/agent/blueprint/replay.py +429 -259
- hwpx/agent/commands.py +269 -467
- hwpx/agent/form_plan.py +167 -92
- hwpx/authoring.py +206 -113
- hwpx/document.py +0 -25
- 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/errors.py +70 -0
- hwpx/evalplan_fill.py +347 -173
- hwpx/experimental.py +48 -0
- hwpx/fill_residue.py +73 -36
- hwpx/mutation_report.py +22 -4
- hwpx/oxml/table.py +216 -128
- hwpx/table_patch.py +19 -4
- 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-4.0.0.dist-info/METADATA +181 -0
- {python_hwpx-3.7.0.dist-info → python_hwpx-4.0.0.dist-info}/RECORD +35 -26
- {python_hwpx-3.7.0.dist-info → python_hwpx-4.0.0.dist-info}/licenses/NOTICE +13 -1
- python_hwpx-3.7.0.dist-info/METADATA +0 -329
- {python_hwpx-3.7.0.dist-info → python_hwpx-4.0.0.dist-info}/WHEEL +0 -0
- {python_hwpx-3.7.0.dist-info → python_hwpx-4.0.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-3.7.0.dist-info → python_hwpx-4.0.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-3.7.0.dist-info → python_hwpx-4.0.0.dist-info}/top_level.txt +0 -0
hwpx/__init__.py
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
"""High-level utilities for working with HWPX documents.
|
|
2
|
+
"""High-level utilities for working with HWPX documents.
|
|
3
3
|
|
|
4
|
+
최상위 ``from hwpx import ...`` 표면은 세 계층으로 나뉩니다(정책·전수 목록:
|
|
5
|
+
``docs/stable-api.md``):
|
|
6
|
+
|
|
7
|
+
- **stable** — ``__all__``에 있는 이름. 계약이 굳었고 major 경계에서만 깨질 수 있음.
|
|
8
|
+
- **experimental** — 계약이 유동적. ``from hwpx.experimental import ...``로 쓰세요.
|
|
9
|
+
최상위 재내보내기는 하위 호환을 위해 유지하되 접근 시 ``DeprecationWarning``이 나며
|
|
10
|
+
다음 major에서 제거될 예정입니다.
|
|
11
|
+
- **deprecated** — 대체 경로로 이전하세요. 접근 시 ``DeprecationWarning``이 납니다.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import warnings
|
|
4
16
|
from importlib.metadata import PackageNotFoundError, version as _metadata_version
|
|
5
17
|
|
|
6
18
|
|
|
@@ -11,13 +23,94 @@ def _resolve_version() -> str:
|
|
|
11
23
|
except PackageNotFoundError:
|
|
12
24
|
return "0+unknown"
|
|
13
25
|
|
|
26
|
+
|
|
27
|
+
# --- experimental / deprecated 최상위 표면 (지연 접근, 접근 시 경고) ---------------
|
|
28
|
+
#
|
|
29
|
+
# stable 이름은 아래에서 eager import 되어 모듈 전역에 존재하므로 ``__getattr__``이
|
|
30
|
+
# 호출되지 않습니다(=경고 없음). 여기 등록된 이름만 지연 해석되어 경고를 냅니다.
|
|
31
|
+
# 4.0.0에서 제거되는 이름은 0개 — 모두 계속 import 가능합니다.
|
|
32
|
+
|
|
33
|
+
_EXPERIMENTAL_EXPORTS = {
|
|
34
|
+
# 문서 ingestion 프레임워크(임의 포맷 -> HWPX). 계약 유동.
|
|
35
|
+
"ConversionAttempt": "hwpx.ingest",
|
|
36
|
+
"DocumentConverter": "hwpx.ingest",
|
|
37
|
+
"DocumentIngestError": "hwpx.ingest",
|
|
38
|
+
"DocumentIngestResult": "hwpx.ingest",
|
|
39
|
+
"DocumentIngestor": "hwpx.ingest",
|
|
40
|
+
"DocumentSourceInfo": "hwpx.ingest",
|
|
41
|
+
"UnsupportedDocumentFormat": "hwpx.ingest",
|
|
42
|
+
# 레이아웃 프리뷰(한컴 없는 정직 근사). 계약 유동.
|
|
43
|
+
"LayoutPreview": "hwpx.tools.layout_preview",
|
|
44
|
+
"PreviewPage": "hwpx.tools.layout_preview",
|
|
45
|
+
"render_layout_preview": "hwpx.tools.layout_preview",
|
|
46
|
+
# 문서 프리뷰 뷰어(3.8.0 신규). 계약 유동.
|
|
47
|
+
"DocumentViewer": "hwpx.tools.document_viewer",
|
|
48
|
+
"render_document_viewer": "hwpx.tools.document_viewer",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_DEPRECATED_EXPORTS = {
|
|
52
|
+
"analyze_template_formfit": "hwpx.template_formfit",
|
|
53
|
+
"apply_template_formfit": "hwpx.template_formfit",
|
|
54
|
+
"TEMPLATE_FORMFIT_BASELINE_SCHEMA_VERSION": "hwpx.template_formfit",
|
|
55
|
+
"TEMPLATE_FORMFIT_PLAN_SCHEMA_VERSION": "hwpx.template_formfit",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# deprecated formfit 표면의 공통 대체 경로 안내.
|
|
59
|
+
_FORMFIT_REPLACEMENT = (
|
|
60
|
+
"구조적 form-fill 경로를 사용하세요: 라이브러리는 "
|
|
61
|
+
"hwpx.table_patch.fill_cells 계열, MCP는 analyze_form_fill/apply_form_fill/"
|
|
62
|
+
"verify_form_fill."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _experimental_message(name: str) -> str:
|
|
67
|
+
return (
|
|
68
|
+
f"'hwpx.{name}'는 실험적(experimental) 표면입니다. 계약이 유동적이므로 "
|
|
69
|
+
f"'from hwpx.experimental import {name}'로 import하세요. 최상위 재내보내기는 "
|
|
70
|
+
f"다음 major에서 제거될 예정입니다."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _deprecated_message(name: str) -> str:
|
|
75
|
+
return (
|
|
76
|
+
f"'hwpx.{name}'는 deprecated입니다. {_FORMFIT_REPLACEMENT} 이 이름은 "
|
|
77
|
+
f"다음 major에서 제거될 예정입니다."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
14
81
|
def __getattr__(name: str) -> object:
|
|
15
|
-
"""Resolve dynamic module attributes.
|
|
82
|
+
"""Resolve dynamic module attributes.
|
|
83
|
+
|
|
84
|
+
``__version__``은 경고 없이 지연 해석하고, experimental/deprecated 이름은
|
|
85
|
+
해석 시 ``DeprecationWarning``을 냅니다.
|
|
86
|
+
"""
|
|
16
87
|
|
|
17
88
|
if name == "__version__":
|
|
18
89
|
return _resolve_version()
|
|
90
|
+
|
|
91
|
+
module_name = _EXPERIMENTAL_EXPORTS.get(name)
|
|
92
|
+
if module_name is not None:
|
|
93
|
+
warnings.warn(_experimental_message(name), DeprecationWarning, stacklevel=2)
|
|
94
|
+
return getattr(importlib.import_module(module_name), name)
|
|
95
|
+
|
|
96
|
+
module_name = _DEPRECATED_EXPORTS.get(name)
|
|
97
|
+
if module_name is not None:
|
|
98
|
+
warnings.warn(_deprecated_message(name), DeprecationWarning, stacklevel=2)
|
|
99
|
+
return getattr(importlib.import_module(module_name), name)
|
|
100
|
+
|
|
19
101
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
20
102
|
|
|
103
|
+
|
|
104
|
+
def __dir__() -> list[str]:
|
|
105
|
+
return sorted(
|
|
106
|
+
set(globals())
|
|
107
|
+
| set(__all__)
|
|
108
|
+
| set(_EXPERIMENTAL_EXPORTS)
|
|
109
|
+
| set(_DEPRECATED_EXPORTS)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --- stable 최상위 표면 (eager import) ------------------------------------------
|
|
21
114
|
from .tools.text_extractor import (
|
|
22
115
|
DEFAULT_NAMESPACES,
|
|
23
116
|
ParagraphInfo,
|
|
@@ -70,21 +163,8 @@ from .tools.style_profile import (
|
|
|
70
163
|
placeholder_fill_report,
|
|
71
164
|
register_template,
|
|
72
165
|
)
|
|
73
|
-
from .
|
|
74
|
-
|
|
75
|
-
PreviewPage,
|
|
76
|
-
render_layout_preview,
|
|
77
|
-
)
|
|
78
|
-
from .ingest import (
|
|
79
|
-
ConversionAttempt,
|
|
80
|
-
DocumentConverter,
|
|
81
|
-
DocumentIngestError,
|
|
82
|
-
DocumentIngestResult,
|
|
83
|
-
DocumentIngestor,
|
|
84
|
-
DocumentSourceInfo,
|
|
85
|
-
HwpxMarkdownConverter,
|
|
86
|
-
UnsupportedDocumentFormat,
|
|
87
|
-
)
|
|
166
|
+
from .ingest import HwpxMarkdownConverter
|
|
167
|
+
from .errors import HwpxError
|
|
88
168
|
from .mutation_report import (
|
|
89
169
|
MutationReport,
|
|
90
170
|
PreservationDowngradeError,
|
|
@@ -120,12 +200,6 @@ from .quality import (
|
|
|
120
200
|
SavePipeline,
|
|
121
201
|
VisualCompleteReport,
|
|
122
202
|
)
|
|
123
|
-
from .template_formfit import (
|
|
124
|
-
TEMPLATE_FORMFIT_BASELINE_SCHEMA_VERSION,
|
|
125
|
-
TEMPLATE_FORMFIT_PLAN_SCHEMA_VERSION,
|
|
126
|
-
analyze_template_formfit,
|
|
127
|
-
apply_template_formfit,
|
|
128
|
-
)
|
|
129
203
|
|
|
130
204
|
__all__ = [
|
|
131
205
|
"QualityPolicy",
|
|
@@ -137,30 +211,21 @@ __all__ = [
|
|
|
137
211
|
"DEFAULT_STYLE_PRESET",
|
|
138
212
|
"DOCUMENT_PLAN_SCHEMA_VERSION",
|
|
139
213
|
"get_document_plan_schema",
|
|
140
|
-
"ConversionAttempt",
|
|
141
214
|
"DocumentBlock",
|
|
142
|
-
"DocumentConverter",
|
|
143
|
-
"DocumentIngestError",
|
|
144
|
-
"DocumentIngestResult",
|
|
145
|
-
"DocumentIngestor",
|
|
146
215
|
"DocumentPlan",
|
|
147
|
-
"DocumentSourceInfo",
|
|
148
216
|
"DocumentStylePreset",
|
|
149
217
|
"EditorOpenSafetyReport",
|
|
150
218
|
"ParagraphInfo",
|
|
151
219
|
"PackageValidationReport",
|
|
152
220
|
"BytePreservingPatchResult",
|
|
221
|
+
"HwpxError",
|
|
153
222
|
"MutationReport",
|
|
154
223
|
"PreservationDowngradeError",
|
|
155
224
|
"PlanValidationReport",
|
|
156
225
|
"ParagraphTextPatch",
|
|
157
226
|
"PatchApplied",
|
|
158
227
|
"PatchSkipped",
|
|
159
|
-
"LayoutPreview",
|
|
160
|
-
"PreviewPage",
|
|
161
228
|
"SectionInfo",
|
|
162
|
-
"TEMPLATE_FORMFIT_BASELINE_SCHEMA_VERSION",
|
|
163
|
-
"TEMPLATE_FORMFIT_PLAN_SCHEMA_VERSION",
|
|
164
229
|
"TextExtractor",
|
|
165
230
|
"FoundElement",
|
|
166
231
|
"HwpxMarkdownConverter",
|
|
@@ -173,7 +238,6 @@ __all__ = [
|
|
|
173
238
|
"STYLE_PROFILE_SCHEMA_VERSION",
|
|
174
239
|
"TEMPLATE_REGISTRY_SCHEMA_VERSION",
|
|
175
240
|
"TABLE_COMPUTE_REPORT_VERSION",
|
|
176
|
-
"UnsupportedDocumentFormat",
|
|
177
241
|
"apply_style_profile_to_plan",
|
|
178
242
|
"build_comparison_table_plan",
|
|
179
243
|
"build_image_grid",
|
|
@@ -186,8 +250,6 @@ __all__ = [
|
|
|
186
250
|
"HwpxDocument",
|
|
187
251
|
"HwpxPackage",
|
|
188
252
|
"create_document_from_plan",
|
|
189
|
-
"analyze_template_formfit",
|
|
190
|
-
"apply_template_formfit",
|
|
191
253
|
"approval_box",
|
|
192
254
|
"describe_template",
|
|
193
255
|
"extract_style_profile",
|
|
@@ -205,7 +267,6 @@ __all__ = [
|
|
|
205
267
|
"validate_editor_open_safety",
|
|
206
268
|
"validate_package",
|
|
207
269
|
"paragraph_patch",
|
|
208
|
-
"render_layout_preview",
|
|
209
270
|
"register_template",
|
|
210
271
|
"table_compute",
|
|
211
272
|
]
|
hwpx/_document/persistence.py
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
-
import warnings
|
|
7
6
|
from os import PathLike
|
|
8
7
|
from typing import TYPE_CHECKING, Any, BinaryIO, Literal, Sequence
|
|
9
8
|
|
|
9
|
+
from ..errors import SaveError
|
|
10
10
|
from ..mutation_report import (
|
|
11
11
|
Fallback,
|
|
12
12
|
Mode,
|
|
@@ -84,7 +84,15 @@ def _run_pre_save_validation(doc: "HwpxDocument") -> None:
|
|
|
84
84
|
report = doc.validate()
|
|
85
85
|
if not report.ok:
|
|
86
86
|
msgs = _summarize_validation_issues(report.issues)
|
|
87
|
-
raise
|
|
87
|
+
raise SaveError(
|
|
88
|
+
f"Document validation failed: {msgs}",
|
|
89
|
+
code="document-validation-failed",
|
|
90
|
+
context={"issueCount": len(report.issues), "issues": msgs},
|
|
91
|
+
suggestion=(
|
|
92
|
+
"Fix the reported schema issues, or disable validate_on_save if "
|
|
93
|
+
"the validation is a false positive for your document."
|
|
94
|
+
),
|
|
95
|
+
)
|
|
88
96
|
|
|
89
97
|
|
|
90
98
|
def _run_open_safety_validation(doc: "HwpxDocument", archive_bytes: bytes) -> None:
|
|
@@ -94,9 +102,15 @@ def _run_open_safety_validation(doc: "HwpxDocument", archive_bytes: bytes) -> No
|
|
|
94
102
|
|
|
95
103
|
report = validate_editor_open_safety(archive_bytes)
|
|
96
104
|
if not report.ok:
|
|
97
|
-
raise
|
|
105
|
+
raise SaveError(
|
|
98
106
|
"Generated HWPX package failed open-safety validation: "
|
|
99
|
-
+ report.summary
|
|
107
|
+
+ report.summary,
|
|
108
|
+
code="open-safety-failed",
|
|
109
|
+
context={"summary": report.summary},
|
|
110
|
+
suggestion=(
|
|
111
|
+
"The serialized package would not reopen in an HWPX editor; "
|
|
112
|
+
"inspect validate_editor_open_safety(...) for the failing checks."
|
|
113
|
+
),
|
|
100
114
|
)
|
|
101
115
|
|
|
102
116
|
|
|
@@ -126,7 +140,15 @@ def _gate_and_write(
|
|
|
126
140
|
)
|
|
127
141
|
if not report.ok:
|
|
128
142
|
detail = "; ".join(str(error) for error in report.errors)
|
|
129
|
-
raise
|
|
143
|
+
raise SaveError(
|
|
144
|
+
f"Document save failed the quality gate: {detail}",
|
|
145
|
+
code="quality-gate-failed",
|
|
146
|
+
context={"errors": [str(error) for error in report.errors]},
|
|
147
|
+
suggestion=(
|
|
148
|
+
"The SavePipeline quality gate rejected the archive; inspect the "
|
|
149
|
+
"returned report's errors before retrying."
|
|
150
|
+
),
|
|
151
|
+
)
|
|
130
152
|
return report
|
|
131
153
|
|
|
132
154
|
|
|
@@ -420,29 +442,3 @@ def _mark_save_clean(doc: "HwpxDocument") -> None:
|
|
|
420
442
|
package = doc._package
|
|
421
443
|
package._opened_members = dict(package._files)
|
|
422
444
|
package._opened_zip_infos = dict(package._zip_infos)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
def save(
|
|
426
|
-
doc: "HwpxDocument",
|
|
427
|
-
path_or_stream: str | PathLike[str] | BinaryIO | None = None,
|
|
428
|
-
) -> str | PathLike[str] | BinaryIO | bytes:
|
|
429
|
-
"""Deprecated compatibility wrapper around save_to_path/save_to_stream/to_bytes.
|
|
430
|
-
|
|
431
|
-
Deprecated:
|
|
432
|
-
``save()``는 하위 호환을 위해 유지되며 향후 제거될 수 있습니다.
|
|
433
|
-
- 경로 저장: ``save_to_path(path)``
|
|
434
|
-
- 스트림 저장: ``save_to_stream(stream)``
|
|
435
|
-
- 바이트 반환: ``to_bytes()``
|
|
436
|
-
"""
|
|
437
|
-
|
|
438
|
-
warnings.warn(
|
|
439
|
-
"HwpxDocument.save()는 deprecated 예정입니다. "
|
|
440
|
-
"save_to_path()/save_to_stream()/to_bytes() 사용을 권장합니다.",
|
|
441
|
-
DeprecationWarning,
|
|
442
|
-
stacklevel=2,
|
|
443
|
-
)
|
|
444
|
-
if path_or_stream is None:
|
|
445
|
-
return doc.to_bytes()
|
|
446
|
-
if isinstance(path_or_stream, (str, PathLike)):
|
|
447
|
-
return doc.save_to_path(path_or_stream)
|
|
448
|
-
return doc.save_to_stream(path_or_stream)
|
|
@@ -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
|