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.
Files changed (80) hide show
  1. datasentry_core/__init__.py +111 -0
  2. datasentry_core/connectors/__init__.py +49 -0
  3. datasentry_core/connectors/base.py +108 -0
  4. datasentry_core/connectors/csv.py +312 -0
  5. datasentry_core/connectors/duckdb.py +105 -0
  6. datasentry_core/connectors/errors.py +19 -0
  7. datasentry_core/connectors/file_based.py +209 -0
  8. datasentry_core/connectors/jsonl.py +71 -0
  9. datasentry_core/connectors/parquet.py +64 -0
  10. datasentry_core/connectors/registry.py +55 -0
  11. datasentry_core/connectors/spec.py +37 -0
  12. datasentry_core/connectors/xlsx.py +109 -0
  13. datasentry_core/contracts/__init__.py +24 -0
  14. datasentry_core/contracts/exporters.py +330 -0
  15. datasentry_core/detectors/__init__.py +15 -0
  16. datasentry_core/detectors/base.py +120 -0
  17. datasentry_core/detectors/common.py +156 -0
  18. datasentry_core/detectors/cross_field.py +157 -0
  19. datasentry_core/detectors/cross_table.py +220 -0
  20. datasentry_core/detectors/datetime.py +343 -0
  21. datasentry_core/detectors/initial/__init__.py +117 -0
  22. datasentry_core/detectors/initial/anomaly_ml.py +204 -0
  23. datasentry_core/detectors/initial/categorical.py +252 -0
  24. datasentry_core/detectors/initial/formula.py +66 -0
  25. datasentry_core/detectors/initial/missing.py +150 -0
  26. datasentry_core/detectors/initial/numeric.py +302 -0
  27. datasentry_core/detectors/initial/textual.py +407 -0
  28. datasentry_core/detectors/initial/uniqueness.py +183 -0
  29. datasentry_core/detectors/missingness.py +424 -0
  30. datasentry_core/detectors/runner.py +228 -0
  31. datasentry_core/detectors/safe_eval.py +255 -0
  32. datasentry_core/detectors/textual_variants.py +300 -0
  33. datasentry_core/drift/__init__.py +8 -0
  34. datasentry_core/drift/engine.py +182 -0
  35. datasentry_core/engine/__init__.py +16 -0
  36. datasentry_core/engine/base.py +34 -0
  37. datasentry_core/engine/duckdb.py +79 -0
  38. datasentry_core/engine/fusion.py +193 -0
  39. datasentry_core/engine/profiler.py +165 -0
  40. datasentry_core/engine/sql_guard.py +23 -0
  41. datasentry_core/llm/provider.py +66 -0
  42. datasentry_core/models/__init__.py +118 -0
  43. datasentry_core/models/audit.py +46 -0
  44. datasentry_core/models/contract.py +89 -0
  45. datasentry_core/models/detector.py +66 -0
  46. datasentry_core/models/drift.py +47 -0
  47. datasentry_core/models/enums.py +126 -0
  48. datasentry_core/models/evidence.py +39 -0
  49. datasentry_core/models/feedback.py +30 -0
  50. datasentry_core/models/fingerprint.py +24 -0
  51. datasentry_core/models/issue.py +49 -0
  52. datasentry_core/models/llm.py +69 -0
  53. datasentry_core/models/profile.py +79 -0
  54. datasentry_core/models/quality.py +16 -0
  55. datasentry_core/models/repair.py +94 -0
  56. datasentry_core/models/rules.py +82 -0
  57. datasentry_core/models/scan.py +112 -0
  58. datasentry_core/models/validation.py +22 -0
  59. datasentry_core/plugins.py +91 -0
  60. datasentry_core/privacy/redactor.py +155 -0
  61. datasentry_core/repair/__init__.py +5 -0
  62. datasentry_core/repair/engine.py +455 -0
  63. datasentry_core/reporting/__init__.py +100 -0
  64. datasentry_core/reporting/html.py +230 -0
  65. datasentry_core/reporting/junit.py +80 -0
  66. datasentry_core/reporting/markdown.py +83 -0
  67. datasentry_core/reporting/sarif.py +92 -0
  68. datasentry_core/rules/engine.py +133 -0
  69. datasentry_core/scoring/__init__.py +30 -0
  70. datasentry_core/scoring/engine.py +138 -0
  71. datasentry_core/scoring/gate.py +96 -0
  72. datasentry_core/scoring/quality.py +137 -0
  73. datasentry_core/scoring/weights.py +33 -0
  74. datasentry_core/storage/__init__.py +15 -0
  75. datasentry_core/storage/paths.py +47 -0
  76. datasentry_core/storage/schema.py +343 -0
  77. datasentry_core/storage/store.py +675 -0
  78. datasentry_core-0.1.0.dist-info/METADATA +59 -0
  79. datasentry_core-0.1.0.dist-info/RECORD +80 -0
  80. 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