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