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,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
|
+
]
|