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.
Files changed (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. 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
+ ]