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,241 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Frozen-corpus metrics for deterministic visual QA."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .fixture_corpus import FixtureCorpus
|
|
11
|
+
from .page_qa import inspect_fixture_case
|
|
12
|
+
from .qa_contracts import (
|
|
13
|
+
TAXONOMY_VERSION,
|
|
14
|
+
DefectCategory,
|
|
15
|
+
FindingSeverity,
|
|
16
|
+
VerdictStatus,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DEFAULT_MINIMUM_CATEGORY_SAMPLES = 5
|
|
21
|
+
GATE_THRESHOLDS = {
|
|
22
|
+
"criticalRecall": 0.95,
|
|
23
|
+
"criticalPrecision": 0.90,
|
|
24
|
+
"defectFalseAcceptanceRate": 0.01,
|
|
25
|
+
"cleanFalseRejectionRate": 0.01,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _wilson(successes: int, total: int, z: float = 1.959963984540054) -> list[float] | None:
|
|
30
|
+
if total <= 0:
|
|
31
|
+
return None
|
|
32
|
+
p = successes / total
|
|
33
|
+
denominator = 1 + z * z / total
|
|
34
|
+
center = (p + z * z / (2 * total)) / denominator
|
|
35
|
+
margin = z * math.sqrt((p * (1 - p) + z * z / (4 * total)) / total) / denominator
|
|
36
|
+
return [max(0.0, center - margin), min(1.0, center + margin)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _required_category_contract(
|
|
40
|
+
required_categories: Iterable[DefectCategory | str] | None,
|
|
41
|
+
provenance: Mapping[str, Any] | None,
|
|
42
|
+
) -> tuple[tuple[str, ...], dict[str, Any]]:
|
|
43
|
+
taxonomy = tuple(category.value for category in DefectCategory)
|
|
44
|
+
if required_categories is None:
|
|
45
|
+
return taxonomy, {
|
|
46
|
+
"mode": "full_taxonomy",
|
|
47
|
+
"taxonomyVersion": TAXONOMY_VERSION,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
normalized: list[str] = []
|
|
51
|
+
for category in required_categories:
|
|
52
|
+
value = category.value if isinstance(category, DefectCategory) else str(category)
|
|
53
|
+
if value not in taxonomy:
|
|
54
|
+
raise ValueError(f"unsupported required visual-QA category: {value!r}")
|
|
55
|
+
if value not in normalized:
|
|
56
|
+
normalized.append(value)
|
|
57
|
+
if not normalized:
|
|
58
|
+
raise ValueError("required visual-QA categories cannot be empty")
|
|
59
|
+
|
|
60
|
+
narrowed = set(normalized) != set(taxonomy)
|
|
61
|
+
provenance_copy = dict(provenance or {})
|
|
62
|
+
if narrowed:
|
|
63
|
+
missing = [key for key in ("reason", "source") if not provenance_copy.get(key)]
|
|
64
|
+
if missing:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
"narrowed required visual-QA categories require provenance fields: "
|
|
67
|
+
+ ", ".join(missing)
|
|
68
|
+
)
|
|
69
|
+
provenance_copy["mode"] = "explicitly_narrowed"
|
|
70
|
+
else:
|
|
71
|
+
provenance_copy.setdefault("mode", "explicit_full_taxonomy")
|
|
72
|
+
provenance_copy.setdefault("taxonomyVersion", TAXONOMY_VERSION)
|
|
73
|
+
return tuple(normalized), provenance_copy
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def measure_fixture_corpus(
|
|
77
|
+
corpus: FixtureCorpus,
|
|
78
|
+
*,
|
|
79
|
+
required_categories: Iterable[DefectCategory | str] | None = None,
|
|
80
|
+
minimum_category_samples: int = DEFAULT_MINIMUM_CATEGORY_SAMPLES,
|
|
81
|
+
required_categories_provenance: Mapping[str, Any] | None = None,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
if minimum_category_samples < 1:
|
|
84
|
+
raise ValueError("minimum_category_samples must be at least 1")
|
|
85
|
+
required, scope_provenance = _required_category_contract(
|
|
86
|
+
required_categories,
|
|
87
|
+
required_categories_provenance,
|
|
88
|
+
)
|
|
89
|
+
expected: set[tuple[str, int, str]] = set()
|
|
90
|
+
predicted: set[tuple[str, int, str]] = set()
|
|
91
|
+
per_category: dict[str, dict[str, int]] = defaultdict(lambda: {"tp": 0, "fp": 0, "fn": 0})
|
|
92
|
+
clean_total = clean_rejected = defect_total = defect_accepted = 0
|
|
93
|
+
case_rows: list[dict[str, Any]] = []
|
|
94
|
+
|
|
95
|
+
for case in corpus.cases:
|
|
96
|
+
if any(annotation.label_status != "adjudicated" for annotation in case.annotations):
|
|
97
|
+
raise ValueError(f"case {case.case_id} has non-adjudicated annotations")
|
|
98
|
+
verdict = inspect_fixture_case(case)
|
|
99
|
+
expected_case = {
|
|
100
|
+
(case.case_id, item.page, item.category.value)
|
|
101
|
+
for item in case.annotations
|
|
102
|
+
if item.severity is FindingSeverity.CRITICAL
|
|
103
|
+
}
|
|
104
|
+
predicted_case = {
|
|
105
|
+
(case.case_id, item.page, item.category.value)
|
|
106
|
+
for item in verdict.findings
|
|
107
|
+
if item.severity is FindingSeverity.CRITICAL
|
|
108
|
+
}
|
|
109
|
+
expected.update(expected_case)
|
|
110
|
+
predicted.update(predicted_case)
|
|
111
|
+
if case.classification == "clean":
|
|
112
|
+
clean_total += 1
|
|
113
|
+
clean_rejected += int(verdict.status is not VerdictStatus.PASS)
|
|
114
|
+
else:
|
|
115
|
+
defect_total += 1
|
|
116
|
+
defect_accepted += int(verdict.status is VerdictStatus.PASS)
|
|
117
|
+
case_rows.append({
|
|
118
|
+
"caseId": case.case_id,
|
|
119
|
+
"classification": case.classification,
|
|
120
|
+
"status": verdict.status.value,
|
|
121
|
+
"renderChecked": verdict.render_checked,
|
|
122
|
+
"expectedCritical": len(expected_case),
|
|
123
|
+
"predictedCritical": len(predicted_case),
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
true_positive = expected & predicted
|
|
127
|
+
false_positive = predicted - expected
|
|
128
|
+
false_negative = expected - predicted
|
|
129
|
+
for _case, _page, category in true_positive:
|
|
130
|
+
per_category[category]["tp"] += 1
|
|
131
|
+
for _case, _page, category in false_positive:
|
|
132
|
+
per_category[category]["fp"] += 1
|
|
133
|
+
for _case, _page, category in false_negative:
|
|
134
|
+
per_category[category]["fn"] += 1
|
|
135
|
+
|
|
136
|
+
recall = len(true_positive) / len(expected) if expected else None
|
|
137
|
+
precision = len(true_positive) / len(predicted) if predicted else None
|
|
138
|
+
defect_false_acceptance = defect_accepted / defect_total if defect_total else None
|
|
139
|
+
clean_false_rejection = clean_rejected / clean_total if clean_total else None
|
|
140
|
+
category_report: dict[str, dict[str, Any]] = {}
|
|
141
|
+
for category in DefectCategory:
|
|
142
|
+
counts = per_category[category.value]
|
|
143
|
+
expected_total = counts["tp"] + counts["fn"]
|
|
144
|
+
predicted_total = counts["tp"] + counts["fp"]
|
|
145
|
+
recall_ci = _wilson(counts["tp"], expected_total)
|
|
146
|
+
precision_ci = _wilson(counts["tp"], predicted_total)
|
|
147
|
+
is_required = category.value in required
|
|
148
|
+
category_report[category.value] = {
|
|
149
|
+
**counts,
|
|
150
|
+
"recall": counts["tp"] / expected_total if expected_total else None,
|
|
151
|
+
"recall95CI": recall_ci,
|
|
152
|
+
"precision": counts["tp"] / predicted_total if predicted_total else None,
|
|
153
|
+
"precision95CI": precision_ci,
|
|
154
|
+
"required": is_required,
|
|
155
|
+
"minimumSamples": minimum_category_samples if is_required else None,
|
|
156
|
+
"sampleSufficient": bool(
|
|
157
|
+
not is_required or expected_total >= minimum_category_samples
|
|
158
|
+
),
|
|
159
|
+
"recallLowerBoundPassed": bool(
|
|
160
|
+
not is_required
|
|
161
|
+
or (recall_ci is not None and recall_ci[0] >= GATE_THRESHOLDS["criticalRecall"])
|
|
162
|
+
),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
recall_ci = _wilson(len(true_positive), len(expected))
|
|
166
|
+
precision_ci = _wilson(len(true_positive), len(predicted))
|
|
167
|
+
defect_false_acceptance_ci = _wilson(defect_accepted, defect_total)
|
|
168
|
+
clean_false_rejection_ci = _wilson(clean_rejected, clean_total)
|
|
169
|
+
required_coverage_passed = all(
|
|
170
|
+
category_report[category]["sampleSufficient"] for category in required
|
|
171
|
+
)
|
|
172
|
+
required_recall_bounds_passed = all(
|
|
173
|
+
category_report[category]["recallLowerBoundPassed"] for category in required
|
|
174
|
+
)
|
|
175
|
+
aggregate_bounds = {
|
|
176
|
+
"criticalRecallLower": bool(
|
|
177
|
+
recall_ci is not None and recall_ci[0] >= GATE_THRESHOLDS["criticalRecall"]
|
|
178
|
+
),
|
|
179
|
+
"criticalPrecisionLower": bool(
|
|
180
|
+
precision_ci is not None
|
|
181
|
+
and precision_ci[0] >= GATE_THRESHOLDS["criticalPrecision"]
|
|
182
|
+
),
|
|
183
|
+
"defectFalseAcceptanceUpper": bool(
|
|
184
|
+
defect_false_acceptance_ci is not None
|
|
185
|
+
and defect_false_acceptance_ci[1]
|
|
186
|
+
<= GATE_THRESHOLDS["defectFalseAcceptanceRate"]
|
|
187
|
+
),
|
|
188
|
+
"cleanFalseRejectionUpper": bool(
|
|
189
|
+
clean_false_rejection_ci is not None
|
|
190
|
+
and clean_false_rejection_ci[1]
|
|
191
|
+
<= GATE_THRESHOLDS["cleanFalseRejectionRate"]
|
|
192
|
+
),
|
|
193
|
+
}
|
|
194
|
+
failed_requirements: list[str] = []
|
|
195
|
+
for category in required:
|
|
196
|
+
if not category_report[category]["sampleSufficient"]:
|
|
197
|
+
failed_requirements.append(f"category:{category}:minimum_samples")
|
|
198
|
+
elif not category_report[category]["recallLowerBoundPassed"]:
|
|
199
|
+
failed_requirements.append(f"category:{category}:recall_lower_bound")
|
|
200
|
+
failed_requirements.extend(
|
|
201
|
+
f"aggregate:{name}" for name, passed in aggregate_bounds.items() if not passed
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
"schema": "hwpx.visual-qa-metrics/v2",
|
|
206
|
+
"assurance": "fixture",
|
|
207
|
+
"renderChecked": False,
|
|
208
|
+
"counts": {
|
|
209
|
+
"cases": len(corpus.cases), "expectedCritical": len(expected),
|
|
210
|
+
"predictedCritical": len(predicted), "truePositive": len(true_positive),
|
|
211
|
+
"falsePositive": len(false_positive), "falseNegative": len(false_negative),
|
|
212
|
+
},
|
|
213
|
+
"criticalRecall": recall,
|
|
214
|
+
"criticalRecall95CI": recall_ci,
|
|
215
|
+
"criticalPrecision": precision,
|
|
216
|
+
"criticalPrecision95CI": precision_ci,
|
|
217
|
+
"defectFalseAcceptanceRate": defect_false_acceptance,
|
|
218
|
+
"defectFalseAcceptance95CI": defect_false_acceptance_ci,
|
|
219
|
+
"cleanFalseRejectionRate": clean_false_rejection,
|
|
220
|
+
"cleanFalseRejection95CI": clean_false_rejection_ci,
|
|
221
|
+
"gateContract": {
|
|
222
|
+
"requiredCategories": list(required),
|
|
223
|
+
"minimumCategorySamples": minimum_category_samples,
|
|
224
|
+
"scopeProvenance": scope_provenance,
|
|
225
|
+
"requiredCoveragePassed": required_coverage_passed,
|
|
226
|
+
"requiredRecallBoundsPassed": required_recall_bounds_passed,
|
|
227
|
+
"aggregateBounds": aggregate_bounds,
|
|
228
|
+
"failedRequirements": failed_requirements,
|
|
229
|
+
},
|
|
230
|
+
"thresholds": dict(GATE_THRESHOLDS),
|
|
231
|
+
"gatePassed": not failed_requirements,
|
|
232
|
+
"perCategory": category_report,
|
|
233
|
+
"cases": case_rows,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
__all__ = [
|
|
238
|
+
"DEFAULT_MINIMUM_CATEGORY_SAMPLES",
|
|
239
|
+
"GATE_THRESHOLDS",
|
|
240
|
+
"measure_fixture_corpus",
|
|
241
|
+
]
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Serialized, supervised real-Hancom render worker primitives."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, Protocol
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
WORKER_SCHEMA_VERSION = "hwpx.hancom-worker.v1"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sha256_file(path: Path) -> str:
|
|
22
|
+
digest = hashlib.sha256()
|
|
23
|
+
with path.open("rb") as handle:
|
|
24
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
25
|
+
digest.update(chunk)
|
|
26
|
+
return "sha256:" + digest.hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class WorkerJob:
|
|
31
|
+
job_id: str
|
|
32
|
+
input_path: Path
|
|
33
|
+
input_content_hash: str
|
|
34
|
+
dpi: int = 144
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class WorkerArtifact:
|
|
39
|
+
kind: str
|
|
40
|
+
content_hash: str
|
|
41
|
+
size_bytes: int
|
|
42
|
+
relative_path: str
|
|
43
|
+
page_number: int | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class WorkerResult:
|
|
48
|
+
schema_version: str
|
|
49
|
+
job_id: str
|
|
50
|
+
status: str
|
|
51
|
+
real_hancom: bool
|
|
52
|
+
render_checked: bool
|
|
53
|
+
worker_version: str
|
|
54
|
+
hancom_build: str | None
|
|
55
|
+
session_generation: int
|
|
56
|
+
retryable: bool
|
|
57
|
+
terminal_reason: str
|
|
58
|
+
artifacts: tuple[WorkerArtifact, ...] = field(default_factory=tuple)
|
|
59
|
+
page_count: int = 0
|
|
60
|
+
|
|
61
|
+
def to_json(self) -> str:
|
|
62
|
+
return json.dumps(asdict(self), ensure_ascii=False, sort_keys=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RenderSession(Protocol):
|
|
66
|
+
real_hancom: bool
|
|
67
|
+
hancom_build: str | None
|
|
68
|
+
|
|
69
|
+
def render_pdf(self, source: Path, target: Path) -> Path | None: ...
|
|
70
|
+
def abort(self) -> None: ...
|
|
71
|
+
def close(self) -> None: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PowerShellHancomSession:
|
|
75
|
+
"""One-at-a-time Windows COM adapter with a killable PowerShell boundary."""
|
|
76
|
+
|
|
77
|
+
real_hancom = True
|
|
78
|
+
|
|
79
|
+
def __init__(self, *, hancom_build: str, powershell: str = "powershell") -> None:
|
|
80
|
+
if not hancom_build:
|
|
81
|
+
raise ValueError("hancom_build is required for real-Hancom provenance")
|
|
82
|
+
self.hancom_build = hancom_build
|
|
83
|
+
self.powershell = powershell
|
|
84
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
85
|
+
self._guard = threading.Lock()
|
|
86
|
+
|
|
87
|
+
def render_pdf(self, source: Path, target: Path) -> Path | None:
|
|
88
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
work = target.parent
|
|
90
|
+
jobs = work / "jobs.json"
|
|
91
|
+
result = work / "result.json"
|
|
92
|
+
jobs.write_text(json.dumps([{"src": str(source.resolve()), "pdf": str(target.resolve())}]), encoding="utf-8")
|
|
93
|
+
script = resources.files("hwpx_automation.office.rendering").joinpath(
|
|
94
|
+
"_render_hwpx.ps1"
|
|
95
|
+
)
|
|
96
|
+
with resources.as_file(script) as ps1:
|
|
97
|
+
command = [
|
|
98
|
+
self.powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
|
|
99
|
+
"-File", str(ps1), "-Jobs", str(jobs), "-ResultPath", str(result),
|
|
100
|
+
]
|
|
101
|
+
with self._guard:
|
|
102
|
+
self._process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
103
|
+
try:
|
|
104
|
+
return_code = self._process.wait()
|
|
105
|
+
finally:
|
|
106
|
+
with self._guard:
|
|
107
|
+
self._process = None
|
|
108
|
+
if return_code != 0 or not target.is_file():
|
|
109
|
+
return None
|
|
110
|
+
return target
|
|
111
|
+
|
|
112
|
+
def abort(self) -> None:
|
|
113
|
+
with self._guard:
|
|
114
|
+
process = self._process
|
|
115
|
+
if process and process.poll() is None:
|
|
116
|
+
process.kill()
|
|
117
|
+
try:
|
|
118
|
+
process.wait(timeout=10)
|
|
119
|
+
except subprocess.TimeoutExpired:
|
|
120
|
+
process.terminate()
|
|
121
|
+
|
|
122
|
+
def close(self) -> None:
|
|
123
|
+
self.abort()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class DeterministicFakeSession:
|
|
127
|
+
"""Contract-faithful CI renderer that can never claim real Hancom."""
|
|
128
|
+
|
|
129
|
+
real_hancom = False
|
|
130
|
+
hancom_build = None
|
|
131
|
+
|
|
132
|
+
def __init__(self) -> None:
|
|
133
|
+
self.aborted = False
|
|
134
|
+
|
|
135
|
+
def render_pdf(self, source: Path, target: Path) -> Path | None:
|
|
136
|
+
target.write_bytes(b"%PDF-FAKE\n" + source.read_bytes()[:64])
|
|
137
|
+
return target
|
|
138
|
+
|
|
139
|
+
def abort(self) -> None:
|
|
140
|
+
self.aborted = True
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SerializedHancomWorker:
|
|
147
|
+
"""Single-flight worker; a timeout poisons and replaces the render session."""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
root: Path,
|
|
152
|
+
*,
|
|
153
|
+
session_factory: Callable[[], RenderSession],
|
|
154
|
+
worker_version: str,
|
|
155
|
+
timeout_seconds: float = 300,
|
|
156
|
+
abort_grace_seconds: float = 10,
|
|
157
|
+
) -> None:
|
|
158
|
+
self.root = root.resolve()
|
|
159
|
+
self.sandboxes = self.root / "sandboxes"
|
|
160
|
+
self.artifacts = self.root / "artifacts"
|
|
161
|
+
self.sandboxes.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
self.artifacts.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
self.session_factory = session_factory
|
|
164
|
+
self.worker_version = worker_version
|
|
165
|
+
self.timeout_seconds = timeout_seconds
|
|
166
|
+
self.abort_grace_seconds = abort_grace_seconds
|
|
167
|
+
self._lock = threading.Lock()
|
|
168
|
+
self._session: RenderSession | None = None
|
|
169
|
+
self._generation = 0
|
|
170
|
+
self._poisoned_thread: threading.Thread | None = None
|
|
171
|
+
|
|
172
|
+
def _new_session(self) -> RenderSession:
|
|
173
|
+
self._generation += 1
|
|
174
|
+
self._session = self.session_factory()
|
|
175
|
+
return self._session
|
|
176
|
+
|
|
177
|
+
def _restart_session(self) -> None:
|
|
178
|
+
if self._session:
|
|
179
|
+
self._session.close()
|
|
180
|
+
self._session = None
|
|
181
|
+
|
|
182
|
+
def render(self, job: WorkerJob) -> WorkerResult:
|
|
183
|
+
if not self._lock.acquire(blocking=False):
|
|
184
|
+
return self._failure(job, "WORKER_BUSY", retryable=True)
|
|
185
|
+
try:
|
|
186
|
+
return self._render_locked(job)
|
|
187
|
+
finally:
|
|
188
|
+
self._lock.release()
|
|
189
|
+
|
|
190
|
+
def _render_locked(self, job: WorkerJob) -> WorkerResult:
|
|
191
|
+
if self._poisoned_thread is not None:
|
|
192
|
+
if self._poisoned_thread.is_alive():
|
|
193
|
+
return self._failure(job, "POISONED_SESSION_STILL_RUNNING", retryable=True)
|
|
194
|
+
self._poisoned_thread = None
|
|
195
|
+
self._restart_session()
|
|
196
|
+
actual = sha256_file(job.input_path)
|
|
197
|
+
if actual != job.input_content_hash:
|
|
198
|
+
return self._failure(job, "INPUT_HASH_MISMATCH", retryable=False)
|
|
199
|
+
sandbox = Path(tempfile.mkdtemp(prefix=f"{job.job_id}-", dir=self.sandboxes))
|
|
200
|
+
try:
|
|
201
|
+
source = sandbox / "input.hwpx"
|
|
202
|
+
shutil.copyfile(job.input_path, source)
|
|
203
|
+
pdf = sandbox / "output.pdf"
|
|
204
|
+
session = self._session or self._new_session()
|
|
205
|
+
outcome: dict[str, object] = {}
|
|
206
|
+
|
|
207
|
+
def invoke() -> None:
|
|
208
|
+
try:
|
|
209
|
+
outcome["pdf"] = session.render_pdf(source, pdf)
|
|
210
|
+
except BaseException as exc: # contained at the worker boundary
|
|
211
|
+
outcome["error"] = type(exc).__name__
|
|
212
|
+
|
|
213
|
+
thread = threading.Thread(target=invoke, name=f"hancom-{job.job_id}", daemon=True)
|
|
214
|
+
thread.start()
|
|
215
|
+
thread.join(self.timeout_seconds)
|
|
216
|
+
if thread.is_alive():
|
|
217
|
+
session.abort()
|
|
218
|
+
thread.join(self.abort_grace_seconds)
|
|
219
|
+
if thread.is_alive():
|
|
220
|
+
self._poisoned_thread = thread
|
|
221
|
+
else:
|
|
222
|
+
self._restart_session()
|
|
223
|
+
return self._failure(job, "COM_WATCHDOG_TIMEOUT", retryable=True)
|
|
224
|
+
if "error" in outcome or not outcome.get("pdf") or not pdf.is_file():
|
|
225
|
+
self._restart_session()
|
|
226
|
+
return self._failure(job, "HANCOM_RENDER_FAILED", retryable=True)
|
|
227
|
+
|
|
228
|
+
destination = self.artifacts / job.job_id
|
|
229
|
+
if destination.exists():
|
|
230
|
+
shutil.rmtree(destination)
|
|
231
|
+
destination.mkdir(parents=True)
|
|
232
|
+
final_pdf = destination / "document.pdf"
|
|
233
|
+
shutil.copyfile(pdf, final_pdf)
|
|
234
|
+
try:
|
|
235
|
+
pages = self._rasterize(final_pdf, destination, job.dpi)
|
|
236
|
+
except Exception:
|
|
237
|
+
shutil.rmtree(destination, ignore_errors=True)
|
|
238
|
+
return self._failure(job, "PAGE_RASTERIZE_FAILED", retryable=True)
|
|
239
|
+
artifacts = [
|
|
240
|
+
WorkerArtifact("pdf", sha256_file(final_pdf), final_pdf.stat().st_size, f"{job.job_id}/document.pdf")
|
|
241
|
+
]
|
|
242
|
+
artifacts.extend(
|
|
243
|
+
WorkerArtifact("page_png", sha256_file(page), page.stat().st_size, f"{job.job_id}/{page.name}", index)
|
|
244
|
+
for index, page in enumerate(pages, 1)
|
|
245
|
+
)
|
|
246
|
+
real = bool(session.real_hancom and session.hancom_build)
|
|
247
|
+
return WorkerResult(
|
|
248
|
+
WORKER_SCHEMA_VERSION, job.job_id, "succeeded", real, real,
|
|
249
|
+
self.worker_version, session.hancom_build, self._generation, False,
|
|
250
|
+
"SUCCEEDED" if real else "FAKE_RENDER_ONLY", tuple(artifacts), len(pages),
|
|
251
|
+
)
|
|
252
|
+
finally:
|
|
253
|
+
shutil.rmtree(sandbox, ignore_errors=True)
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def _rasterize(pdf: Path, destination: Path, dpi: int) -> list[Path]:
|
|
257
|
+
try:
|
|
258
|
+
import fitz
|
|
259
|
+
except ImportError as exc:
|
|
260
|
+
raise RuntimeError("PyMuPDF is required for page PNG output") from exc
|
|
261
|
+
document = fitz.open(pdf)
|
|
262
|
+
pages: list[Path] = []
|
|
263
|
+
try:
|
|
264
|
+
scale = dpi / 72
|
|
265
|
+
for page_index in range(document.page_count):
|
|
266
|
+
page = document.load_page(page_index)
|
|
267
|
+
target = destination / f"page-{page_index + 1:04d}.png"
|
|
268
|
+
page.get_pixmap(matrix=fitz.Matrix(scale, scale), alpha=False).save(target)
|
|
269
|
+
pages.append(target)
|
|
270
|
+
finally:
|
|
271
|
+
document.close()
|
|
272
|
+
return pages
|
|
273
|
+
|
|
274
|
+
def _failure(self, job: WorkerJob, reason: str, *, retryable: bool) -> WorkerResult:
|
|
275
|
+
session = self._session
|
|
276
|
+
return WorkerResult(
|
|
277
|
+
WORKER_SCHEMA_VERSION, job.job_id, "unverified", False, False,
|
|
278
|
+
self.worker_version, session.hancom_build if session else None,
|
|
279
|
+
self._generation, retryable, reason,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def close(self) -> None:
|
|
283
|
+
with self._lock:
|
|
284
|
+
self._restart_session()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
__all__ = [
|
|
288
|
+
"DeterministicFakeSession", "PowerShellHancomSession", "SerializedHancomWorker",
|
|
289
|
+
"WorkerArtifact", "WorkerJob", "WorkerResult", "WORKER_SCHEMA_VERSION", "sha256_file",
|
|
290
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Canonical application utilities unrelated to HWPX format semantics."""
|
|
3
|
+
|
|
4
|
+
from .table_compute import (
|
|
5
|
+
TABLE_COMPUTE_REPORT_VERSION,
|
|
6
|
+
table_compute,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"TABLE_COMPUTE_REPORT_VERSION",
|
|
11
|
+
"table_compute",
|
|
12
|
+
]
|