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,388 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """PII detection, masking, and structural privacy helpers.
3
+
4
+ The machine-checkable set is intentionally always enabled by default. Contextual
5
+ patterns are label-gated and reported as low-confidence to avoid broad free-text
6
+ over-masking.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import logging
12
+ import re
13
+ from collections.abc import Iterable, Mapping, Sized
14
+ from dataclasses import dataclass
15
+ from typing import Any, Literal, TypedDict
16
+
17
+ Confidence = Literal["high", "low"]
18
+
19
+
20
+ class PIISpan(TypedDict):
21
+ type: str
22
+ start: int
23
+ end: int
24
+ value: str
25
+ confidence: Confidence
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class PIIPolicy:
30
+ """Controls PII detection and masking behavior."""
31
+
32
+ mask_char: str = "*"
33
+ machine_enabled: bool = True
34
+ contextual_enabled: bool = True
35
+ prefix_len: int = 1
36
+ suffix_len: int = 0
37
+
38
+ def __post_init__(self) -> None:
39
+ if len(self.mask_char) != 1:
40
+ raise ValueError("mask_char must be exactly one character")
41
+ if self.prefix_len < 0 or self.suffix_len < 0:
42
+ raise ValueError("prefix_len and suffix_len must be non-negative")
43
+
44
+
45
+ DEFAULT_POLICY = PIIPolicy()
46
+
47
+ __all__ = [
48
+ "Confidence",
49
+ "DEFAULT_POLICY",
50
+ "PIIPolicy",
51
+ "PIISpan",
52
+ "PiiLogFilter",
53
+ "Pseudonymizer",
54
+ "deidentify",
55
+ "detect_pii",
56
+ "mask_pii",
57
+ "mask_value",
58
+ "minimize_fields",
59
+ "scrub_exception_message",
60
+ ]
61
+
62
+ _RRN_RE = re.compile(r"(?<!\d)(?P<front>\d{6})-?(?P<gender>[1-4])(?P<rear>\d{6})(?!\d)")
63
+ _PHONE_RE = re.compile(
64
+ r"(?<!\d)(?P<prefix>010|011|016|017|018|019)-?(?P<middle>\d{3,4})-?(?P<last>\d{4})(?!\d)"
65
+ )
66
+ _EMAIL_RE = re.compile(
67
+ r"(?<![A-Za-z0-9.!#$%&'*+/=?^_`{|}~-])"
68
+ r"(?P<local>[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+)"
69
+ r"@"
70
+ r"(?P<domain>[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
71
+ r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+)"
72
+ r"(?![A-Za-z0-9-])"
73
+ )
74
+ _CARD_RE = re.compile(r"(?<!\d)(?:\d{4}[- ]?){3}\d{4}(?!\d)")
75
+
76
+ _ACCOUNT_RE = re.compile(
77
+ r"(?:계좌(?:번호)?|통장(?:번호)?)[^\d\n]{0,12}(?P<value>\d[\d-]{8,20}\d)"
78
+ )
79
+ _REGIONS = (
80
+ "서울특별시",
81
+ "부산광역시",
82
+ "대구광역시",
83
+ "인천광역시",
84
+ "광주광역시",
85
+ "대전광역시",
86
+ "울산광역시",
87
+ "세종특별자치시",
88
+ "경기도",
89
+ "강원도",
90
+ "충청북도",
91
+ "충청남도",
92
+ "전라북도",
93
+ "전라남도",
94
+ "경상북도",
95
+ "경상남도",
96
+ "제주특별자치도",
97
+ "서울",
98
+ "부산",
99
+ "대구",
100
+ "인천",
101
+ "광주",
102
+ "대전",
103
+ "울산",
104
+ "세종",
105
+ "경기",
106
+ "강원",
107
+ "충북",
108
+ "충남",
109
+ "전북",
110
+ "전남",
111
+ "경북",
112
+ "경남",
113
+ "제주",
114
+ )
115
+ _REGION_RE = "|".join(re.escape(region) for region in _REGIONS)
116
+ _ADDRESS_RE = re.compile(
117
+ rf"(?:주소|도로명주소|소재지|거주지)\s*[::]?\s*(?P<value>(?:{_REGION_RE})[^\n,;]{{2,80}})"
118
+ )
119
+ _NAME_RE = re.compile(r"(?:성명|수신자|이름)\s*[::]\s*(?P<value>[가-힣]{2,4})")
120
+
121
+ _TYPE_ALIASES = {
122
+ "resident_registration_number": "rrn",
123
+ "registration_number": "rrn",
124
+ "jumin": "rrn",
125
+ "주민등록번호": "rrn",
126
+ "휴대폰": "phone",
127
+ "전화번호": "phone",
128
+ "mobile": "phone",
129
+ "email_address": "email",
130
+ "이메일": "email",
131
+ "card_number": "card",
132
+ "credit_card": "card",
133
+ "카드": "card",
134
+ "account_number": "account",
135
+ "계좌번호": "account",
136
+ "계좌": "account",
137
+ "주소": "address",
138
+ "이름": "name",
139
+ "성명": "name",
140
+ "수신자": "name",
141
+ }
142
+
143
+
144
+ def detect_pii(text: str, policy: PIIPolicy = DEFAULT_POLICY) -> list[PIISpan]:
145
+ """Detect PII spans in ``text`` according to ``policy``."""
146
+
147
+ spans: list[PIISpan] = []
148
+ if not text:
149
+ return spans
150
+
151
+ if policy.machine_enabled:
152
+ for match in _RRN_RE.finditer(text):
153
+ spans.append(_span("rrn", match.start(), match.end(), match.group(0), "high"))
154
+ for match in _PHONE_RE.finditer(text):
155
+ spans.append(_span("phone", match.start(), match.end(), match.group(0), "high"))
156
+ for match in _EMAIL_RE.finditer(text):
157
+ spans.append(_span("email", match.start(), match.end(), match.group(0), "high"))
158
+ for match in _CARD_RE.finditer(text):
159
+ value = match.group(0)
160
+ if _luhn_valid(_digits(value)):
161
+ spans.append(_span("card", match.start(), match.end(), value, "high"))
162
+
163
+ if policy.contextual_enabled:
164
+ for match in _ACCOUNT_RE.finditer(text):
165
+ value = match.group("value")
166
+ digits = _digits(value)
167
+ if 10 <= len(digits) <= 14:
168
+ spans.append(_span("account", match.start("value"), match.end("value"), value, "low"))
169
+ for match in _ADDRESS_RE.finditer(text):
170
+ value = match.group("value").rstrip()
171
+ start = match.start("value")
172
+ spans.append(_span("address", start, start + len(value), value, "low"))
173
+ for match in _NAME_RE.finditer(text):
174
+ value = match.group("value")
175
+ spans.append(_span("name", match.start("value"), match.end("value"), value, "low"))
176
+
177
+ return _without_overlaps(spans)
178
+
179
+
180
+ def mask_pii(text: str, policy: PIIPolicy = DEFAULT_POLICY) -> str:
181
+ """Return ``text`` with detected PII spans masked."""
182
+
183
+ masked = text
184
+ for span in sorted(detect_pii(text, policy), key=lambda item: item["start"], reverse=True):
185
+ start = span["start"]
186
+ end = span["end"]
187
+ masked = masked[:start] + mask_value(span["value"], span["type"], policy) + masked[end:]
188
+ return masked
189
+
190
+
191
+ def mask_value(value: str, pii_type: str, policy: PIIPolicy = DEFAULT_POLICY) -> str:
192
+ """Mask a single known PII value."""
193
+
194
+ normalized_type = _normalize_type(pii_type)
195
+ if normalized_type == "rrn":
196
+ match = _RRN_RE.fullmatch(value)
197
+ if match:
198
+ return f"{match.group('front')}-{match.group('gender')}{policy.mask_char * 6}"
199
+ digits = _digits(value)
200
+ if len(digits) == 13 and digits[6] in "1234":
201
+ return f"{digits[:6]}-{digits[6]}{policy.mask_char * 6}"
202
+ if normalized_type == "phone":
203
+ match = _PHONE_RE.fullmatch(value)
204
+ if match:
205
+ return f"{match.group('prefix')}-{policy.mask_char * 4}-{policy.mask_char * 4}"
206
+ if normalized_type == "email":
207
+ match = _EMAIL_RE.fullmatch(value)
208
+ if match:
209
+ return f"{match.group('local')[:1]}{policy.mask_char * 4}@{match.group('domain')}"
210
+ if normalized_type == "card":
211
+ digits = _digits(value)
212
+ if len(digits) == 16:
213
+ return f"{digits[:4]}-{policy.mask_char * 4}-{policy.mask_char * 4}-{digits[-4:]}"
214
+ if normalized_type == "account":
215
+ digits = _digits(value)
216
+ suffix_len = policy.suffix_len if policy.suffix_len > 0 else 4
217
+ if len(digits) > suffix_len:
218
+ return f"{policy.mask_char * 3}-{policy.mask_char * 4}-{digits[-suffix_len:]}"
219
+ if normalized_type == "name":
220
+ return _mask_generic(value, policy, suffix_len=0)
221
+ if normalized_type == "address":
222
+ return _mask_generic(value, policy, prefix_len=max(policy.prefix_len, 2), suffix_len=0)
223
+ return _mask_generic(value, policy)
224
+
225
+
226
+ def minimize_fields(
227
+ record: Mapping[str, Any],
228
+ allowed: Iterable[str],
229
+ *,
230
+ drop_empty: bool = False,
231
+ ) -> dict[str, Any]:
232
+ """Return a minimized copy containing only allowed fields.
233
+
234
+ Output order follows the order of ``allowed``. Missing fields are skipped.
235
+ When ``drop_empty`` is true, ``None`` and empty sized values are omitted
236
+ while falsey scalars such as ``0`` and ``False`` are retained.
237
+ """
238
+
239
+ minimized: dict[str, Any] = {}
240
+ for key in allowed:
241
+ if key not in record:
242
+ continue
243
+ value = record[key]
244
+ if drop_empty and _is_empty_value(value):
245
+ continue
246
+ minimized[key] = value
247
+ return minimized
248
+
249
+
250
+ class Pseudonymizer:
251
+ """Deterministic in-memory reversible pseudonym token map.
252
+
253
+ ``mapping()`` exposes the value-to-token map. Persisting that map outside
254
+ this instance makes the pseudonymization reversible; v1 keeps it in memory
255
+ only.
256
+ """
257
+
258
+ def __init__(self) -> None:
259
+ self._tokens: dict[str, str] = {}
260
+ self._counts: dict[str, int] = {}
261
+
262
+ def token(self, value: str, *, kind: str = "이름") -> str:
263
+ """Return a stable token for ``value`` within this instance."""
264
+
265
+ if value in self._tokens:
266
+ return self._tokens[value]
267
+ count = self._counts.get(kind, 0) + 1
268
+ self._counts[kind] = count
269
+ token = f"{kind}_{count:03d}"
270
+ self._tokens[value] = token
271
+ return token
272
+
273
+ def mapping(self) -> dict[str, str]:
274
+ """Return a copy of the in-memory value-to-token map."""
275
+
276
+ return dict(self._tokens)
277
+
278
+
279
+ def deidentify(value: str, *, salt: str, length: int = 12) -> str:
280
+ """Return an irreversible deterministic salted hash token."""
281
+
282
+ digest = hashlib.sha256((salt + "\x00" + value).encode()).hexdigest()
283
+ return "di_" + digest[:length]
284
+
285
+
286
+ class PiiLogFilter(logging.Filter):
287
+ """Logging filter that masks PII in formatted log messages."""
288
+
289
+ def __init__(self, name: str = "", policy: PIIPolicy = DEFAULT_POLICY) -> None:
290
+ super().__init__(name)
291
+ self.policy = policy
292
+
293
+ def filter(self, record: logging.LogRecord) -> bool:
294
+ record.msg = _scrub_log_value(record.msg, self.policy)
295
+ record.args = _scrub_log_value(record.args, self.policy)
296
+ try:
297
+ message = record.getMessage()
298
+ except Exception:
299
+ message = str(record.msg)
300
+ masked_message = mask_pii(message, self.policy)
301
+ record.msg = masked_message
302
+ record.args = ()
303
+ return True
304
+
305
+
306
+ def scrub_exception_message(msg: str, policy: PIIPolicy = DEFAULT_POLICY) -> str:
307
+ """Mask PII in an exception message before exposing or logging it."""
308
+
309
+ return mask_pii(msg, policy)
310
+
311
+
312
+ def _span(kind: str, start: int, end: int, value: str, confidence: Confidence) -> PIISpan:
313
+ return {"type": kind, "start": start, "end": end, "value": value, "confidence": confidence}
314
+
315
+
316
+ def _is_empty_value(value: Any) -> bool:
317
+ if value is None:
318
+ return True
319
+ return isinstance(value, Sized) and len(value) == 0
320
+
321
+
322
+ def _scrub_log_value(value: Any, policy: PIIPolicy) -> Any:
323
+ if isinstance(value, str):
324
+ return mask_pii(value, policy)
325
+ if isinstance(value, tuple):
326
+ return tuple(_scrub_log_value(item, policy) for item in value)
327
+ if isinstance(value, Mapping):
328
+ return {key: _scrub_log_value(item, policy) for key, item in value.items()}
329
+ return value
330
+
331
+
332
+ def _normalize_type(pii_type: str) -> str:
333
+ lowered = pii_type.strip().lower()
334
+ return _TYPE_ALIASES.get(lowered, lowered)
335
+
336
+
337
+ def _digits(value: str) -> str:
338
+ return "".join(ch for ch in value if ch.isdigit())
339
+
340
+
341
+ def _luhn_valid(digits: str) -> bool:
342
+ if len(digits) != 16 or not digits.isdigit():
343
+ return False
344
+ total = 0
345
+ double = False
346
+ for char in reversed(digits):
347
+ value = int(char)
348
+ if double:
349
+ value *= 2
350
+ if value > 9:
351
+ value -= 9
352
+ total += value
353
+ double = not double
354
+ return total % 10 == 0
355
+
356
+
357
+ def _without_overlaps(spans: list[PIISpan]) -> list[PIISpan]:
358
+ selected: list[PIISpan] = []
359
+ for span in sorted(spans, key=lambda item: (item["start"], -(item["end"] - item["start"]))):
360
+ if any(_overlaps(span, existing) for existing in selected):
361
+ continue
362
+ selected.append(span)
363
+ return sorted(selected, key=lambda item: (item["start"], item["end"]))
364
+
365
+
366
+ def _overlaps(left: PIISpan, right: PIISpan) -> bool:
367
+ return left["start"] < right["end"] and right["start"] < left["end"]
368
+
369
+
370
+ def _mask_generic(
371
+ value: str,
372
+ policy: PIIPolicy,
373
+ *,
374
+ prefix_len: int | None = None,
375
+ suffix_len: int | None = None,
376
+ ) -> str:
377
+ if not value:
378
+ return value
379
+ requested_prefix = policy.prefix_len if prefix_len is None else prefix_len
380
+ requested_suffix = policy.suffix_len if suffix_len is None else suffix_len
381
+ if len(value) <= requested_prefix + requested_suffix:
382
+ visible_prefix = min(requested_prefix, max(len(value) - 1, 0))
383
+ return f"{value[:visible_prefix]}{policy.mask_char * (len(value) - visible_prefix)}"
384
+ prefix = min(len(value), requested_prefix)
385
+ suffix = min(len(value) - prefix, requested_suffix)
386
+ mask_count = len(value) - prefix - suffix
387
+ suffix_text = value[-suffix:] if suffix else ""
388
+ return f"{value[:prefix]}{policy.mask_char * mask_count}{suffix_text}"
@@ -0,0 +1,13 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Canonical application owners for compare, bulk merge, and redline review."""
3
+
4
+ from .comparison import build_comparison_table_plan
5
+ from .mail_merge import build_mail_merge, inspect_mail_merge_placeholders
6
+ from .redline import verify_redline
7
+
8
+ __all__ = [
9
+ "build_comparison_table_plan",
10
+ "build_mail_merge",
11
+ "inspect_mail_merge_placeholders",
12
+ "verify_redline",
13
+ ]
@@ -0,0 +1,62 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Application document-plan composition for old/new comparisons."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from hwpx.tools.doc_diff import doc_diff
9
+
10
+
11
+ def build_comparison_table_plan(
12
+ old_source: Any,
13
+ new_source: Any,
14
+ *,
15
+ title: str = "신구대조표",
16
+ include_equal: bool = True,
17
+ ) -> dict[str, Any]:
18
+ """Return a plan-v2 document containing an old/new comparison table."""
19
+
20
+ report = doc_diff(old_source, new_source)
21
+ rows = [
22
+ [
23
+ _change_label(change["tag"]),
24
+ change.get("old_text") or "",
25
+ change.get("new_text") or "",
26
+ ]
27
+ for change in report["changes"]
28
+ if include_equal or change["tag"] != "equal"
29
+ ]
30
+ if not rows:
31
+ rows = [["동일", "", ""]]
32
+ return {
33
+ "schemaVersion": "hwpx.document_plan.v2",
34
+ "preset": "government_report",
35
+ "title": title,
36
+ "sections": [
37
+ {
38
+ "blocks": [
39
+ {"type": "heading", "level": 1, "text": title},
40
+ {
41
+ "type": "table",
42
+ "header": ["구분", "구", "신"],
43
+ "rows": rows,
44
+ "columnWidths": [1, 4, 4],
45
+ "headerShading": "EAF1FB",
46
+ },
47
+ ]
48
+ }
49
+ ],
50
+ }
51
+
52
+
53
+ def _change_label(tag: str) -> str:
54
+ return {
55
+ "equal": "동일",
56
+ "added": "추가",
57
+ "removed": "삭제",
58
+ "changed": "변경",
59
+ }.get(tag, tag)
60
+
61
+
62
+ __all__ = ["build_comparison_table_plan"]
@@ -0,0 +1,73 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """PII-bound application workflow over the generic core merge seam."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Mapping, Sequence
7
+ from functools import partial
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any, cast
10
+
11
+ from hwpx.tools.mail_merge import (
12
+ inspect_mail_merge_placeholders as inspect_core_placeholders,
13
+ )
14
+ from hwpx.tools.mail_merge import merge_template_rows
15
+
16
+ from ..compliance import DEFAULT_POLICY, mask_pii
17
+ from ..form_fill.fit import FitMode, FitPolicy
18
+
19
+ if TYPE_CHECKING:
20
+ from hwpx.document import HwpxDocument
21
+
22
+
23
+ def inspect_mail_merge_placeholders(
24
+ source: str | Path | HwpxDocument,
25
+ ) -> dict[str, Any]:
26
+ """Return placeholders through the generic core document inspector."""
27
+
28
+ return inspect_core_placeholders(source)
29
+
30
+
31
+ def build_mail_merge(
32
+ template: str | Path,
33
+ data: str | Path | Sequence[Mapping[str, Any]] | Mapping[str, Any],
34
+ *,
35
+ output_dir: str | Path | None = None,
36
+ filename_pattern: str = "{index:03d}.hwpx",
37
+ zip_path: str | Path | None = None,
38
+ strict: bool = False,
39
+ split_newlines: bool = True,
40
+ fit_mode: str | None = None,
41
+ max_lines: int = 1,
42
+ ) -> dict[str, Any]:
43
+ """Run mail merge with automation-owned fit and PII policies."""
44
+
45
+ fit_policy = None
46
+ if fit_mode:
47
+ valid_modes = set(getattr(FitMode, "__args__", ()))
48
+ if valid_modes and fit_mode not in valid_modes:
49
+ raise ValueError(
50
+ f"unknown fit_mode {fit_mode!r}; expected one of "
51
+ f"{sorted(valid_modes)}"
52
+ )
53
+ fit_policy = FitPolicy(
54
+ mode=cast(FitMode, fit_mode),
55
+ max_lines=max_lines,
56
+ )
57
+
58
+ sanitizer = partial(mask_pii, policy=DEFAULT_POLICY)
59
+ return merge_template_rows(
60
+ template,
61
+ data,
62
+ output_dir=output_dir,
63
+ filename_pattern=filename_pattern,
64
+ zip_path=zip_path,
65
+ strict=strict,
66
+ split_newlines=split_newlines,
67
+ fit_policy=fit_policy,
68
+ max_lines=max_lines,
69
+ value_sanitizer=sanitizer,
70
+ )
71
+
72
+
73
+ __all__ = ["build_mail_merge", "inspect_mail_merge_placeholders"]
@@ -0,0 +1,35 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Hancom-bound redline verification at the automation application boundary."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from os import PathLike
7
+ from typing import Any
8
+
9
+ from hwpx.quality.rendering import RenderBackend
10
+ from hwpx.tools.redline import REDLINE_VERIFY_REPORT_VERSION
11
+ from hwpx.tools.redline import verify_redline as _core_verify_redline
12
+
13
+ from ..rendering import resolve_hancom_backend
14
+
15
+
16
+ def verify_redline(
17
+ before_hwpx: str | PathLike[str],
18
+ after_hwpx: str | PathLike[str],
19
+ *,
20
+ oracle: RenderBackend | None = None,
21
+ ) -> dict[str, Any]:
22
+ """Combine neutral structure evidence with canonical Hancom rendering.
23
+
24
+ The judgement itself is core's — structure, degrade rules, and report shape
25
+ live in :func:`hwpx.tools.redline.verify_redline`. What this layer owns is
26
+ finding a renderer, which is why it supplies a backend when the caller does
27
+ not. Previously this function was a line-for-line copy of core's, and the two
28
+ drifted apart the moment one side was patched; there is now one implementation.
29
+ """
30
+
31
+ backend = oracle if oracle is not None else resolve_hancom_backend()
32
+ return _core_verify_redline(before_hwpx, after_hwpx, oracle=backend)
33
+
34
+
35
+ __all__ = ["REDLINE_VERIFY_REPORT_VERSION", "verify_redline"]
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Canonical automation owner for evaluation-plan application behavior."""
3
+
4
+ from .runtime import (
5
+ EvalPlanContent,
6
+ Rubric,
7
+ expected_skeleton,
8
+ fill_achievement,
9
+ fill_evalplan,
10
+ fill_levels,
11
+ fill_ratio,
12
+ fill_rubrics,
13
+ fill_schedule,
14
+ fill_sections,
15
+ finalize_evalplan,
16
+ parse_review_file,
17
+ parse_review_md,
18
+ plan_structural_ops,
19
+ )
20
+
21
+ __all__ = [ # noqa: RUF022 - frozen 4.x public ordering
22
+ "EvalPlanContent",
23
+ "Rubric",
24
+ "parse_review_md",
25
+ "parse_review_file",
26
+ "expected_skeleton",
27
+ "plan_structural_ops",
28
+ "fill_evalplan",
29
+ "finalize_evalplan",
30
+ "fill_schedule",
31
+ "fill_achievement",
32
+ "fill_levels",
33
+ "fill_rubrics",
34
+ "fill_ratio",
35
+ "fill_sections",
36
+ ]