python-hwpx-automation 6.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Deterministic, full-page visual QA over page-only PNG inputs."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib
|
|
7
|
+
import io
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable, Iterable
|
|
11
|
+
|
|
12
|
+
from .fixture_corpus import FixtureCase, sha256_file
|
|
13
|
+
from .qa_contracts import (
|
|
14
|
+
DefectCategory,
|
|
15
|
+
Evidence,
|
|
16
|
+
FindingSeverity,
|
|
17
|
+
NormalizedBBox,
|
|
18
|
+
PageVerdict,
|
|
19
|
+
Provenance,
|
|
20
|
+
VisualFinding,
|
|
21
|
+
VisualVerdict,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DETECTOR_VERSION = "1.0.0"
|
|
26
|
+
_INK_THRESHOLD = 160
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class _Signal:
|
|
31
|
+
detector_id: str
|
|
32
|
+
category: DefectCategory
|
|
33
|
+
severity: FindingSeverity
|
|
34
|
+
confidence: float
|
|
35
|
+
bbox: NormalizedBBox
|
|
36
|
+
message: str
|
|
37
|
+
details: dict[str, float]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
Detector = Callable[[object], _Signal | None]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _mask(image):
|
|
44
|
+
np = importlib.import_module("numpy")
|
|
45
|
+
|
|
46
|
+
return np.asarray(image.convert("L")) < _INK_THRESHOLD
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _blank_page(image) -> _Signal | None:
|
|
50
|
+
ink_ratio = float(_mask(image).mean())
|
|
51
|
+
if ink_ratio >= 0.0005:
|
|
52
|
+
return None
|
|
53
|
+
return _Signal(
|
|
54
|
+
"blank-page",
|
|
55
|
+
DefectCategory.UNEXPECTED_BLANK_PAGE,
|
|
56
|
+
FindingSeverity.CRITICAL,
|
|
57
|
+
0.99,
|
|
58
|
+
NormalizedBBox.whole_page(),
|
|
59
|
+
"Page contains no meaningful rendered ink.",
|
|
60
|
+
{"inkRatio": ink_ratio},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _edge_clipping(image) -> _Signal | None:
|
|
65
|
+
np = importlib.import_module("numpy")
|
|
66
|
+
|
|
67
|
+
mask = _mask(image)
|
|
68
|
+
height, width = mask.shape
|
|
69
|
+
margin = max(2, int(round(min(width, height) * 0.002)))
|
|
70
|
+
edge = np.zeros_like(mask)
|
|
71
|
+
edge[:margin, :] = mask[:margin, :]
|
|
72
|
+
edge[-margin:, :] = mask[-margin:, :]
|
|
73
|
+
edge[:, :margin] = mask[:, :margin]
|
|
74
|
+
edge[:, -margin:] = mask[:, -margin:]
|
|
75
|
+
ys, xs = np.where(edge)
|
|
76
|
+
if not len(xs):
|
|
77
|
+
return None
|
|
78
|
+
pad = max(2, margin * 2)
|
|
79
|
+
bbox = _pixel_bbox(int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1, width, height, pad)
|
|
80
|
+
return _Signal(
|
|
81
|
+
"edge-clipping",
|
|
82
|
+
DefectCategory.TEXT_CLIPPING_OVERLAP,
|
|
83
|
+
FindingSeverity.CRITICAL,
|
|
84
|
+
0.98,
|
|
85
|
+
bbox,
|
|
86
|
+
"Rendered ink touches the page edge and may be clipped.",
|
|
87
|
+
{"edgeInkPixels": float(len(xs)), "edgeMarginPixels": float(margin)},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _dense_overlap_band(image) -> _Signal | None:
|
|
92
|
+
np = importlib.import_module("numpy")
|
|
93
|
+
|
|
94
|
+
mask = _mask(image)
|
|
95
|
+
height, width = mask.shape
|
|
96
|
+
density = mask.mean(axis=1)
|
|
97
|
+
dense = density >= 0.20
|
|
98
|
+
bands: list[tuple[int, int]] = []
|
|
99
|
+
start: int | None = None
|
|
100
|
+
for row, value in enumerate(dense):
|
|
101
|
+
if value and start is None:
|
|
102
|
+
start = row
|
|
103
|
+
elif not value and start is not None:
|
|
104
|
+
bands.append((start, row))
|
|
105
|
+
start = None
|
|
106
|
+
if start is not None:
|
|
107
|
+
bands.append((start, height))
|
|
108
|
+
min_height = max(14, int(round(height * 0.018)))
|
|
109
|
+
candidates = [(begin, end) for begin, end in bands if end - begin >= min_height]
|
|
110
|
+
if not candidates:
|
|
111
|
+
return None
|
|
112
|
+
begin, end = max(candidates, key=lambda item: item[1] - item[0])
|
|
113
|
+
band_mask = mask[begin:end, :]
|
|
114
|
+
_ys, xs = np.where(band_mask)
|
|
115
|
+
if not len(xs):
|
|
116
|
+
return None
|
|
117
|
+
bbox = _pixel_bbox(int(xs.min()), begin, int(xs.max()) + 1, end, width, height, 4)
|
|
118
|
+
ratio = (end - begin) / max(1, height)
|
|
119
|
+
return _Signal(
|
|
120
|
+
"dense-overlap-band",
|
|
121
|
+
DefectCategory.TEXT_CLIPPING_OVERLAP,
|
|
122
|
+
FindingSeverity.CRITICAL,
|
|
123
|
+
min(0.99, 0.90 + ratio),
|
|
124
|
+
bbox,
|
|
125
|
+
"Abnormally tall dense ink band suggests collapsed or overlapping text.",
|
|
126
|
+
{"bandHeightRatio": ratio, "maxRowDensity": float(density[begin:end].max())},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _implausible_density(image) -> _Signal | None:
|
|
131
|
+
ink_ratio = float(_mask(image).mean())
|
|
132
|
+
if ink_ratio <= 0.35:
|
|
133
|
+
return None
|
|
134
|
+
return _Signal(
|
|
135
|
+
"page-density",
|
|
136
|
+
DefectCategory.IMPLAUSIBLE_WHITESPACE_DENSITY,
|
|
137
|
+
FindingSeverity.ERROR,
|
|
138
|
+
min(0.99, 0.75 + ink_ratio / 4),
|
|
139
|
+
NormalizedBBox.whole_page(),
|
|
140
|
+
"Page ink density is visually implausible.",
|
|
141
|
+
{"inkRatio": ink_ratio},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
DETECTORS: tuple[Detector, ...] = (
|
|
146
|
+
_blank_page,
|
|
147
|
+
_edge_clipping,
|
|
148
|
+
_dense_overlap_band,
|
|
149
|
+
_implausible_density,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def inspect_page_png(path: str | Path, *, page: int) -> PageVerdict:
|
|
154
|
+
"""Inspect one entire PNG and return a hash-bound verdict."""
|
|
155
|
+
|
|
156
|
+
from PIL import Image
|
|
157
|
+
|
|
158
|
+
image_path = Path(path)
|
|
159
|
+
page_hash = sha256_file(image_path)
|
|
160
|
+
with Image.open(image_path) as source:
|
|
161
|
+
source.verify()
|
|
162
|
+
with Image.open(image_path) as source:
|
|
163
|
+
image = source.convert("RGB")
|
|
164
|
+
findings = [_finding_from_signal(signal, image, page, page_hash) for detector in DETECTORS if (signal := detector(image))]
|
|
165
|
+
return PageVerdict.build(page=page, page_sha256=page_hash, findings=findings)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def inspect_page_set(
|
|
169
|
+
pages: dict[int, str | Path],
|
|
170
|
+
*,
|
|
171
|
+
expected_pages: Iterable[int],
|
|
172
|
+
assurance: str,
|
|
173
|
+
render_checked: bool,
|
|
174
|
+
) -> VisualVerdict:
|
|
175
|
+
"""Inspect all supplied pages; unreadable or absent expected pages fail closed."""
|
|
176
|
+
|
|
177
|
+
verdicts: list[PageVerdict] = []
|
|
178
|
+
for page, path in sorted(pages.items()):
|
|
179
|
+
try:
|
|
180
|
+
verdicts.append(inspect_page_png(path, page=page))
|
|
181
|
+
except (OSError, ValueError):
|
|
182
|
+
# Omitting this page makes VisualVerdict mark it missing/unverified.
|
|
183
|
+
continue
|
|
184
|
+
return VisualVerdict.build(
|
|
185
|
+
expected_pages=expected_pages,
|
|
186
|
+
pages=verdicts,
|
|
187
|
+
assurance=assurance,
|
|
188
|
+
render_checked=render_checked,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def inspect_fixture_case(case: FixtureCase) -> VisualVerdict:
|
|
193
|
+
"""Inspect a fixture case without ever claiming real-Hancom assurance."""
|
|
194
|
+
|
|
195
|
+
return inspect_page_set(
|
|
196
|
+
{page.page: page.path for page in case.pages},
|
|
197
|
+
expected_pages=(page.page for page in case.pages),
|
|
198
|
+
assurance="fixture",
|
|
199
|
+
render_checked=False,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _pixel_bbox(x0: int, y0: int, x1: int, y1: int, width: int, height: int, pad: int) -> NormalizedBBox:
|
|
204
|
+
x0 = max(0, x0 - pad)
|
|
205
|
+
y0 = max(0, y0 - pad)
|
|
206
|
+
x1 = min(width, x1 + pad)
|
|
207
|
+
y1 = min(height, y1 + pad)
|
|
208
|
+
return NormalizedBBox(x0 / width, y0 / height, x1 / width, y1 / height)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _finding_from_signal(signal: _Signal, image, page: int, page_hash: str) -> VisualFinding:
|
|
212
|
+
width, height = image.size
|
|
213
|
+
box = signal.bbox
|
|
214
|
+
pixel_box = (
|
|
215
|
+
int(box.x0 * width),
|
|
216
|
+
int(box.y0 * height),
|
|
217
|
+
max(1, int(box.x1 * width)),
|
|
218
|
+
max(1, int(box.y1 * height)),
|
|
219
|
+
)
|
|
220
|
+
crop = image.crop(pixel_box)
|
|
221
|
+
buffer = io.BytesIO()
|
|
222
|
+
crop.save(buffer, format="PNG", optimize=False)
|
|
223
|
+
crop_hash = hashlib.sha256(buffer.getvalue()).hexdigest()
|
|
224
|
+
stable = f"{page_hash}:{page}:{signal.detector_id}:{signal.category.value}:{box}"
|
|
225
|
+
finding_id = "vf-" + hashlib.sha256(stable.encode("utf-8")).hexdigest()[:16]
|
|
226
|
+
return VisualFinding(
|
|
227
|
+
finding_id=finding_id,
|
|
228
|
+
page=page,
|
|
229
|
+
bbox=box,
|
|
230
|
+
category=signal.category,
|
|
231
|
+
severity=signal.severity,
|
|
232
|
+
confidence=round(signal.confidence, 6),
|
|
233
|
+
evidence=Evidence(page_hash, crop_hash, box),
|
|
234
|
+
provenance=Provenance(signal.detector_id, DETECTOR_VERSION, details=signal.details),
|
|
235
|
+
message=signal.message,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
__all__ = [
|
|
240
|
+
"DETECTOR_VERSION",
|
|
241
|
+
"DETECTORS",
|
|
242
|
+
"inspect_page_png",
|
|
243
|
+
"inspect_page_set",
|
|
244
|
+
"inspect_fixture_case",
|
|
245
|
+
]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Frozen contracts for full-page visual quality assurance.
|
|
3
|
+
|
|
4
|
+
The contracts in this module deliberately make two unsafe states impossible to
|
|
5
|
+
serialize as a pass:
|
|
6
|
+
|
|
7
|
+
* a document with a missing page verdict; and
|
|
8
|
+
* a document containing a critical finding.
|
|
9
|
+
|
|
10
|
+
Coordinates are normalized to the rendered page, making findings stable across
|
|
11
|
+
render DPI. Fixture provenance is represented elsewhere and can never be
|
|
12
|
+
promoted to real-Hancom evidence.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import asdict, dataclass, field
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any, Iterable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
TAXONOMY_VERSION = "hwpx-visual-defects/1.0"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DefectCategory(str, Enum):
|
|
25
|
+
TEXT_CLIPPING_OVERLAP = "text_clipping_overlap"
|
|
26
|
+
CELL_OVERFLOW = "cell_overflow"
|
|
27
|
+
UNEXPECTED_BLANK_PAGE = "unexpected_blank_page"
|
|
28
|
+
LEFTOVER_GUIDANCE_PLACEHOLDER_SAMPLE = "leftover_guidance_placeholder_sample"
|
|
29
|
+
EMPTY_REQUIRED_FIELD = "empty_required_field"
|
|
30
|
+
ORPHAN_BULLET_HEADING = "orphan_bullet_heading"
|
|
31
|
+
TABLE_GRID_BORDER_ANOMALY = "table_grid_border_anomaly"
|
|
32
|
+
FONT_COLOR_ALIGNMENT_INCONSISTENCY = "font_color_alignment_inconsistency"
|
|
33
|
+
IMAGE_SEAL_MISPLACEMENT = "image_seal_misplacement"
|
|
34
|
+
HEADER_FOOTER_PAGE_NUMBER_LOSS = "header_footer_page_number_loss"
|
|
35
|
+
IMPLAUSIBLE_WHITESPACE_DENSITY = "implausible_whitespace_density"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FindingSeverity(str, Enum):
|
|
39
|
+
INFO = "info"
|
|
40
|
+
WARNING = "warning"
|
|
41
|
+
ERROR = "error"
|
|
42
|
+
CRITICAL = "critical"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class VerdictStatus(str, Enum):
|
|
46
|
+
PASS = "pass"
|
|
47
|
+
FAIL = "fail"
|
|
48
|
+
NEEDS_REVIEW = "needs_review"
|
|
49
|
+
UNVERIFIED = "unverified"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class NormalizedBBox:
|
|
54
|
+
x0: float
|
|
55
|
+
y0: float
|
|
56
|
+
x1: float
|
|
57
|
+
y1: float
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
values = (self.x0, self.y0, self.x1, self.y1)
|
|
61
|
+
if not all(0.0 <= value <= 1.0 for value in values):
|
|
62
|
+
raise ValueError("normalized bbox coordinates must be in [0, 1]")
|
|
63
|
+
if self.x0 >= self.x1 or self.y0 >= self.y1:
|
|
64
|
+
raise ValueError("normalized bbox must have positive area")
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def whole_page(cls) -> "NormalizedBBox":
|
|
68
|
+
return cls(0.0, 0.0, 1.0, 1.0)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Evidence:
|
|
73
|
+
page_sha256: str
|
|
74
|
+
crop_sha256: str
|
|
75
|
+
crop_bbox: NormalizedBBox
|
|
76
|
+
mime_type: str = "image/png"
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
for name, value in (("page_sha256", self.page_sha256), ("crop_sha256", self.crop_sha256)):
|
|
80
|
+
if len(value) != 64 or any(ch not in "0123456789abcdef" for ch in value):
|
|
81
|
+
raise ValueError(f"{name} must be a lowercase sha256 hex digest")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class Provenance:
|
|
86
|
+
detector_id: str
|
|
87
|
+
detector_version: str
|
|
88
|
+
kind: str = "deterministic"
|
|
89
|
+
model: str | None = None
|
|
90
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
def __post_init__(self) -> None:
|
|
93
|
+
if not self.detector_id or not self.detector_version:
|
|
94
|
+
raise ValueError("detector id and version are required")
|
|
95
|
+
if self.kind not in {"deterministic", "vision_provider", "human"}:
|
|
96
|
+
raise ValueError("unsupported provenance kind")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class DocumentTarget:
|
|
101
|
+
kind: str
|
|
102
|
+
target_id: str
|
|
103
|
+
revision: str | None = None
|
|
104
|
+
paragraph_index: int | None = None
|
|
105
|
+
table_index: int | None = None
|
|
106
|
+
row: int | None = None
|
|
107
|
+
column: int | None = None
|
|
108
|
+
|
|
109
|
+
def __post_init__(self) -> None:
|
|
110
|
+
if self.kind not in {"page", "paragraph", "cell", "object", "header", "footer"}:
|
|
111
|
+
raise ValueError("unsupported document target kind")
|
|
112
|
+
if not self.target_id:
|
|
113
|
+
raise ValueError("document target id is required")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class VisualFinding:
|
|
118
|
+
finding_id: str
|
|
119
|
+
page: int
|
|
120
|
+
bbox: NormalizedBBox
|
|
121
|
+
category: DefectCategory
|
|
122
|
+
severity: FindingSeverity
|
|
123
|
+
confidence: float
|
|
124
|
+
evidence: Evidence
|
|
125
|
+
provenance: Provenance
|
|
126
|
+
message: str
|
|
127
|
+
target: DocumentTarget | None = None
|
|
128
|
+
|
|
129
|
+
def __post_init__(self) -> None:
|
|
130
|
+
if not self.finding_id:
|
|
131
|
+
raise ValueError("finding id is required")
|
|
132
|
+
if self.page < 0:
|
|
133
|
+
raise ValueError("page is zero-based and must be non-negative")
|
|
134
|
+
if not 0.0 <= self.confidence <= 1.0:
|
|
135
|
+
raise ValueError("confidence must be in [0, 1]")
|
|
136
|
+
if not self.message:
|
|
137
|
+
raise ValueError("finding message is required")
|
|
138
|
+
|
|
139
|
+
def to_dict(self) -> dict[str, Any]:
|
|
140
|
+
return _enum_values(asdict(self))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class PageVerdict:
|
|
145
|
+
page: int
|
|
146
|
+
page_sha256: str
|
|
147
|
+
status: VerdictStatus
|
|
148
|
+
findings: tuple[VisualFinding, ...] = ()
|
|
149
|
+
coverage_complete: bool = True
|
|
150
|
+
|
|
151
|
+
def __post_init__(self) -> None:
|
|
152
|
+
if self.page < 0:
|
|
153
|
+
raise ValueError("page is zero-based and must be non-negative")
|
|
154
|
+
if len(self.page_sha256) != 64 or any(ch not in "0123456789abcdef" for ch in self.page_sha256):
|
|
155
|
+
raise ValueError("page_sha256 must be a lowercase sha256 hex digest")
|
|
156
|
+
if any(item.page != self.page for item in self.findings):
|
|
157
|
+
raise ValueError("all findings must belong to the page verdict")
|
|
158
|
+
if not self.coverage_complete and self.status is not VerdictStatus.UNVERIFIED:
|
|
159
|
+
raise ValueError("incomplete page coverage must be unverified")
|
|
160
|
+
if any(
|
|
161
|
+
item.severity in {FindingSeverity.ERROR, FindingSeverity.CRITICAL}
|
|
162
|
+
for item in self.findings
|
|
163
|
+
) and self.status is not VerdictStatus.FAIL:
|
|
164
|
+
raise ValueError("error or critical finding must fail the page verdict")
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def build(
|
|
168
|
+
cls,
|
|
169
|
+
*,
|
|
170
|
+
page: int,
|
|
171
|
+
page_sha256: str,
|
|
172
|
+
findings: Iterable[VisualFinding],
|
|
173
|
+
coverage_complete: bool = True,
|
|
174
|
+
) -> "PageVerdict":
|
|
175
|
+
items = tuple(findings)
|
|
176
|
+
if any(item.page != page for item in items):
|
|
177
|
+
raise ValueError("all findings must belong to the page verdict")
|
|
178
|
+
if not coverage_complete:
|
|
179
|
+
status = VerdictStatus.UNVERIFIED
|
|
180
|
+
elif any(item.severity in {FindingSeverity.ERROR, FindingSeverity.CRITICAL} for item in items):
|
|
181
|
+
status = VerdictStatus.FAIL
|
|
182
|
+
elif items:
|
|
183
|
+
status = VerdictStatus.NEEDS_REVIEW
|
|
184
|
+
else:
|
|
185
|
+
status = VerdictStatus.PASS
|
|
186
|
+
return cls(page, page_sha256, status, items, coverage_complete)
|
|
187
|
+
|
|
188
|
+
def to_dict(self) -> dict[str, Any]:
|
|
189
|
+
return _enum_values(asdict(self))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class VisualVerdict:
|
|
194
|
+
taxonomy_version: str
|
|
195
|
+
status: VerdictStatus
|
|
196
|
+
expected_pages: tuple[int, ...]
|
|
197
|
+
pages: tuple[PageVerdict, ...]
|
|
198
|
+
missing_pages: tuple[int, ...]
|
|
199
|
+
unexpected_pages: tuple[int, ...]
|
|
200
|
+
findings: tuple[VisualFinding, ...]
|
|
201
|
+
critical_count: int
|
|
202
|
+
needs_review: bool
|
|
203
|
+
render_checked: bool
|
|
204
|
+
assurance: str
|
|
205
|
+
|
|
206
|
+
def __post_init__(self) -> None:
|
|
207
|
+
if self.assurance == "fixture" and self.render_checked:
|
|
208
|
+
raise ValueError("fixture evidence can never set render_checked=true")
|
|
209
|
+
if self.assurance in {"real_hancom", "provider"} and not self.render_checked:
|
|
210
|
+
raise ValueError("non-fixture assurance requires render_checked=true")
|
|
211
|
+
if self.critical_count and self.status is not VerdictStatus.FAIL:
|
|
212
|
+
raise ValueError("critical findings cannot be hidden by document status")
|
|
213
|
+
if (self.missing_pages or self.unexpected_pages) and self.status is not VerdictStatus.UNVERIFIED:
|
|
214
|
+
raise ValueError("incomplete document page coverage must be unverified")
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def build(
|
|
218
|
+
cls,
|
|
219
|
+
*,
|
|
220
|
+
expected_pages: Iterable[int],
|
|
221
|
+
pages: Iterable[PageVerdict],
|
|
222
|
+
assurance: str,
|
|
223
|
+
render_checked: bool,
|
|
224
|
+
) -> "VisualVerdict":
|
|
225
|
+
expected = tuple(sorted(set(expected_pages)))
|
|
226
|
+
if not expected:
|
|
227
|
+
raise ValueError("expected_pages must not be empty")
|
|
228
|
+
if assurance not in {"fixture", "real_hancom", "provider"}:
|
|
229
|
+
raise ValueError("unsupported visual assurance")
|
|
230
|
+
if assurance == "fixture" and render_checked:
|
|
231
|
+
raise ValueError("fixture evidence can never set render_checked=true")
|
|
232
|
+
if assurance in {"real_hancom", "provider"} and not render_checked:
|
|
233
|
+
raise ValueError("non-fixture assurance requires render_checked=true")
|
|
234
|
+
page_items = tuple(sorted(pages, key=lambda item: item.page))
|
|
235
|
+
actual = tuple(item.page for item in page_items)
|
|
236
|
+
if len(actual) != len(set(actual)):
|
|
237
|
+
raise ValueError("duplicate page verdict")
|
|
238
|
+
missing = tuple(page for page in expected if page not in actual)
|
|
239
|
+
unexpected = tuple(page for page in actual if page not in expected)
|
|
240
|
+
findings = tuple(finding for page in page_items for finding in page.findings)
|
|
241
|
+
critical_count = sum(item.severity is FindingSeverity.CRITICAL for item in findings)
|
|
242
|
+
incomplete = bool(missing or unexpected) or any(not page.coverage_complete for page in page_items)
|
|
243
|
+
failing = critical_count > 0 or any(page.status is VerdictStatus.FAIL for page in page_items)
|
|
244
|
+
review = any(page.status is VerdictStatus.NEEDS_REVIEW for page in page_items)
|
|
245
|
+
if incomplete:
|
|
246
|
+
status = VerdictStatus.UNVERIFIED
|
|
247
|
+
elif failing:
|
|
248
|
+
status = VerdictStatus.FAIL
|
|
249
|
+
elif review:
|
|
250
|
+
status = VerdictStatus.NEEDS_REVIEW
|
|
251
|
+
else:
|
|
252
|
+
status = VerdictStatus.PASS
|
|
253
|
+
return cls(
|
|
254
|
+
taxonomy_version=TAXONOMY_VERSION,
|
|
255
|
+
status=status,
|
|
256
|
+
expected_pages=expected,
|
|
257
|
+
pages=page_items,
|
|
258
|
+
missing_pages=missing,
|
|
259
|
+
unexpected_pages=unexpected,
|
|
260
|
+
findings=findings,
|
|
261
|
+
critical_count=critical_count,
|
|
262
|
+
needs_review=incomplete or failing or review,
|
|
263
|
+
render_checked=render_checked,
|
|
264
|
+
assurance=assurance,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def to_dict(self) -> dict[str, Any]:
|
|
268
|
+
return _enum_values(asdict(self))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _enum_values(value: Any) -> Any:
|
|
272
|
+
if isinstance(value, Enum):
|
|
273
|
+
return value.value
|
|
274
|
+
if isinstance(value, dict):
|
|
275
|
+
return {key: _enum_values(item) for key, item in value.items()}
|
|
276
|
+
if isinstance(value, (tuple, list)):
|
|
277
|
+
return [_enum_values(item) for item in value]
|
|
278
|
+
return value
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
__all__ = [
|
|
282
|
+
"TAXONOMY_VERSION",
|
|
283
|
+
"DefectCategory",
|
|
284
|
+
"FindingSeverity",
|
|
285
|
+
"VerdictStatus",
|
|
286
|
+
"NormalizedBBox",
|
|
287
|
+
"Evidence",
|
|
288
|
+
"Provenance",
|
|
289
|
+
"DocumentTarget",
|
|
290
|
+
"VisualFinding",
|
|
291
|
+
"PageVerdict",
|
|
292
|
+
"VisualVerdict",
|
|
293
|
+
]
|