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