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,252 @@
|
|
|
1
|
+
"""类别异常检测器(11.6):placeholder / rare_category / category_explosion / inconsistent_case。"""
|
|
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
|
+
quote_literal,
|
|
15
|
+
string_columns,
|
|
16
|
+
)
|
|
17
|
+
from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
|
|
18
|
+
from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _json_safe(value: Any) -> Any:
|
|
22
|
+
"""evidence 数据必须 JSON 可序列化:date/datetime 转 ISO 字符串。"""
|
|
23
|
+
if isinstance(value, (date, datetime)):
|
|
24
|
+
return value.isoformat()
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_PLACEHOLDERS = ("test", "xxx", "foo", "abc", "12345", "dummy", "example")
|
|
29
|
+
_IDENTIFIER_HINTS = ("_id", "id_", " id", "key", "code", "uuid", "hash")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SuspiciousPlaceholderDetector(DetectorBase):
|
|
33
|
+
"""占位符值(11.6):匹配即报告。"""
|
|
34
|
+
|
|
35
|
+
detector_id = "suspicious_placeholder"
|
|
36
|
+
display_name = "Suspicious Placeholder"
|
|
37
|
+
description = "Reports values like 'test', 'xxx', 'foo' that are likely placeholders."
|
|
38
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
39
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
40
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {
|
|
41
|
+
"placeholder_count": len(_PLACEHOLDERS)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
45
|
+
return bool(string_columns(context))
|
|
46
|
+
|
|
47
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
48
|
+
candidates: list[IssueCandidate] = []
|
|
49
|
+
for col in string_columns(context):
|
|
50
|
+
q = quote_ident(col)
|
|
51
|
+
tokens = ", ".join(quote_literal(t) for t in _PLACEHOLDERS)
|
|
52
|
+
table = context.handle.sql_aggregate(
|
|
53
|
+
f"SELECT count(*) AS n FROM data WHERE lower(trim({q})) IN ({tokens})"
|
|
54
|
+
).table
|
|
55
|
+
count = int(table.column("n").to_pylist()[0])
|
|
56
|
+
if count > 0:
|
|
57
|
+
candidates.append(
|
|
58
|
+
make_candidate(
|
|
59
|
+
detector_id=self.detector_id,
|
|
60
|
+
detector_version=self.detector_version,
|
|
61
|
+
context=context,
|
|
62
|
+
issue_type="suspicious_placeholder",
|
|
63
|
+
columns=[col],
|
|
64
|
+
affected_count=count,
|
|
65
|
+
evidence=[
|
|
66
|
+
make_evidence(
|
|
67
|
+
detector_id=self.detector_id,
|
|
68
|
+
detector_version=self.detector_version,
|
|
69
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
70
|
+
description=f"{count} placeholder values",
|
|
71
|
+
data={"count": count},
|
|
72
|
+
)
|
|
73
|
+
],
|
|
74
|
+
raw_score=count,
|
|
75
|
+
confidence=0.95,
|
|
76
|
+
severity=Severity.LOW,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return candidates
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RareCategoryDetector(DetectorBase):
|
|
83
|
+
"""稀有类别(11.6):频数 < 5 且占比 < 0.001;仅高频字段(distinct 2~1000)启用。"""
|
|
84
|
+
|
|
85
|
+
detector_id = "rare_category"
|
|
86
|
+
display_name = "Rare Category"
|
|
87
|
+
description = "Reports categories with very low frequency in high-cardinality fields."
|
|
88
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
89
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
90
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {
|
|
91
|
+
"min_count": 5,
|
|
92
|
+
"min_ratio": 0.001,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
96
|
+
candidates: list[IssueCandidate] = []
|
|
97
|
+
for col in context.columns:
|
|
98
|
+
q = quote_ident(col)
|
|
99
|
+
meta = context.handle.sql_aggregate(
|
|
100
|
+
f"SELECT count(*) AS n, count(DISTINCT {q}) AS d, count({q}) AS nn FROM data"
|
|
101
|
+
).table
|
|
102
|
+
row = meta.to_pylist()[0]
|
|
103
|
+
total = int(row["n"])
|
|
104
|
+
distinct = int(row["d"])
|
|
105
|
+
non_null = int(row["nn"])
|
|
106
|
+
if total <= 0:
|
|
107
|
+
continue
|
|
108
|
+
unique_ratio = distinct / non_null if non_null else 1.0
|
|
109
|
+
if not (2 <= distinct <= 1000) or unique_ratio > 0.9:
|
|
110
|
+
continue # 单值/爆炸列不适用(category_explosion 处理后者)
|
|
111
|
+
table = context.handle.sql_aggregate(
|
|
112
|
+
f"SELECT {q} AS v, count(*) AS n FROM data "
|
|
113
|
+
f"WHERE {q} IS NOT NULL GROUP BY {q} "
|
|
114
|
+
f"HAVING count(*) < 5 AND count(*)::DOUBLE * 1000.0 < {total} "
|
|
115
|
+
f"ORDER BY n LIMIT 20"
|
|
116
|
+
).table
|
|
117
|
+
values = table.column("v").to_pylist()
|
|
118
|
+
counts = table.column("n").to_pylist()
|
|
119
|
+
if not values:
|
|
120
|
+
continue
|
|
121
|
+
candidates.append(
|
|
122
|
+
make_candidate(
|
|
123
|
+
detector_id=self.detector_id,
|
|
124
|
+
detector_version=self.detector_version,
|
|
125
|
+
context=context,
|
|
126
|
+
issue_type="rare_category",
|
|
127
|
+
columns=[col],
|
|
128
|
+
affected_count=sum(int(c) for c in counts),
|
|
129
|
+
evidence=[
|
|
130
|
+
make_evidence(
|
|
131
|
+
detector_id=self.detector_id,
|
|
132
|
+
detector_version=self.detector_version,
|
|
133
|
+
evidence_type=EvidenceType.STATISTICAL_MEASURE,
|
|
134
|
+
description=f"{len(values)} rare categories",
|
|
135
|
+
data={
|
|
136
|
+
"categories": [
|
|
137
|
+
(_json_safe(v), int(c))
|
|
138
|
+
for v, c in zip(values, counts, strict=True)
|
|
139
|
+
]
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
],
|
|
143
|
+
raw_score=len(values),
|
|
144
|
+
confidence=0.85,
|
|
145
|
+
severity=Severity.LOW,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return candidates
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class CategoryExplosionDetector(DetectorBase):
|
|
152
|
+
"""类别爆炸(11.6):唯一值比例 > 0.9 且列名不像标识符。"""
|
|
153
|
+
|
|
154
|
+
detector_id = "category_explosion"
|
|
155
|
+
display_name = "Category Explosion"
|
|
156
|
+
description = "Flags near-unique columns that may be free-text masquerading as categories."
|
|
157
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
158
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
159
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {"unique_ratio_threshold": 0.9}
|
|
160
|
+
|
|
161
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
162
|
+
candidates: list[IssueCandidate] = []
|
|
163
|
+
for col in context.columns:
|
|
164
|
+
lowered = col.lower()
|
|
165
|
+
if any(hint in lowered for hint in _IDENTIFIER_HINTS):
|
|
166
|
+
continue
|
|
167
|
+
q = quote_ident(col)
|
|
168
|
+
table = context.handle.sql_aggregate(
|
|
169
|
+
f"SELECT count({q}) AS nn, count(DISTINCT {q}) AS d FROM data"
|
|
170
|
+
).table
|
|
171
|
+
row = table.to_pylist()[0]
|
|
172
|
+
non_null = int(row["nn"])
|
|
173
|
+
distinct = int(row["d"])
|
|
174
|
+
if non_null <= 0:
|
|
175
|
+
continue
|
|
176
|
+
ratio = distinct / non_null
|
|
177
|
+
if ratio > 0.9:
|
|
178
|
+
candidates.append(
|
|
179
|
+
make_candidate(
|
|
180
|
+
detector_id=self.detector_id,
|
|
181
|
+
detector_version=self.detector_version,
|
|
182
|
+
context=context,
|
|
183
|
+
issue_type="category_explosion",
|
|
184
|
+
columns=[col],
|
|
185
|
+
affected_count=distinct,
|
|
186
|
+
evidence=[
|
|
187
|
+
make_evidence(
|
|
188
|
+
detector_id=self.detector_id,
|
|
189
|
+
detector_version=self.detector_version,
|
|
190
|
+
evidence_type=EvidenceType.STATISTICAL_MEASURE,
|
|
191
|
+
description=f"unique_ratio={ratio:.3f} > 0.9",
|
|
192
|
+
data={"unique_ratio": round(ratio, 6), "distinct": distinct},
|
|
193
|
+
)
|
|
194
|
+
],
|
|
195
|
+
raw_score=ratio,
|
|
196
|
+
confidence=0.8,
|
|
197
|
+
severity=Severity.LOW,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return candidates
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class InconsistentCaseDetector(DetectorBase):
|
|
204
|
+
"""大小写不一致(11.6):归一化(小写)后同一值出现 ≥2 种形态(如 California/california)。"""
|
|
205
|
+
|
|
206
|
+
detector_id = "inconsistent_case"
|
|
207
|
+
display_name = "Inconsistent Case"
|
|
208
|
+
description = "Reports normalized values that appear in multiple case forms."
|
|
209
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
210
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
211
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {"min_group_rows": 2}
|
|
212
|
+
|
|
213
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
214
|
+
candidates: list[IssueCandidate] = []
|
|
215
|
+
for col in string_columns(context):
|
|
216
|
+
q = quote_ident(col)
|
|
217
|
+
table = context.handle.sql_aggregate(
|
|
218
|
+
f"SELECT lower(trim({q})) AS k, count(*) AS n, count(DISTINCT trim({q})) AS forms "
|
|
219
|
+
f"FROM data WHERE {q} IS NOT NULL "
|
|
220
|
+
f"GROUP BY lower(trim({q})) "
|
|
221
|
+
f"HAVING count(DISTINCT trim({q})) >= 2 AND count(*) >= 2 "
|
|
222
|
+
f"ORDER BY n DESC LIMIT 20"
|
|
223
|
+
).table
|
|
224
|
+
keys = table.column("k").to_pylist()
|
|
225
|
+
counts = table.column("n").to_pylist()
|
|
226
|
+
forms = table.column("forms").to_pylist()
|
|
227
|
+
if not keys:
|
|
228
|
+
continue
|
|
229
|
+
affected = sum(int(c) for c in counts)
|
|
230
|
+
candidates.append(
|
|
231
|
+
make_candidate(
|
|
232
|
+
detector_id=self.detector_id,
|
|
233
|
+
detector_version=self.detector_version,
|
|
234
|
+
context=context,
|
|
235
|
+
issue_type="inconsistent_case",
|
|
236
|
+
columns=[col],
|
|
237
|
+
affected_count=affected,
|
|
238
|
+
evidence=[
|
|
239
|
+
make_evidence(
|
|
240
|
+
detector_id=self.detector_id,
|
|
241
|
+
detector_version=self.detector_version,
|
|
242
|
+
evidence_type=EvidenceType.STATISTICAL_MEASURE,
|
|
243
|
+
description=f"{affected} rows in {len(keys)} case-variant groups",
|
|
244
|
+
data={"groups": list(zip(keys, counts, forms, strict=True))},
|
|
245
|
+
)
|
|
246
|
+
],
|
|
247
|
+
raw_score=len(keys),
|
|
248
|
+
confidence=0.9,
|
|
249
|
+
severity=Severity.LOW,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
return candidates
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""公式注入检测器(11.7):值以 = + - @ \\t \\r 开头(导出风险)。"""
|
|
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_literal,
|
|
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
|
+
FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FormulaInjectionDetector(DetectorBase):
|
|
23
|
+
"""公式注入(11.7):CSV 导出时可能被电子表格执行。"""
|
|
24
|
+
|
|
25
|
+
detector_id = "suspicious_formula_injection"
|
|
26
|
+
display_name = "Formula Injection"
|
|
27
|
+
description = "Reports values starting with spreadsheet formula prefixes."
|
|
28
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
29
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
30
|
+
|
|
31
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
32
|
+
candidates: list[IssueCandidate] = []
|
|
33
|
+
prefixes = ", ".join(
|
|
34
|
+
f"chr({ord(p)})" if ord(p) < 32 else quote_literal(p)
|
|
35
|
+
for p in FORMULA_INJECTION_PREFIXES
|
|
36
|
+
)
|
|
37
|
+
for col in string_columns(context):
|
|
38
|
+
q = quote_ident(col)
|
|
39
|
+
table = context.handle.sql_aggregate(
|
|
40
|
+
f"SELECT count(*) AS n FROM data WHERE left({q}, 1) IN ({prefixes})"
|
|
41
|
+
).table
|
|
42
|
+
count = int(table.column("n").to_pylist()[0])
|
|
43
|
+
if count > 0:
|
|
44
|
+
candidates.append(
|
|
45
|
+
make_candidate(
|
|
46
|
+
detector_id=self.detector_id,
|
|
47
|
+
detector_version=self.detector_version,
|
|
48
|
+
context=context,
|
|
49
|
+
issue_type="suspicious_formula_injection",
|
|
50
|
+
columns=[col],
|
|
51
|
+
affected_count=count,
|
|
52
|
+
evidence=[
|
|
53
|
+
make_evidence(
|
|
54
|
+
detector_id=self.detector_id,
|
|
55
|
+
detector_version=self.detector_version,
|
|
56
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
57
|
+
description=f"{count} values start with formula-injection prefixes",
|
|
58
|
+
data={"count": count},
|
|
59
|
+
)
|
|
60
|
+
],
|
|
61
|
+
raw_score=count,
|
|
62
|
+
confidence=0.95,
|
|
63
|
+
severity=Severity.LOW,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
return candidates
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""缺失类检测器(11.3):excessive_null_rate / suspicious_missing_token。"""
|
|
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_literal,
|
|
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
|
+
_MISSING_TOKENS = (
|
|
20
|
+
"na",
|
|
21
|
+
"n/a",
|
|
22
|
+
"null",
|
|
23
|
+
"none",
|
|
24
|
+
"-",
|
|
25
|
+
"?",
|
|
26
|
+
"unknown",
|
|
27
|
+
"missing",
|
|
28
|
+
"todo",
|
|
29
|
+
"tbd",
|
|
30
|
+
"n.a.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExcessiveNullRateDetector(DetectorBase):
|
|
35
|
+
"""缺失率过高(11.3):null_rate > threshold(默认 0.05,critical 列收紧至 0.01)。"""
|
|
36
|
+
|
|
37
|
+
detector_id = "excessive_null_rate"
|
|
38
|
+
display_name = "Excessive Null Rate"
|
|
39
|
+
description = "Reports columns whose null ratio exceeds the configured threshold."
|
|
40
|
+
quality_dimension = QualityDimension.COMPLETENESS
|
|
41
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
42
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {
|
|
43
|
+
"threshold": 0.05,
|
|
44
|
+
"critical_threshold": 0.01,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
48
|
+
# MVP 固定默认阈值;可配置化经 YAML 契约(Step 13)与 detector options 接入
|
|
49
|
+
threshold = 0.05
|
|
50
|
+
candidates: list[IssueCandidate] = []
|
|
51
|
+
for col in context.columns:
|
|
52
|
+
q = quote_ident(col)
|
|
53
|
+
table = context.handle.sql_aggregate(
|
|
54
|
+
f"SELECT count(*) AS n, sum({q} IS NULL) AS nulls FROM data"
|
|
55
|
+
).table
|
|
56
|
+
total = int(table.column("n").to_pylist()[0])
|
|
57
|
+
nulls = int(table.column("nulls").to_pylist()[0])
|
|
58
|
+
if total <= 0:
|
|
59
|
+
continue
|
|
60
|
+
ratio = nulls / total
|
|
61
|
+
if ratio > threshold:
|
|
62
|
+
severity = (
|
|
63
|
+
Severity.HIGH
|
|
64
|
+
if ratio > 0.3
|
|
65
|
+
else Severity.MEDIUM
|
|
66
|
+
if ratio > 0.1
|
|
67
|
+
else Severity.LOW
|
|
68
|
+
)
|
|
69
|
+
candidates.append(
|
|
70
|
+
make_candidate(
|
|
71
|
+
detector_id=self.detector_id,
|
|
72
|
+
detector_version=self.detector_version,
|
|
73
|
+
context=context,
|
|
74
|
+
issue_type="excessive_null_rate",
|
|
75
|
+
columns=[col],
|
|
76
|
+
affected_count=nulls,
|
|
77
|
+
evidence=[
|
|
78
|
+
make_evidence(
|
|
79
|
+
detector_id=self.detector_id,
|
|
80
|
+
detector_version=self.detector_version,
|
|
81
|
+
evidence_type=EvidenceType.STATISTICAL_MEASURE,
|
|
82
|
+
description=f"null_ratio={ratio:.4f} exceeds threshold={threshold}",
|
|
83
|
+
data={
|
|
84
|
+
"null_ratio": round(ratio, 6),
|
|
85
|
+
"threshold": threshold,
|
|
86
|
+
"nulls": nulls,
|
|
87
|
+
"total": total,
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
],
|
|
91
|
+
raw_score=ratio,
|
|
92
|
+
confidence=0.95,
|
|
93
|
+
severity=severity,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return candidates
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SuspiciousMissingTokenDetector(DetectorBase):
|
|
100
|
+
"""特殊缺失标记(11.3):标记占比 > 0.005。"""
|
|
101
|
+
|
|
102
|
+
detector_id = "suspicious_missing_token"
|
|
103
|
+
display_name = "Suspicious Missing Token"
|
|
104
|
+
description = "Reports values that are common stand-ins for missing data."
|
|
105
|
+
quality_dimension = QualityDimension.COMPLETENESS
|
|
106
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
107
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {"ratio_threshold": 0.005}
|
|
108
|
+
|
|
109
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
110
|
+
return bool(string_columns(context))
|
|
111
|
+
|
|
112
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
113
|
+
threshold = 0.005
|
|
114
|
+
candidates: list[IssueCandidate] = []
|
|
115
|
+
for col in string_columns(context):
|
|
116
|
+
q = quote_ident(col)
|
|
117
|
+
tokens = ", ".join(quote_literal(t) for t in _MISSING_TOKENS)
|
|
118
|
+
table = context.handle.sql_aggregate(
|
|
119
|
+
f"SELECT count(*) AS n FROM data WHERE lower(trim({q})) IN ({tokens})"
|
|
120
|
+
).table
|
|
121
|
+
count = int(table.column("n").to_pylist()[0])
|
|
122
|
+
if count == 0:
|
|
123
|
+
continue
|
|
124
|
+
table_total = context.handle.sql_aggregate("SELECT count(*) AS n FROM data").table
|
|
125
|
+
total = int(table_total.column("n").to_pylist()[0])
|
|
126
|
+
ratio = count / total if total else 0.0
|
|
127
|
+
if ratio > threshold:
|
|
128
|
+
candidates.append(
|
|
129
|
+
make_candidate(
|
|
130
|
+
detector_id=self.detector_id,
|
|
131
|
+
detector_version=self.detector_version,
|
|
132
|
+
context=context,
|
|
133
|
+
issue_type="suspicious_missing_token",
|
|
134
|
+
columns=[col],
|
|
135
|
+
affected_count=count,
|
|
136
|
+
evidence=[
|
|
137
|
+
make_evidence(
|
|
138
|
+
detector_id=self.detector_id,
|
|
139
|
+
detector_version=self.detector_version,
|
|
140
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
141
|
+
description=f"{count} values are missing stand-ins ({ratio:.4f})",
|
|
142
|
+
data={"count": count, "ratio": round(ratio, 6)},
|
|
143
|
+
)
|
|
144
|
+
],
|
|
145
|
+
raw_score=ratio,
|
|
146
|
+
confidence=0.9,
|
|
147
|
+
severity=Severity.LOW,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
return candidates
|