datasentry-core 0.1.0__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.
- datasentry_core/__init__.py +111 -0
- datasentry_core/connectors/__init__.py +49 -0
- datasentry_core/connectors/base.py +108 -0
- datasentry_core/connectors/csv.py +312 -0
- datasentry_core/connectors/duckdb.py +105 -0
- datasentry_core/connectors/errors.py +19 -0
- datasentry_core/connectors/file_based.py +209 -0
- datasentry_core/connectors/jsonl.py +71 -0
- datasentry_core/connectors/parquet.py +64 -0
- datasentry_core/connectors/registry.py +55 -0
- datasentry_core/connectors/spec.py +37 -0
- datasentry_core/connectors/xlsx.py +109 -0
- datasentry_core/contracts/__init__.py +24 -0
- datasentry_core/contracts/exporters.py +330 -0
- datasentry_core/detectors/__init__.py +15 -0
- datasentry_core/detectors/base.py +120 -0
- datasentry_core/detectors/common.py +156 -0
- datasentry_core/detectors/cross_field.py +157 -0
- datasentry_core/detectors/cross_table.py +220 -0
- datasentry_core/detectors/datetime.py +343 -0
- datasentry_core/detectors/initial/__init__.py +117 -0
- datasentry_core/detectors/initial/anomaly_ml.py +204 -0
- datasentry_core/detectors/initial/categorical.py +252 -0
- datasentry_core/detectors/initial/formula.py +66 -0
- datasentry_core/detectors/initial/missing.py +150 -0
- datasentry_core/detectors/initial/numeric.py +302 -0
- datasentry_core/detectors/initial/textual.py +407 -0
- datasentry_core/detectors/initial/uniqueness.py +183 -0
- datasentry_core/detectors/missingness.py +424 -0
- datasentry_core/detectors/runner.py +228 -0
- datasentry_core/detectors/safe_eval.py +255 -0
- datasentry_core/detectors/textual_variants.py +300 -0
- datasentry_core/drift/__init__.py +8 -0
- datasentry_core/drift/engine.py +182 -0
- datasentry_core/engine/__init__.py +16 -0
- datasentry_core/engine/base.py +34 -0
- datasentry_core/engine/duckdb.py +79 -0
- datasentry_core/engine/fusion.py +193 -0
- datasentry_core/engine/profiler.py +165 -0
- datasentry_core/engine/sql_guard.py +23 -0
- datasentry_core/llm/provider.py +66 -0
- datasentry_core/models/__init__.py +118 -0
- datasentry_core/models/audit.py +46 -0
- datasentry_core/models/contract.py +89 -0
- datasentry_core/models/detector.py +66 -0
- datasentry_core/models/drift.py +47 -0
- datasentry_core/models/enums.py +126 -0
- datasentry_core/models/evidence.py +39 -0
- datasentry_core/models/feedback.py +30 -0
- datasentry_core/models/fingerprint.py +24 -0
- datasentry_core/models/issue.py +49 -0
- datasentry_core/models/llm.py +69 -0
- datasentry_core/models/profile.py +79 -0
- datasentry_core/models/quality.py +16 -0
- datasentry_core/models/repair.py +94 -0
- datasentry_core/models/rules.py +82 -0
- datasentry_core/models/scan.py +112 -0
- datasentry_core/models/validation.py +22 -0
- datasentry_core/plugins.py +91 -0
- datasentry_core/privacy/redactor.py +155 -0
- datasentry_core/repair/__init__.py +5 -0
- datasentry_core/repair/engine.py +455 -0
- datasentry_core/reporting/__init__.py +100 -0
- datasentry_core/reporting/html.py +230 -0
- datasentry_core/reporting/junit.py +80 -0
- datasentry_core/reporting/markdown.py +83 -0
- datasentry_core/reporting/sarif.py +92 -0
- datasentry_core/rules/engine.py +133 -0
- datasentry_core/scoring/__init__.py +30 -0
- datasentry_core/scoring/engine.py +138 -0
- datasentry_core/scoring/gate.py +96 -0
- datasentry_core/scoring/quality.py +137 -0
- datasentry_core/scoring/weights.py +33 -0
- datasentry_core/storage/__init__.py +15 -0
- datasentry_core/storage/paths.py +47 -0
- datasentry_core/storage/schema.py +343 -0
- datasentry_core/storage/store.py +675 -0
- datasentry_core-0.1.0.dist-info/METADATA +59 -0
- datasentry_core-0.1.0.dist-info/RECORD +80 -0
- datasentry_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""字符串与格式检测器(11.7):空白/控制字符/长度/邮箱/电话/URL/IP。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from datasentry_core.detectors.base import DetectionContext
|
|
8
|
+
from datasentry_core.detectors.common import (
|
|
9
|
+
DetectorBase,
|
|
10
|
+
make_candidate,
|
|
11
|
+
make_evidence,
|
|
12
|
+
quote_ident,
|
|
13
|
+
quote_re,
|
|
14
|
+
string_columns,
|
|
15
|
+
)
|
|
16
|
+
from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
|
|
17
|
+
from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
|
|
18
|
+
|
|
19
|
+
_EMAIL_RE = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
|
|
20
|
+
# RE2 支持 \xNN 十六进制转义;直接嵌入控制字符会破坏 duckdb 字符串字面量
|
|
21
|
+
_CONTROL_CHAR_RE = r"[\x00-\x08\x0b\x0c\x0e-\x1f]"
|
|
22
|
+
_EMAIL_HINTS = ("email", "mail", "@")
|
|
23
|
+
_URL_RE = r"^(https?|ftp)://[^\s]+$"
|
|
24
|
+
_URL_HINTS = ("url", "website", "web", "link")
|
|
25
|
+
_IPV4_RE = (
|
|
26
|
+
r"^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])"
|
|
27
|
+
r"(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$"
|
|
28
|
+
)
|
|
29
|
+
_IP_HINTS = ("ip_address", "ip_addr", "ipv4")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hinted_columns(context: DetectionContext, hints: tuple[str, ...]) -> list[str]:
|
|
33
|
+
return [col for col in string_columns(context) if any(h in col.lower() for h in hints)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LeadingTrailingWhitespaceDetector(DetectorBase):
|
|
37
|
+
"""前后空白(11.7)。"""
|
|
38
|
+
|
|
39
|
+
detector_id = "leading_or_trailing_whitespace"
|
|
40
|
+
display_name = "Leading/Trailing Whitespace"
|
|
41
|
+
description = "Reports values with leading or trailing whitespace."
|
|
42
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
43
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
44
|
+
|
|
45
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
46
|
+
candidates: list[IssueCandidate] = []
|
|
47
|
+
for col in string_columns(context):
|
|
48
|
+
q = quote_ident(col)
|
|
49
|
+
table = context.handle.sql_aggregate(
|
|
50
|
+
f"SELECT count(*) AS n FROM data WHERE {q} <> trim({q})"
|
|
51
|
+
).table
|
|
52
|
+
count = int(table.column("n").to_pylist()[0])
|
|
53
|
+
if count > 0:
|
|
54
|
+
candidates.append(
|
|
55
|
+
make_candidate(
|
|
56
|
+
detector_id=self.detector_id,
|
|
57
|
+
detector_version=self.detector_version,
|
|
58
|
+
context=context,
|
|
59
|
+
issue_type="leading_or_trailing_whitespace",
|
|
60
|
+
columns=[col],
|
|
61
|
+
affected_count=count,
|
|
62
|
+
evidence=[
|
|
63
|
+
make_evidence(
|
|
64
|
+
detector_id=self.detector_id,
|
|
65
|
+
detector_version=self.detector_version,
|
|
66
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
67
|
+
description=f"{count} values differ from their trimmed form",
|
|
68
|
+
data={"count": count},
|
|
69
|
+
)
|
|
70
|
+
],
|
|
71
|
+
raw_score=count,
|
|
72
|
+
confidence=0.98,
|
|
73
|
+
severity=Severity.LOW,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
return candidates
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RepeatedWhitespaceDetector(DetectorBase):
|
|
80
|
+
"""重复空白(11.7):连续 2+ 空白。"""
|
|
81
|
+
|
|
82
|
+
detector_id = "repeated_whitespace"
|
|
83
|
+
display_name = "Repeated Whitespace"
|
|
84
|
+
description = "Reports values containing runs of 2+ whitespace characters."
|
|
85
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
86
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
87
|
+
|
|
88
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
89
|
+
candidates: list[IssueCandidate] = []
|
|
90
|
+
for col in string_columns(context):
|
|
91
|
+
q = quote_ident(col)
|
|
92
|
+
table = context.handle.sql_aggregate(
|
|
93
|
+
f"SELECT count(*) AS n FROM data WHERE regexp_matches({q}, '\\s{{2,}}')"
|
|
94
|
+
).table
|
|
95
|
+
count = int(table.column("n").to_pylist()[0])
|
|
96
|
+
if count > 0:
|
|
97
|
+
candidates.append(
|
|
98
|
+
make_candidate(
|
|
99
|
+
detector_id=self.detector_id,
|
|
100
|
+
detector_version=self.detector_version,
|
|
101
|
+
context=context,
|
|
102
|
+
issue_type="repeated_whitespace",
|
|
103
|
+
columns=[col],
|
|
104
|
+
affected_count=count,
|
|
105
|
+
evidence=[
|
|
106
|
+
make_evidence(
|
|
107
|
+
detector_id=self.detector_id,
|
|
108
|
+
detector_version=self.detector_version,
|
|
109
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
110
|
+
description=f"{count} values with repeated whitespace",
|
|
111
|
+
data={"count": count},
|
|
112
|
+
)
|
|
113
|
+
],
|
|
114
|
+
raw_score=count,
|
|
115
|
+
confidence=0.95,
|
|
116
|
+
severity=Severity.LOW,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
return candidates
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class HiddenControlCharacterDetector(DetectorBase):
|
|
123
|
+
"""隐藏控制字符(11.7):\x00-\x08 等。"""
|
|
124
|
+
|
|
125
|
+
detector_id = "hidden_control_character"
|
|
126
|
+
display_name = "Hidden Control Character"
|
|
127
|
+
description = "Reports values containing control characters."
|
|
128
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
129
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
130
|
+
|
|
131
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
132
|
+
candidates: list[IssueCandidate] = []
|
|
133
|
+
for col in string_columns(context):
|
|
134
|
+
q = quote_ident(col)
|
|
135
|
+
table = context.handle.sql_aggregate(
|
|
136
|
+
"SELECT count(*) AS n FROM data "
|
|
137
|
+
f"WHERE regexp_matches({q}, {quote_re(_CONTROL_CHAR_RE)})"
|
|
138
|
+
).table
|
|
139
|
+
count = int(table.column("n").to_pylist()[0])
|
|
140
|
+
if count > 0:
|
|
141
|
+
candidates.append(
|
|
142
|
+
make_candidate(
|
|
143
|
+
detector_id=self.detector_id,
|
|
144
|
+
detector_version=self.detector_version,
|
|
145
|
+
context=context,
|
|
146
|
+
issue_type="hidden_control_character",
|
|
147
|
+
columns=[col],
|
|
148
|
+
affected_count=count,
|
|
149
|
+
evidence=[
|
|
150
|
+
make_evidence(
|
|
151
|
+
detector_id=self.detector_id,
|
|
152
|
+
detector_version=self.detector_version,
|
|
153
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
154
|
+
description=f"{count} values contain control characters",
|
|
155
|
+
data={"count": count},
|
|
156
|
+
)
|
|
157
|
+
],
|
|
158
|
+
raw_score=count,
|
|
159
|
+
confidence=0.98,
|
|
160
|
+
severity=Severity.MEDIUM,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
return candidates
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class UnusualLengthDetector(DetectorBase):
|
|
167
|
+
"""异常长度(11.7):超长必报(> max_length);P 边界分位数留待 profile 完善。"""
|
|
168
|
+
|
|
169
|
+
detector_id = "unusual_length"
|
|
170
|
+
display_name = "Unusual Length"
|
|
171
|
+
description = "Reports values whose length exceeds max_length."
|
|
172
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
173
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
174
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {"max_length": 1024}
|
|
175
|
+
|
|
176
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
177
|
+
max_length = 1024
|
|
178
|
+
candidates: list[IssueCandidate] = []
|
|
179
|
+
for col in string_columns(context):
|
|
180
|
+
q = quote_ident(col)
|
|
181
|
+
table = context.handle.sql_aggregate(
|
|
182
|
+
f"SELECT count(*) AS n FROM data WHERE length({q}) > {max_length}"
|
|
183
|
+
).table
|
|
184
|
+
count = int(table.column("n").to_pylist()[0])
|
|
185
|
+
if count > 0:
|
|
186
|
+
candidates.append(
|
|
187
|
+
make_candidate(
|
|
188
|
+
detector_id=self.detector_id,
|
|
189
|
+
detector_version=self.detector_version,
|
|
190
|
+
context=context,
|
|
191
|
+
issue_type="unusual_length",
|
|
192
|
+
columns=[col],
|
|
193
|
+
affected_count=count,
|
|
194
|
+
evidence=[
|
|
195
|
+
make_evidence(
|
|
196
|
+
detector_id=self.detector_id,
|
|
197
|
+
detector_version=self.detector_version,
|
|
198
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
199
|
+
description=f"{count} values longer than {max_length} chars",
|
|
200
|
+
data={"max_length": max_length, "count": count},
|
|
201
|
+
)
|
|
202
|
+
],
|
|
203
|
+
raw_score=count,
|
|
204
|
+
confidence=0.9,
|
|
205
|
+
severity=Severity.LOW,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
return candidates
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class InvalidEmailDetector(DetectorBase):
|
|
212
|
+
"""无效邮箱(11.7):仅对列名含 email 特征的列启用。"""
|
|
213
|
+
|
|
214
|
+
detector_id = "invalid_email"
|
|
215
|
+
display_name = "Invalid Email"
|
|
216
|
+
description = "Reports values that do not match a basic email pattern."
|
|
217
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
218
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
219
|
+
|
|
220
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
221
|
+
return any(hint in col.lower() for col in context.columns for hint in _EMAIL_HINTS)
|
|
222
|
+
|
|
223
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
224
|
+
candidates: list[IssueCandidate] = []
|
|
225
|
+
for col in string_columns(context):
|
|
226
|
+
if not any(hint in col.lower() for hint in _EMAIL_HINTS):
|
|
227
|
+
continue
|
|
228
|
+
q = quote_ident(col)
|
|
229
|
+
table = context.handle.sql_aggregate(
|
|
230
|
+
f"SELECT count(*) AS n FROM data "
|
|
231
|
+
f"WHERE {q} IS NOT NULL AND NOT regexp_matches(trim({q}), {quote_re(_EMAIL_RE)})"
|
|
232
|
+
).table
|
|
233
|
+
count = int(table.column("n").to_pylist()[0])
|
|
234
|
+
if count > 0:
|
|
235
|
+
candidates.append(
|
|
236
|
+
make_candidate(
|
|
237
|
+
detector_id=self.detector_id,
|
|
238
|
+
detector_version=self.detector_version,
|
|
239
|
+
context=context,
|
|
240
|
+
issue_type="invalid_email",
|
|
241
|
+
columns=[col],
|
|
242
|
+
affected_count=count,
|
|
243
|
+
evidence=[
|
|
244
|
+
make_evidence(
|
|
245
|
+
detector_id=self.detector_id,
|
|
246
|
+
detector_version=self.detector_version,
|
|
247
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
248
|
+
description=f"{count} values fail email pattern",
|
|
249
|
+
data={"count": count, "pattern": _EMAIL_RE},
|
|
250
|
+
)
|
|
251
|
+
],
|
|
252
|
+
raw_score=count,
|
|
253
|
+
confidence=0.85,
|
|
254
|
+
severity=Severity.LOW,
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
return candidates
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class InvalidPhoneDetector(DetectorBase):
|
|
261
|
+
"""无效电话(11.7):去非数字后长度 ∉ [7,15];仅列名含电话特征启用。"""
|
|
262
|
+
|
|
263
|
+
detector_id = "invalid_phone"
|
|
264
|
+
display_name = "Invalid Phone"
|
|
265
|
+
description = "Reports values whose digit-only length is outside [7, 15]."
|
|
266
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
267
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
268
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {"min_digits": 7, "max_digits": 15}
|
|
269
|
+
|
|
270
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
271
|
+
return any(h in col.lower() for col in context.columns for h in ("phone", "tel", "mobile"))
|
|
272
|
+
|
|
273
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
274
|
+
candidates: list[IssueCandidate] = []
|
|
275
|
+
for col in _hinted_columns(context, ("phone", "tel", "mobile")):
|
|
276
|
+
q = quote_ident(col)
|
|
277
|
+
table = context.handle.sql_aggregate(
|
|
278
|
+
f"SELECT count(*) AS n FROM data "
|
|
279
|
+
f"WHERE {q} IS NOT NULL "
|
|
280
|
+
f"AND (length(regexp_replace({q}, '[^0-9]', '', 'g')) < 7 "
|
|
281
|
+
f"OR length(regexp_replace({q}, '[^0-9]', '', 'g')) > 15)"
|
|
282
|
+
).table
|
|
283
|
+
count = int(table.column("n").to_pylist()[0])
|
|
284
|
+
if count > 0:
|
|
285
|
+
candidates.append(
|
|
286
|
+
make_candidate(
|
|
287
|
+
detector_id=self.detector_id,
|
|
288
|
+
detector_version=self.detector_version,
|
|
289
|
+
context=context,
|
|
290
|
+
issue_type="invalid_phone",
|
|
291
|
+
columns=[col],
|
|
292
|
+
affected_count=count,
|
|
293
|
+
evidence=[
|
|
294
|
+
make_evidence(
|
|
295
|
+
detector_id=self.detector_id,
|
|
296
|
+
detector_version=self.detector_version,
|
|
297
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
298
|
+
description=f"{count} values with digit count outside [7, 15]",
|
|
299
|
+
data={"count": count, "min_digits": 7, "max_digits": 15},
|
|
300
|
+
)
|
|
301
|
+
],
|
|
302
|
+
raw_score=count,
|
|
303
|
+
confidence=0.9,
|
|
304
|
+
severity=Severity.LOW,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
return candidates
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class InvalidUrlDetector(DetectorBase):
|
|
311
|
+
"""无效 URL(11.7):非 scheme:// 开头;仅列名含 URL 特征启用。"""
|
|
312
|
+
|
|
313
|
+
detector_id = "invalid_url"
|
|
314
|
+
display_name = "Invalid URL"
|
|
315
|
+
description = "Reports values that do not match a basic http(s)/ftp URL pattern."
|
|
316
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
317
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
318
|
+
|
|
319
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
320
|
+
return any(h in col.lower() for col in context.columns for h in _URL_HINTS)
|
|
321
|
+
|
|
322
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
323
|
+
candidates: list[IssueCandidate] = []
|
|
324
|
+
for col in _hinted_columns(context, _URL_HINTS):
|
|
325
|
+
q = quote_ident(col)
|
|
326
|
+
table = context.handle.sql_aggregate(
|
|
327
|
+
f"SELECT count(*) AS n FROM data "
|
|
328
|
+
f"WHERE {q} IS NOT NULL AND NOT regexp_matches(trim({q}), {quote_re(_URL_RE)})"
|
|
329
|
+
).table
|
|
330
|
+
count = int(table.column("n").to_pylist()[0])
|
|
331
|
+
if count > 0:
|
|
332
|
+
candidates.append(
|
|
333
|
+
make_candidate(
|
|
334
|
+
detector_id=self.detector_id,
|
|
335
|
+
detector_version=self.detector_version,
|
|
336
|
+
context=context,
|
|
337
|
+
issue_type="invalid_url",
|
|
338
|
+
columns=[col],
|
|
339
|
+
affected_count=count,
|
|
340
|
+
evidence=[
|
|
341
|
+
make_evidence(
|
|
342
|
+
detector_id=self.detector_id,
|
|
343
|
+
detector_version=self.detector_version,
|
|
344
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
345
|
+
description=f"{count} values fail URL pattern",
|
|
346
|
+
data={"count": count, "pattern": _URL_RE},
|
|
347
|
+
)
|
|
348
|
+
],
|
|
349
|
+
raw_score=count,
|
|
350
|
+
confidence=0.9,
|
|
351
|
+
severity=Severity.LOW,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
return candidates
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class InvalidIpDetector(DetectorBase):
|
|
358
|
+
"""无效 IP(11.7):IPv4 严格校验;仅列名含 ip 特征且非 zip 类启用。"""
|
|
359
|
+
|
|
360
|
+
detector_id = "invalid_ip"
|
|
361
|
+
display_name = "Invalid IP"
|
|
362
|
+
description = "Reports values that do not match a strict IPv4 pattern."
|
|
363
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
364
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
365
|
+
|
|
366
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
367
|
+
return any(
|
|
368
|
+
any(h in col.lower() for h in _IP_HINTS) or col.lower() == "ip"
|
|
369
|
+
for col in context.columns
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
373
|
+
candidates: list[IssueCandidate] = []
|
|
374
|
+
for col in string_columns(context):
|
|
375
|
+
low = col.lower()
|
|
376
|
+
if not (any(h in low for h in _IP_HINTS) or low == "ip"):
|
|
377
|
+
continue
|
|
378
|
+
q = quote_ident(col)
|
|
379
|
+
table = context.handle.sql_aggregate(
|
|
380
|
+
f"SELECT count(*) AS n FROM data "
|
|
381
|
+
f"WHERE {q} IS NOT NULL AND NOT regexp_matches(trim({q}), {quote_re(_IPV4_RE)})"
|
|
382
|
+
).table
|
|
383
|
+
count = int(table.column("n").to_pylist()[0])
|
|
384
|
+
if count > 0:
|
|
385
|
+
candidates.append(
|
|
386
|
+
make_candidate(
|
|
387
|
+
detector_id=self.detector_id,
|
|
388
|
+
detector_version=self.detector_version,
|
|
389
|
+
context=context,
|
|
390
|
+
issue_type="invalid_ip",
|
|
391
|
+
columns=[col],
|
|
392
|
+
affected_count=count,
|
|
393
|
+
evidence=[
|
|
394
|
+
make_evidence(
|
|
395
|
+
detector_id=self.detector_id,
|
|
396
|
+
detector_version=self.detector_version,
|
|
397
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
398
|
+
description=f"{count} values fail IPv4 pattern",
|
|
399
|
+
data={"count": count, "pattern": _IPV4_RE},
|
|
400
|
+
)
|
|
401
|
+
],
|
|
402
|
+
raw_score=count,
|
|
403
|
+
confidence=0.95,
|
|
404
|
+
severity=Severity.LOW,
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
return candidates
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""唯一性检测器(11.4):uniqueness_violation + fuzzy_duplicate(Level 3 模糊重复)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
from datasentry_core.detectors.base import DetectionContext
|
|
9
|
+
from datasentry_core.detectors.common import (
|
|
10
|
+
DetectorBase,
|
|
11
|
+
make_candidate,
|
|
12
|
+
make_evidence,
|
|
13
|
+
quote_ident,
|
|
14
|
+
)
|
|
15
|
+
from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
|
|
16
|
+
from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
|
|
17
|
+
|
|
18
|
+
_CAP_EXAMPLES = 20
|
|
19
|
+
|
|
20
|
+
_STRING_TYPES = frozenset({"VARCHAR", "CHAR", "TEXT", "BPCHAR"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UniquenessViolationDetector(DetectorBase):
|
|
24
|
+
"""重复值检测(11.4):按列 GROUP BY,输出重复频次最高的前 _CAP_EXAMPLES 个值。"""
|
|
25
|
+
|
|
26
|
+
detector_id = "uniqueness_violation"
|
|
27
|
+
display_name = "Uniqueness Violation"
|
|
28
|
+
description = "Reports duplicate values in columns that are expected to be unique."
|
|
29
|
+
quality_dimension = QualityDimension.UNIQUENESS
|
|
30
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
31
|
+
|
|
32
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
33
|
+
candidates: list[IssueCandidate] = []
|
|
34
|
+
for col in context.columns:
|
|
35
|
+
q = quote_ident(col)
|
|
36
|
+
table = context.handle.sql_aggregate(
|
|
37
|
+
f"SELECT {q} AS v, count(*) AS n FROM data "
|
|
38
|
+
f"WHERE {q} IS NOT NULL GROUP BY {q} HAVING count(*) > 1 "
|
|
39
|
+
f"ORDER BY n DESC, v LIMIT {_CAP_EXAMPLES}"
|
|
40
|
+
).table
|
|
41
|
+
values = table.column("v").to_pylist()
|
|
42
|
+
counts = table.column("n").to_pylist()
|
|
43
|
+
if not values:
|
|
44
|
+
continue
|
|
45
|
+
duplicate_rows = sum(int(c) - 1 for c in counts)
|
|
46
|
+
|
|
47
|
+
def _json_safe(value: Any) -> Any:
|
|
48
|
+
if isinstance(value, (date, datetime)):
|
|
49
|
+
return value.isoformat()
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
examples = [(_json_safe(v), int(c)) for v, c in zip(values, counts, strict=True)]
|
|
53
|
+
candidates.append(
|
|
54
|
+
make_candidate(
|
|
55
|
+
detector_id=self.detector_id,
|
|
56
|
+
detector_version=self.detector_version,
|
|
57
|
+
context=context,
|
|
58
|
+
issue_type="uniqueness_violation",
|
|
59
|
+
columns=[col],
|
|
60
|
+
affected_count=duplicate_rows,
|
|
61
|
+
evidence=[
|
|
62
|
+
make_evidence(
|
|
63
|
+
detector_id=self.detector_id,
|
|
64
|
+
detector_version=self.detector_version,
|
|
65
|
+
evidence_type=EvidenceType.DUPLICATE_MATCH,
|
|
66
|
+
description=f"{len(values)} duplicated values, "
|
|
67
|
+
f"{duplicate_rows} duplicate rows",
|
|
68
|
+
data={"examples": examples[:10]},
|
|
69
|
+
)
|
|
70
|
+
],
|
|
71
|
+
raw_score=duplicate_rows,
|
|
72
|
+
confidence=0.98,
|
|
73
|
+
severity=Severity.MEDIUM,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
return candidates
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
#: 归一化分组上限(防长尾组数爆炸)
|
|
80
|
+
_MAX_FUZZY_GROUPS = 50
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FuzzyDuplicateDetector(DetectorBase):
|
|
84
|
+
"""模糊重复(Level 3,11.4):归一化后相同但原始值不同的组。
|
|
85
|
+
|
|
86
|
+
SQL 下推做归一化分组:lower + 去除空白/标点/控制字符
|
|
87
|
+
(保留字母数字与 CJK),组大小 ≥ 2 且组内原始值 ≥ 2 种。
|
|
88
|
+
按列输出一条 issue:affected_count = 组内行数 − 组数(可去重
|
|
89
|
+
行数),evidence 携带各组样例(归一化键 + 原始值列表)。
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
detector_id = "fuzzy_duplicate"
|
|
93
|
+
display_name = "Fuzzy Duplicate"
|
|
94
|
+
description = (
|
|
95
|
+
"Finds near-duplicate values that only differ by case, whitespace "
|
|
96
|
+
"or punctuation after normalization."
|
|
97
|
+
)
|
|
98
|
+
quality_dimension = QualityDimension.UNIQUENESS
|
|
99
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
100
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {
|
|
101
|
+
"min_group_size": 2,
|
|
102
|
+
"min_norm_length": 2,
|
|
103
|
+
"max_groups": _MAX_FUZZY_GROUPS,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
107
|
+
return any(
|
|
108
|
+
col.physical_type.upper() in _STRING_TYPES for col in context.handle.schema().columns
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
112
|
+
candidates: list[IssueCandidate] = []
|
|
113
|
+
for column in context.handle.schema().columns:
|
|
114
|
+
if column.physical_type.upper() not in _STRING_TYPES:
|
|
115
|
+
continue
|
|
116
|
+
q = quote_ident(column.name)
|
|
117
|
+
table = context.handle.sql_aggregate(
|
|
118
|
+
"SELECT norm AS k, count(*) AS n, count(DISTINCT "
|
|
119
|
+
+ q
|
|
120
|
+
+ ") AS raw_n, string_agg(DISTINCT "
|
|
121
|
+
+ q
|
|
122
|
+
+ ", ' || ') AS samples FROM ("
|
|
123
|
+
"SELECT lower(regexp_replace("
|
|
124
|
+
+ q
|
|
125
|
+
+ ", '[^0-9A-Za-z\u4e00-\u9fff]', '', 'g')) AS norm, "
|
|
126
|
+
+ q
|
|
127
|
+
+ " FROM data WHERE "
|
|
128
|
+
+ q
|
|
129
|
+
+ " IS NOT NULL) WHERE length(norm) >= 2 GROUP BY norm "
|
|
130
|
+
"HAVING count(*) >= 2 AND count(DISTINCT "
|
|
131
|
+
+ q
|
|
132
|
+
+ ") >= 2 ORDER BY n DESC LIMIT "
|
|
133
|
+
+ str(_MAX_FUZZY_GROUPS)
|
|
134
|
+
).table
|
|
135
|
+
keys = table.column("k").to_pylist()
|
|
136
|
+
counts = table.column("n").to_pylist()
|
|
137
|
+
raw_counts = table.column("raw_n").to_pylist()
|
|
138
|
+
sample_lists = table.column("samples").to_pylist()
|
|
139
|
+
if not keys:
|
|
140
|
+
continue
|
|
141
|
+
groups: list[dict[str, Any]] = []
|
|
142
|
+
redundant_rows = 0
|
|
143
|
+
for key, n, raw_n, joined in zip(keys, counts, raw_counts, sample_lists, strict=True):
|
|
144
|
+
group_rows = int(n)
|
|
145
|
+
redundant_rows += group_rows - 1
|
|
146
|
+
samples = str(joined).split(" || ") if joined is not None else []
|
|
147
|
+
groups.append(
|
|
148
|
+
{
|
|
149
|
+
"normalized": key,
|
|
150
|
+
"row_count": group_rows,
|
|
151
|
+
"distinct_raw": int(raw_n),
|
|
152
|
+
"examples": samples[:3],
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
if not groups:
|
|
156
|
+
continue
|
|
157
|
+
candidates.append(
|
|
158
|
+
make_candidate(
|
|
159
|
+
detector_id=self.detector_id,
|
|
160
|
+
detector_version=self.detector_version,
|
|
161
|
+
context=context,
|
|
162
|
+
issue_type="fuzzy_duplicate",
|
|
163
|
+
columns=[column.name],
|
|
164
|
+
affected_count=redundant_rows,
|
|
165
|
+
evidence=[
|
|
166
|
+
make_evidence(
|
|
167
|
+
detector_id=self.detector_id,
|
|
168
|
+
detector_version=self.detector_version,
|
|
169
|
+
evidence_type=EvidenceType.DUPLICATE_MATCH,
|
|
170
|
+
description=(
|
|
171
|
+
f"{len(groups)} normalized groups with "
|
|
172
|
+
f"{redundant_rows} redundant rows"
|
|
173
|
+
),
|
|
174
|
+
data={"groups": groups[:10]},
|
|
175
|
+
)
|
|
176
|
+
],
|
|
177
|
+
raw_score=redundant_rows,
|
|
178
|
+
confidence=0.9,
|
|
179
|
+
severity=Severity.MEDIUM,
|
|
180
|
+
fpr=0.15,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return candidates
|