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