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 +6 -0
- 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/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/oxml/table.py +216 -128
- 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.7.0.dist-info → python_hwpx-3.8.0.dist-info}/METADATA +20 -5
- {python_hwpx-3.7.0.dist-info → python_hwpx-3.8.0.dist-info}/RECORD +29 -22
- {python_hwpx-3.7.0.dist-info → python_hwpx-3.8.0.dist-info}/licenses/NOTICE +13 -1
- {python_hwpx-3.7.0.dist-info → python_hwpx-3.8.0.dist-info}/WHEEL +0 -0
- {python_hwpx-3.7.0.dist-info → python_hwpx-3.8.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-3.7.0.dist-info → python_hwpx-3.8.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-3.7.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,
|
|
@@ -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
|