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,667 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Provider-neutral, fixture-only full-page visual QA contracts.
3
+
4
+ This module deliberately keeps fixture evidence in a receipt type that cannot be
5
+ promoted to real-Hancom evidence. Production adapters may implement
6
+ ``VisionAdapter`` later, while deterministic fixtures keep tests and evaluations
7
+ reproducible today.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import shutil
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Mapping, Protocol, Sequence
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
21
+
22
+ from .document_state import document_revision
23
+
24
+
25
+ FIXTURE_RECEIPT_SCHEMA = "hwpx.visual-review.fixture.v1"
26
+ FIXTURE_MANIFEST_SCHEMA = "hwpx.visual-fixture-manifest/v1"
27
+ UNVERIFIED_STATUS = "structurally_verified_render_unverified"
28
+ SEVERITY_ORDER = {"info": 0, "warning": 1, "error": 2, "critical": 3}
29
+ _SAFE_REPAIR_TYPES = frozenset({"replace_text"})
30
+
31
+
32
+ def _canonical_hash(value: Any) -> str:
33
+ raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
34
+ return f"sha256:{hashlib.sha256(raw).hexdigest()}"
35
+
36
+
37
+ class FixtureVisualReviewReceipt(BaseModel):
38
+ """Evidence for deterministic fixtures, never evidence of a real render."""
39
+
40
+ model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
41
+
42
+ schema_version: str = Field(default=FIXTURE_RECEIPT_SCHEMA, alias="schemaVersion")
43
+ receipt_kind: str = Field(default="deterministic_fixture", alias="receiptKind")
44
+ manifest_hash: str = Field(alias="manifestHash")
45
+ fixture_set_id: str = Field(alias="fixtureSetId")
46
+ taxonomy_version: str = Field(alias="taxonomyVersion")
47
+ reviewed_at: str = Field(alias="reviewedAt")
48
+ expected_pages: int = Field(alias="expectedPages", ge=1)
49
+ reviewed_pages: tuple[int, ...] = Field(alias="reviewedPages")
50
+ coverage_complete: bool = Field(alias="coverageComplete")
51
+ adapter_ids: tuple[str, ...] = Field(alias="adapterIds")
52
+ render_checked: bool = Field(default=False, alias="renderChecked")
53
+ real_hancom_verified: bool = Field(default=False, alias="realHancomVerified")
54
+ verification_status: str = Field(default=UNVERIFIED_STATUS, alias="verificationStatus")
55
+
56
+ @field_validator("render_checked", "real_hancom_verified")
57
+ @classmethod
58
+ def fixture_cannot_claim_real_render(cls, value: bool) -> bool:
59
+ if value:
60
+ raise ValueError("a fixture receipt can never claim real-Hancom verification")
61
+ return False
62
+
63
+ @field_validator("verification_status")
64
+ @classmethod
65
+ def fixture_status_is_always_unverified(cls, value: str) -> str:
66
+ if value != UNVERIFIED_STATUS:
67
+ raise ValueError("fixture verification status cannot be promoted")
68
+ return value
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class AdapterPageResult:
73
+ adapter_id: str
74
+ page: int
75
+ findings: tuple[dict[str, Any], ...]
76
+ error: str | None = None
77
+
78
+
79
+ class VisionAdapter(Protocol):
80
+ """Provider-neutral boundary: adapters return normalized findings per page."""
81
+
82
+ adapter_id: str
83
+
84
+ def inspect_page(self, page: Mapping[str, Any]) -> AdapterPageResult: ...
85
+
86
+
87
+ class DeterministicFixtureAdapter:
88
+ """Read one adapter's frozen findings from a versioned fixture manifest."""
89
+
90
+ def __init__(self, adapter_id: str) -> None:
91
+ self.adapter_id = adapter_id
92
+
93
+ def inspect_page(self, page: Mapping[str, Any]) -> AdapterPageResult:
94
+ page_number = int(page["page"])
95
+ by_adapter = page.get("findingsByAdapter", {})
96
+ if not isinstance(by_adapter, Mapping):
97
+ return AdapterPageResult(self.adapter_id, page_number, (), "findingsByAdapter must be an object")
98
+ raw = by_adapter.get(self.adapter_id)
99
+ if raw is None:
100
+ return AdapterPageResult(self.adapter_id, page_number, (), "adapter coverage is missing")
101
+ if not isinstance(raw, list):
102
+ return AdapterPageResult(self.adapter_id, page_number, (), "adapter findings must be an array")
103
+ return AdapterPageResult(
104
+ self.adapter_id,
105
+ page_number,
106
+ tuple(_normalize_finding(item, page_number, self.adapter_id) for item in raw),
107
+ )
108
+
109
+
110
+ class CoreDeterministicAdapter:
111
+ """Run the automation-owned full-page detectors behind the adapter boundary."""
112
+
113
+ adapter_id = "python-hwpx-deterministic"
114
+
115
+ def inspect_page(self, page: Mapping[str, Any]) -> AdapterPageResult:
116
+ number = int(page["page"])
117
+ try:
118
+ from .office.rendering.page_qa import inspect_page_png
119
+
120
+ verdict = inspect_page_png(str(page["absolutePath"]), page=number)
121
+ raw_findings = verdict.to_dict().get("findings", [])
122
+ normalized = []
123
+ for raw in raw_findings:
124
+ evidence = raw.get("evidence", {})
125
+ provenance = raw.get("provenance", {})
126
+ item = _normalize_finding(
127
+ {
128
+ "findingId": raw.get("finding_id"),
129
+ "bbox": list(raw["bbox"].values()) if isinstance(raw.get("bbox"), Mapping) else raw["bbox"],
130
+ "category": raw["category"],
131
+ "severity": raw["severity"],
132
+ "confidence": raw["confidence"],
133
+ "evidenceHash": "sha256:" + evidence.get("crop_sha256", evidence.get("cropSha256", "")),
134
+ "evidenceCrop": evidence.get("crop_bbox", evidence.get("cropBbox")),
135
+ "target": raw.get("target"),
136
+ },
137
+ number,
138
+ self.adapter_id,
139
+ )
140
+ item["provenance"][0].update(
141
+ {
142
+ "detectorId": provenance.get("detector_id", provenance.get("detectorId")),
143
+ "detectorVersion": provenance.get("detector_version", provenance.get("detectorVersion")),
144
+ "kind": provenance.get("kind", "deterministic"),
145
+ "model": provenance.get("model"),
146
+ "details": provenance.get("details", {}),
147
+ }
148
+ )
149
+ normalized.append(item)
150
+ return AdapterPageResult(self.adapter_id, number, tuple(normalized))
151
+ except Exception as exc:
152
+ return AdapterPageResult(self.adapter_id, number, (), f"deterministic detector failed: {exc}")
153
+
154
+
155
+ def _normalized_bbox(value: Any) -> tuple[float, float, float, float]:
156
+ if not isinstance(value, (list, tuple)) or len(value) != 4:
157
+ raise ValueError("finding bbox must be [x0,y0,x1,y1]")
158
+ bbox = tuple(float(part) for part in value)
159
+ if any(part < 0.0 or part > 1.0 for part in bbox) or bbox[0] >= bbox[2] or bbox[1] >= bbox[3]:
160
+ raise ValueError("finding bbox must be normalized and non-empty")
161
+ return bbox # type: ignore[return-value]
162
+
163
+
164
+ def _normalize_finding(raw: Any, page: int, adapter_id: str) -> dict[str, Any]:
165
+ if not isinstance(raw, Mapping):
166
+ raise ValueError("finding must be an object")
167
+ severity = str(raw.get("severity", "warning")).lower()
168
+ if severity not in SEVERITY_ORDER:
169
+ raise ValueError(f"unsupported finding severity: {severity}")
170
+ confidence = float(raw.get("confidence", 1.0))
171
+ if confidence < 0.0 or confidence > 1.0:
172
+ raise ValueError("finding confidence must be between 0 and 1")
173
+ bbox = _normalized_bbox(raw.get("bbox"))
174
+ category = str(raw.get("category", "")).strip()
175
+ if not category:
176
+ raise ValueError("finding category is required")
177
+ evidence_hash = str(raw.get("evidenceHash", "")).strip()
178
+ if not evidence_hash.startswith("sha256:"):
179
+ raise ValueError("finding evidenceHash must be a sha256 digest")
180
+ finding_id = str(raw.get("findingId") or _canonical_hash([page, category, bbox, evidence_hash])[:31])
181
+ result = {
182
+ "findingId": finding_id,
183
+ "page": page,
184
+ "bbox": list(bbox),
185
+ "category": category,
186
+ "severity": severity,
187
+ "confidence": confidence,
188
+ "evidenceHash": evidence_hash,
189
+ "evidenceCrop": raw.get("evidenceCrop"),
190
+ "target": raw.get("target"),
191
+ "repair": raw.get("repair"),
192
+ "provenance": [{"adapterId": adapter_id, "sourceFindingId": finding_id}],
193
+ }
194
+ return result
195
+
196
+
197
+ def _iou(left: Sequence[float], right: Sequence[float]) -> float:
198
+ x0, y0 = max(left[0], right[0]), max(left[1], right[1])
199
+ x1, y1 = min(left[2], right[2]), min(left[3], right[3])
200
+ intersection = max(0.0, x1 - x0) * max(0.0, y1 - y0)
201
+ left_area = (left[2] - left[0]) * (left[3] - left[1])
202
+ right_area = (right[2] - right[0]) * (right[3] - right[1])
203
+ union = left_area + right_area - intersection
204
+ return intersection / union if union else 0.0
205
+
206
+
207
+ def _map_target(finding: dict[str, Any], page: Mapping[str, Any]) -> dict[str, Any] | None:
208
+ if isinstance(finding.get("target"), Mapping):
209
+ return dict(finding["target"])
210
+ candidates = []
211
+ for target in page.get("targetMap", []):
212
+ if not isinstance(target, Mapping) or "bbox" not in target:
213
+ continue
214
+ overlap = _iou(finding["bbox"], _normalized_bbox(target["bbox"]))
215
+ if overlap >= 0.25:
216
+ candidates.append((overlap, target))
217
+ candidates.sort(key=lambda item: item[0], reverse=True)
218
+ if not candidates:
219
+ return None
220
+ # Ambiguous mappings remain unmapped instead of guessing.
221
+ if len(candidates) > 1 and abs(candidates[0][0] - candidates[1][0]) < 0.05:
222
+ return None
223
+ return {key: value for key, value in candidates[0][1].items() if key != "bbox"}
224
+
225
+
226
+ def _fuse(results: Sequence[AdapterPageResult], pages: Mapping[int, Mapping[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
227
+ groups: list[list[dict[str, Any]]] = []
228
+ adapter_ids = sorted({result.adapter_id for result in results})
229
+ for result in results:
230
+ for finding in result.findings:
231
+ match = next(
232
+ (
233
+ group
234
+ for group in groups
235
+ if group[0]["page"] == finding["page"]
236
+ and group[0]["category"] == finding["category"]
237
+ and _iou(group[0]["bbox"], finding["bbox"]) >= 0.5
238
+ ),
239
+ None,
240
+ )
241
+ (match if match is not None else groups.append([finding]))
242
+ if match is not None:
243
+ match.append(finding)
244
+
245
+ fused: list[dict[str, Any]] = []
246
+ disagreements: list[dict[str, Any]] = []
247
+ for group in groups:
248
+ chosen = max(group, key=lambda item: (SEVERITY_ORDER[item["severity"]], item["confidence"]))
249
+ supporters = sorted(item["provenance"][0]["adapterId"] for item in group)
250
+ item = dict(chosen)
251
+ item["provenance"] = [entry for member in group for entry in member["provenance"]]
252
+ item["supportingAdapters"] = supporters
253
+ item["target"] = _map_target(item, pages[item["page"]])
254
+ item["agreement"] = len(supporters) == len(adapter_ids)
255
+ fused.append(item)
256
+ if not item["agreement"]:
257
+ disagreements.append(
258
+ {
259
+ "findingId": item["findingId"],
260
+ "page": item["page"],
261
+ "category": item["category"],
262
+ "supportingAdapters": supporters,
263
+ "nonSupportingAdapters": sorted(set(adapter_ids) - set(supporters)),
264
+ }
265
+ )
266
+ fused.sort(key=lambda item: (item["page"], -SEVERITY_ORDER[item["severity"]], item["findingId"]))
267
+ return fused, disagreements
268
+
269
+
270
+ def load_fixture_manifest(manifest_path: str | Path) -> tuple[Path, dict[str, Any]]:
271
+ path = Path(manifest_path).expanduser().resolve()
272
+ data = json.loads(path.read_text(encoding="utf-8"))
273
+ if data.get("schema") != FIXTURE_MANIFEST_SCHEMA:
274
+ raise ValueError(f"unsupported visual fixture schema: {data.get('schema')!r}")
275
+ if data.get("assurance") != "fixture":
276
+ raise ValueError("fixture manifest assurance must be exactly 'fixture'")
277
+ if not isinstance(data.get("cases"), list) or not data["cases"]:
278
+ raise ValueError("fixture manifest must contain at least one case")
279
+ for case in data["cases"]:
280
+ if not isinstance(case, Mapping) or not isinstance(case.get("pages"), list) or not case["pages"]:
281
+ raise ValueError("every fixture case must contain pages")
282
+ for page in case["pages"]:
283
+ page_path = (path.parent / str(page["path"])).resolve()
284
+ if not page_path.is_relative_to(path.parent) or page_path.suffix.lower() != ".png":
285
+ raise ValueError("fixture pages must be PNG files inside the corpus root")
286
+ actual = hashlib.sha256(page_path.read_bytes()).hexdigest()
287
+ if actual != str(page.get("sha256")):
288
+ raise ValueError(f"fixture page hash mismatch: {page_path.name}")
289
+ return path, data
290
+
291
+
292
+ def _select_case(manifest: Mapping[str, Any], case_id: str | None) -> tuple[dict[str, Any], str]:
293
+ cases = [item for item in manifest["cases"] if isinstance(item, Mapping)]
294
+ if case_id is None:
295
+ if len(cases) != 1:
296
+ raise ValueError("case_id is required when a fixture manifest contains multiple cases")
297
+ selected = cases[0]
298
+ else:
299
+ selected = next((item for item in cases if str(item.get("id")) == case_id), None)
300
+ if selected is None:
301
+ raise ValueError(f"fixture case not found: {case_id}")
302
+ return dict(selected), str(selected["id"])
303
+
304
+
305
+ def _case_rounds(case: Mapping[str, Any]) -> list[dict[str, Any]]:
306
+ return [{"pages": case.get("pages", []), "annotations": case.get("annotations", [])}]
307
+
308
+
309
+ def review_fixture(
310
+ manifest_path: str | Path,
311
+ *,
312
+ case_id: str | None = None,
313
+ round_index: int = 0,
314
+ strict: bool = True,
315
+ runtime_round: Mapping[str, Any] | None = None,
316
+ ) -> dict[str, Any]:
317
+ path, manifest = load_fixture_manifest(manifest_path)
318
+ case, selected_case_id = _select_case(manifest, case_id)
319
+ rounds = [dict(runtime_round)] if runtime_round is not None else _case_rounds(case)
320
+ if round_index < 0 or round_index >= len(rounds):
321
+ raise ValueError(f"fixture round {round_index} is unavailable")
322
+ round_data = rounds[round_index]
323
+ pages_raw = round_data.get("pages", []) if isinstance(round_data, Mapping) else []
324
+ if not isinstance(pages_raw, list):
325
+ raise ValueError("fixture round pages must be an array")
326
+ pages: dict[int, Mapping[str, Any]] = {}
327
+ duplicate_pages: list[int] = []
328
+ for page in pages_raw:
329
+ number = int(page["page"])
330
+ if number in pages:
331
+ duplicate_pages.append(number)
332
+ pages[number] = page
333
+ expected_pages = int(round_data.get("expectedPageCount", case.get("expectedPageCount", len(pages))))
334
+ expected_numbers = set(range(0, expected_pages))
335
+ missing_pages = sorted(expected_numbers - set(pages))
336
+ unexpected_pages = sorted(set(pages) - expected_numbers)
337
+ annotations = round_data.get("annotations", case.get("annotations", []))
338
+ annotations_by_page: dict[int, list[dict[str, Any]]] = {}
339
+ for annotation in annotations if isinstance(annotations, list) else []:
340
+ item = dict(annotation)
341
+ item.setdefault("confidence", 1.0)
342
+ item.setdefault("evidenceHash", "sha256:" + pages[int(item["page"])]["sha256"])
343
+ annotations_by_page.setdefault(int(item["page"]), []).append(item)
344
+ adapter_ids = tuple(str(item) for item in round_data.get("adapters", ()))
345
+ adapter_ids = ("python-hwpx-deterministic", *adapter_ids)
346
+ provider_findings = round_data.get("findingsByAdapter", case.get("findingsByAdapter", {}))
347
+ for number, page in list(pages.items()):
348
+ enriched = dict(page)
349
+ page_path = Path(str(enriched["path"]))
350
+ if not page_path.is_absolute():
351
+ page_path = path.parent / page_path
352
+ page_path = page_path.resolve()
353
+ if not page_path.is_relative_to(path.parent) or page_path.suffix.lower() != ".png":
354
+ raise ValueError("runtime fixture pages must remain PNG files inside the corpus root")
355
+ actual_hash = hashlib.sha256(page_path.read_bytes()).hexdigest()
356
+ if actual_hash != str(enriched.get("sha256")):
357
+ raise ValueError(f"runtime fixture page hash mismatch: {page_path.name}")
358
+ enriched["absolutePath"] = str(page_path)
359
+ enriched["findingsByAdapter"] = {
360
+ adapter_id: (
361
+ provider_findings.get(adapter_id, {}).get(str(number), provider_findings.get(adapter_id, {}).get(number, []))
362
+ if isinstance(provider_findings, Mapping) and adapter_id != "frozen-annotation"
363
+ else annotations_by_page.get(number, [])
364
+ )
365
+ for adapter_id in adapter_ids
366
+ }
367
+ pages[number] = enriched
368
+ adapters: list[VisionAdapter] = [CoreDeterministicAdapter()]
369
+ adapters.extend(DeterministicFixtureAdapter(item) for item in adapter_ids[1:])
370
+ results = [adapter.inspect_page(page) for page in pages.values() for adapter in adapters]
371
+ adapter_errors = [
372
+ {"adapterId": result.adapter_id, "page": result.page, "error": result.error}
373
+ for result in results
374
+ if result.error
375
+ ]
376
+ findings, disagreements = _fuse(results, pages)
377
+ critical = [item for item in findings if item["severity"] == "critical"]
378
+ unmapped_critical = [item["findingId"] for item in critical if item.get("target") is None]
379
+ coverage_complete = not (missing_pages or unexpected_pages or duplicate_pages or adapter_errors)
380
+ fail_reasons: list[str] = []
381
+ if not coverage_complete:
382
+ fail_reasons.append("incomplete_page_or_adapter_coverage")
383
+ if critical:
384
+ fail_reasons.append("critical_findings_present")
385
+ if unmapped_critical:
386
+ fail_reasons.append("unmapped_critical_findings")
387
+ if strict and disagreements:
388
+ fail_reasons.append("adapter_disagreements_require_review")
389
+ receipt = FixtureVisualReviewReceipt(
390
+ manifestHash=_canonical_hash(manifest),
391
+ fixtureSetId=selected_case_id,
392
+ taxonomyVersion=str(manifest.get("taxonomyVersion", "unknown")),
393
+ reviewedAt=datetime.now(timezone.utc).isoformat(),
394
+ expectedPages=expected_pages,
395
+ reviewedPages=tuple(sorted(pages)),
396
+ coverageComplete=coverage_complete,
397
+ adapterIds=adapter_ids,
398
+ ).model_dump(by_alias=True)
399
+ needs_review = bool(fail_reasons)
400
+ return {
401
+ "ok": not needs_review,
402
+ "verdict": "needs_review" if needs_review else "pass",
403
+ "handoffStatus": "needs_review" if needs_review else "ready",
404
+ "verificationStatus": UNVERIFIED_STATUS,
405
+ "renderChecked": False,
406
+ "realHancomVerified": False,
407
+ "visualReviewReceipt": receipt,
408
+ "coverage": {
409
+ "expectedPages": expected_pages,
410
+ "reviewedPages": sorted(pages),
411
+ "missingPages": missing_pages,
412
+ "unexpectedPages": unexpected_pages,
413
+ "duplicatePages": sorted(set(duplicate_pages)),
414
+ "adapterErrors": adapter_errors,
415
+ "complete": coverage_complete,
416
+ },
417
+ "findings": findings,
418
+ "disagreements": disagreements,
419
+ "criticalFindingCount": len(critical),
420
+ "unmappedCriticalFindingIds": unmapped_critical,
421
+ "failReasons": fail_reasons,
422
+ "fixtureRound": round_index,
423
+ "caseId": selected_case_id,
424
+ }
425
+
426
+
427
+ def write_review_artifact(review: Mapping[str, Any], output_dir: str | Path | None) -> str | None:
428
+ if output_dir is None:
429
+ return None
430
+ directory = Path(output_dir).expanduser().resolve()
431
+ directory.mkdir(parents=True, exist_ok=True)
432
+ output = directory / "visual-review-fixture.json"
433
+ output.write_text(json.dumps(review, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
434
+ return str(output)
435
+
436
+
437
+ def review_fixture_evidence(
438
+ manifest_path: str | Path,
439
+ *,
440
+ case_id: str | None,
441
+ adapter_evidence_path: str | Path | None,
442
+ strict: bool,
443
+ ) -> dict[str, Any]:
444
+ if adapter_evidence_path is None:
445
+ return review_fixture(manifest_path, case_id=case_id, strict=strict)
446
+ manifest_file, manifest = load_fixture_manifest(manifest_path)
447
+ _case, selected_case_id = _select_case(manifest, case_id)
448
+ evidence_file = Path(adapter_evidence_path).expanduser().resolve()
449
+ evidence = json.loads(evidence_file.read_text(encoding="utf-8"))
450
+ if evidence.get("schema") != "hwpx.visual-adapter-evidence/v1":
451
+ raise ValueError("unsupported visual adapter evidence schema")
452
+ if str(evidence.get("caseId")) != selected_case_id:
453
+ raise ValueError("adapter evidence caseId does not match selected fixture case")
454
+ if evidence.get("manifestHash") != _canonical_hash(manifest):
455
+ raise ValueError("adapter evidence manifestHash does not match the immutable corpus")
456
+ runtime_round = evidence.get("result")
457
+ if not isinstance(runtime_round, Mapping):
458
+ raise ValueError("adapter evidence result is required")
459
+ return review_fixture(
460
+ manifest_file,
461
+ case_id=selected_case_id,
462
+ strict=strict,
463
+ runtime_round=runtime_round,
464
+ )
465
+
466
+
467
+ def repair_fixture(
468
+ *,
469
+ filename: str | Path,
470
+ manifest_path: str | Path,
471
+ repair_plan_path: str | Path,
472
+ case_id: str | None,
473
+ output_path: str | Path,
474
+ expected_revision: str,
475
+ idempotency_key: str,
476
+ max_rounds: int,
477
+ apply_repair: Callable[[Path, Mapping[str, Any], str, str], Mapping[str, Any]],
478
+ ) -> dict[str, Any]:
479
+ """Run a guarded, allow-listed fixture repair loop with regression rollback."""
480
+
481
+ source = Path(filename).expanduser().resolve()
482
+ output = Path(output_path).expanduser().resolve()
483
+ actual_revision = document_revision(source)
484
+ if actual_revision != expected_revision:
485
+ return {
486
+ "ok": False,
487
+ "verdict": "blocked",
488
+ "handoffStatus": "blocked",
489
+ "reason": "document revision mismatch",
490
+ "expectedRevision": expected_revision,
491
+ "documentRevision": actual_revision,
492
+ "verificationStatus": UNVERIFIED_STATUS,
493
+ "renderChecked": False,
494
+ "realHancomVerified": False,
495
+ "ledger": [],
496
+ }
497
+ if not idempotency_key or len(idempotency_key) < 8:
498
+ raise ValueError("idempotency_key must contain at least 8 characters")
499
+ if max_rounds < 0 or max_rounds > 3:
500
+ raise ValueError("max_rounds must be between 0 and the hard cap of 3")
501
+ if source == output:
502
+ raise ValueError("fixture repair preserves the source; output_path must differ")
503
+ manifest_file, manifest = load_fixture_manifest(manifest_path)
504
+ case, selected_case_id = _select_case(manifest, case_id)
505
+ plan_file = Path(repair_plan_path).expanduser().resolve()
506
+ plan = json.loads(plan_file.read_text(encoding="utf-8"))
507
+ if plan.get("schema") != "hwpx.visual-repair-plan/v1":
508
+ raise ValueError("unsupported visual repair plan schema")
509
+ if str(plan.get("caseId")) != selected_case_id:
510
+ raise ValueError("repair plan caseId does not match selected fixture case")
511
+ if plan.get("manifestHash") != _canonical_hash(manifest):
512
+ raise ValueError("repair plan manifestHash does not match the immutable corpus")
513
+ plan_rounds = plan.get("rounds", [])
514
+ if not isinstance(plan_rounds, list):
515
+ raise ValueError("repair plan rounds must be an array")
516
+ output.parent.mkdir(parents=True, exist_ok=True)
517
+ shutil.copy2(source, output)
518
+ initial_review = plan.get("initialReview")
519
+ current = review_fixture(
520
+ manifest_path,
521
+ case_id=selected_case_id,
522
+ round_index=0,
523
+ strict=True,
524
+ runtime_round=initial_review if isinstance(initial_review, Mapping) else None,
525
+ )
526
+ ledger: list[dict[str, Any]] = []
527
+ rolled_back = False
528
+ applied_keys: set[str] = set()
529
+ available_rounds = len(plan_rounds) + 1
530
+
531
+ for round_number in range(1, min(max_rounds, available_rounds - 1) + 1):
532
+ planned_round = plan_rounds[round_number - 1]
533
+ planned_repairs = planned_round.get("repairs", []) if isinstance(planned_round, Mapping) else []
534
+ by_finding = {
535
+ str(item.get("findingId")): dict(item)
536
+ for item in planned_repairs
537
+ if isinstance(item, Mapping)
538
+ }
539
+ candidates = []
540
+ for finding in current["findings"]:
541
+ repair = by_finding.get(finding["findingId"])
542
+ if repair and finding["severity"] != "critical" and finding.get("target") is not None:
543
+ item = dict(finding)
544
+ item["repair"] = repair
545
+ candidates.append(item)
546
+ if not candidates:
547
+ break
548
+ before_bytes = output.read_bytes()
549
+ round_start_revision = document_revision(output)
550
+ before_revision = round_start_revision
551
+ actions: list[dict[str, Any]] = []
552
+ for index, finding in enumerate(candidates):
553
+ repair = dict(finding["repair"])
554
+ repair_type = str(repair.get("type", ""))
555
+ if repair_type not in _SAFE_REPAIR_TYPES:
556
+ continue
557
+ action_key = _canonical_hash([idempotency_key, finding["findingId"], repair])
558
+ if action_key in applied_keys:
559
+ continue
560
+ result = apply_repair(output, repair, before_revision, f"{idempotency_key}:r{round_number}:{index}")
561
+ if result.get("ok") is False or int(result.get("replaced_count", 0)) < 1:
562
+ actions.append({"findingId": finding["findingId"], "action": repair_type, "applied": False})
563
+ continue
564
+ applied_keys.add(action_key)
565
+ before_revision = document_revision(output)
566
+ actions.append({"findingId": finding["findingId"], "action": repair_type, "applied": True})
567
+ if not any(action["applied"] for action in actions):
568
+ ledger.append(
569
+ {
570
+ "round": round_number,
571
+ "status": "no_effect",
572
+ "beforeRevision": round_start_revision,
573
+ "afterRevision": document_revision(output),
574
+ "actions": actions,
575
+ }
576
+ )
577
+ break
578
+ rerender = planned_round.get("rerender") if isinstance(planned_round, Mapping) else None
579
+ if not isinstance(rerender, Mapping):
580
+ raise ValueError("every repair round requires deterministic fixture rerender evidence")
581
+ candidate_review = review_fixture(
582
+ manifest_file,
583
+ case_id=selected_case_id,
584
+ round_index=0,
585
+ strict=True,
586
+ runtime_round=rerender,
587
+ )
588
+ old_keys = {
589
+ (item["findingId"], item["page"], item["category"], item["severity"])
590
+ for item in current["findings"]
591
+ }
592
+ new_findings = [
593
+ item
594
+ for item in candidate_review["findings"]
595
+ if (item["findingId"], item["page"], item["category"], item["severity"]) not in old_keys
596
+ ]
597
+ regression = bool(new_findings) or candidate_review["criticalFindingCount"] > current["criticalFindingCount"]
598
+ if regression:
599
+ attempted_revision = document_revision(output)
600
+ output.write_bytes(before_bytes)
601
+ rolled_back = True
602
+ ledger.append(
603
+ {
604
+ "round": round_number,
605
+ "status": "rolled_back",
606
+ "beforeRevision": round_start_revision,
607
+ "attemptedRevision": attempted_revision,
608
+ "afterRevision": document_revision(output),
609
+ "actions": actions,
610
+ "regressionFindingIds": [item["findingId"] for item in new_findings],
611
+ }
612
+ )
613
+ break
614
+ after_revision = document_revision(output)
615
+ ledger.append(
616
+ {
617
+ "round": round_number,
618
+ "status": "accepted",
619
+ "beforeRevision": round_start_revision,
620
+ "afterRevision": after_revision,
621
+ "actions": actions,
622
+ "fixtureRound": round_number,
623
+ "repairPlanHash": _canonical_hash(plan),
624
+ }
625
+ )
626
+ current = candidate_review
627
+
628
+ current = dict(current)
629
+ current.update(
630
+ {
631
+ "ledger": ledger,
632
+ "repairLedger": {
633
+ "schema": "hwpx.visual-repair-ledger/v1",
634
+ "manifestSchema": FIXTURE_MANIFEST_SCHEMA,
635
+ "manifestHash": _canonical_hash(manifest),
636
+ "caseId": selected_case_id,
637
+ "pageHashes": [page.get("sha256") for page in case.get("pages", [])],
638
+ "entries": ledger,
639
+ },
640
+ "rolledBack": rolled_back,
641
+ "outputPath": str(output),
642
+ "originalRevision": actual_revision,
643
+ "documentRevision": document_revision(output),
644
+ "repairRounds": len(ledger),
645
+ "renderChecked": False,
646
+ "realHancomVerified": False,
647
+ "verificationStatus": UNVERIFIED_STATUS,
648
+ }
649
+ )
650
+ # Any remaining finding must be reviewed. Fixture evidence is never a visual pass
651
+ # suitable for real-Hancom completion even when the deterministic set is clean.
652
+ if current["findings"]:
653
+ current["ok"] = False
654
+ current["verdict"] = "needs_review"
655
+ current["handoffStatus"] = "needs_review"
656
+ return current
657
+
658
+
659
+ __all__ = [
660
+ "DeterministicFixtureAdapter",
661
+ "FixtureVisualReviewReceipt",
662
+ "VisionAdapter",
663
+ "repair_fixture",
664
+ "review_fixture",
665
+ "review_fixture_evidence",
666
+ "write_review_artifact",
667
+ ]