python-hwpx 3.7.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 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,
@@ -206,6 +210,8 @@ __all__ = [
206
210
  "validate_package",
207
211
  "paragraph_patch",
208
212
  "render_layout_preview",
213
+ "render_document_viewer",
214
+ "DocumentViewer",
209
215
  "register_template",
210
216
  "table_compute",
211
217
  ]
@@ -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