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,330 @@
1
+ """契约导出器(V1:Pandera / Great Expectations)。
2
+
3
+ 纯函数式:Contract → 外部生态格式(零运行时依赖,不引入
4
+ pandera / great_expectations 包)。契约是可移植质量声明的
5
+ 单一来源,导出使其落地既有数据栈。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from datasentry_core.models.contract import ColumnCheck, ColumnContract, Contract
13
+ from datasentry_core.models.enums import RuleType
14
+ from datasentry_core.models.rules import Rule
15
+
16
+ #: 语义类型 → Pandera dtype(映射表保持收敛,未知类型回退 object)
17
+ _PANDERA_DTYPE = {
18
+ "string": "pa.String()",
19
+ "text": "pa.String()",
20
+ "integer": "pa.Int64()",
21
+ "bigint": "pa.Int64()",
22
+ "float": "pa.Float64()",
23
+ "double": "pa.Float64()",
24
+ "boolean": "pa.Bool()",
25
+ "datetime": "pa.DateTime()",
26
+ "date": "pa.DateTime()",
27
+ "timestamp": "pa.DateTime()",
28
+ }
29
+
30
+ _GE_DTYPE = {
31
+ "string": "String",
32
+ "text": "String",
33
+ "integer": "Integer",
34
+ "bigint": "Integer",
35
+ "float": "Float",
36
+ "double": "Float",
37
+ "boolean": "Boolean",
38
+ "datetime": "DateTime",
39
+ "date": "DateTime",
40
+ "timestamp": "DateTime",
41
+ }
42
+
43
+ #: RuleType → Pandera Check 生成器(value 经 repr 嵌入代码)
44
+ _RULE_PANDERA: dict[str, str] = {
45
+ RuleType.VALUE_RANGE.value: "pa.Check.ge({min}) & pa.Check.le({max})",
46
+ RuleType.ALLOWED_VALUES.value: "pa.Check.isin({allowed})",
47
+ RuleType.REGEX.value: "pa.Check.str_matches({pattern})",
48
+ RuleType.NOT_NULL.value: "pa.Check.not_null()",
49
+ }
50
+
51
+
52
+ def _pandera_checks(column: ColumnContract, name: str) -> list[str]:
53
+ """列契约 + 内嵌 checks → Pandera Check 表达式列表。"""
54
+ checks: list[str] = []
55
+ if column.min is not None:
56
+ checks.append(f"pa.Check.ge({column.min!r}, title='{name}_min')")
57
+ if column.max is not None:
58
+ checks.append(f"pa.Check.le({column.max!r}, title='{name}_max')")
59
+ if column.allowed_values is not None:
60
+ checks.append(f"pa.Check.isin({column.allowed_values!r}, title='{name}_allowed')")
61
+ if column.regex is not None:
62
+ checks.append(f"pa.Check.str_matches({column.regex!r}, title='{name}_regex')")
63
+ for check in column.checks:
64
+ checks.extend(_pandera_column_check(check, name))
65
+ return checks
66
+
67
+
68
+ def _pandera_column_check(check: ColumnCheck, name: str) -> list[str]:
69
+ """单条 ColumnCheck → Pandera Check 表达式。"""
70
+ if check.type == "regex" and check.pattern:
71
+ return [f"pa.Check.str_matches({check.pattern!r}, title='{name}_check_regex')"]
72
+ if check.type == "range":
73
+ parts = []
74
+ if check.min is not None:
75
+ parts.append(f"pa.Check.ge({check.min!r})")
76
+ if check.max is not None:
77
+ parts.append(f"pa.Check.le({check.max!r})")
78
+ return parts
79
+ if check.type == "allowed_values" and check.allowed_values is not None:
80
+ return [f"pa.Check.isin({check.allowed_values!r}, title='{name}_check_allowed')"]
81
+ if check.type == "not_null":
82
+ return ["pa.Check.not_null()"]
83
+ if check.type == "unique":
84
+ return ["pa.Check.unique()"]
85
+ return [] # custom:不可表达,忽略
86
+
87
+
88
+ def _pandera_rule_check(rule: Rule) -> str | None:
89
+ """顶层 Rule → Pandera Check 表达式;不可表达的返回 None。"""
90
+ if rule.type == RuleType.NOT_NULL:
91
+ return "pa.Check.not_null()"
92
+ if rule.type == RuleType.UNIQUENESS:
93
+ return "pa.Check.unique()"
94
+ if rule.when is None:
95
+ return None
96
+ cond = rule.when
97
+ if rule.type == RuleType.VALUE_RANGE:
98
+ if cond.operator == "between" and isinstance(cond.value, list) and len(cond.value) == 2:
99
+ return f"pa.Check.ge({cond.value[0]!r}) & pa.Check.le({cond.value[1]!r})"
100
+ if cond.operator in ("gte", "lte", "gt", "lt") and isinstance(cond.value, (int, float)):
101
+ op = {"gte": "ge", "lte": "le", "gt": "gt", "lt": "lt"}[cond.operator]
102
+ return f"pa.Check.{op}({cond.value!r})"
103
+ return None
104
+ if rule.type == RuleType.ALLOWED_VALUES and cond.operator == "in":
105
+ if isinstance(cond.value, list):
106
+ return f"pa.Check.isin({cond.value!r})"
107
+ return None
108
+ if rule.type == RuleType.REGEX and cond.operator == "matches" and cond.value:
109
+ return f"pa.Check.str_matches({str(cond.value)!r})"
110
+ return None
111
+
112
+
113
+ def to_pandera(contract: Contract) -> str:
114
+ """Contract → Pandera DataFrameSchema 代码(字符串,用户可复制/执行)。"""
115
+ lines = [
116
+ "import pandera as pa",
117
+ "import pandas as pd",
118
+ "",
119
+ f"# generated by datasentry contract export from dataset: {contract.dataset.name}",
120
+ "# unsupported rules are listed as comments (custom/conditional/aggregate)",
121
+ "schema = pa.DataFrameSchema({",
122
+ ]
123
+ for name, column in contract.columns.items():
124
+ dtype = _PANDERA_DTYPE.get(column.type, "pa.Object()")
125
+ args = [f" {name!r}: pa.Column("]
126
+ args.append(f" dtype={dtype},")
127
+ if not column.nullable:
128
+ args.append(" nullable=False,")
129
+ if column.unique:
130
+ args.append(" unique=True,")
131
+ checks = _pandera_checks(column, name)
132
+ for rule in contract.rules:
133
+ if name not in rule.columns:
134
+ continue
135
+ expr = _pandera_rule_check(rule)
136
+ if expr is not None:
137
+ checks.append(expr)
138
+ if checks:
139
+ args.append(" checks=[")
140
+ args.extend(f" {c}," for c in checks)
141
+ args.append(" ],")
142
+ args.append(" ),")
143
+ lines.extend(args)
144
+ lines.append("})")
145
+ unsupported = [
146
+ f"# unsupported rule '{r.id}' (type={r.type.value})"
147
+ for r in contract.rules
148
+ if _pandera_rule_check(r) is None
149
+ ]
150
+ if unsupported:
151
+ lines.append("")
152
+ lines.extend(unsupported)
153
+ return "\n".join(lines) + "\n"
154
+
155
+
156
+ def _ge_column_expectations(name: str, column: ColumnContract) -> list[dict[str, Any]]:
157
+ """列契约 → GE expect_* 序列。"""
158
+ exps: list[dict[str, Any]] = []
159
+ if column.min is not None or column.max is not None:
160
+ kwargs: dict[str, Any] = {"column": name}
161
+ if column.min is not None:
162
+ kwargs["min_value"] = column.min
163
+ if column.max is not None:
164
+ kwargs["max_value"] = column.max
165
+ exps.append({"expectation_type": "expect_column_values_to_be_between", "kwargs": kwargs})
166
+ if column.allowed_values is not None:
167
+ exps.append(
168
+ {
169
+ "expectation_type": "expect_column_values_to_be_in_set",
170
+ "kwargs": {"column": name, "value_set": column.allowed_values},
171
+ }
172
+ )
173
+ if column.regex is not None:
174
+ exps.append(
175
+ {
176
+ "expectation_type": "expect_column_values_to_match_regex",
177
+ "kwargs": {"column": name, "regex": column.regex},
178
+ }
179
+ )
180
+ for check in column.checks:
181
+ exps.extend(_ge_column_check(name, check))
182
+ if not column.nullable:
183
+ exps.append(
184
+ {
185
+ "expectation_type": "expect_column_values_to_not_be_null",
186
+ "kwargs": {"column": name},
187
+ }
188
+ )
189
+ if column.unique:
190
+ exps.append(
191
+ {
192
+ "expectation_type": "expect_column_values_to_be_unique",
193
+ "kwargs": {"column": name},
194
+ }
195
+ )
196
+ return exps
197
+
198
+
199
+ def _ge_column_check(name: str, check: ColumnCheck) -> list[dict[str, Any]]:
200
+ """单条 ColumnCheck → GE expect_* 序列。"""
201
+ if check.type == "regex" and check.pattern:
202
+ return [
203
+ {
204
+ "expectation_type": "expect_column_values_to_match_regex",
205
+ "kwargs": {"column": name, "regex": check.pattern},
206
+ }
207
+ ]
208
+ if check.type == "range":
209
+ if check.min is None and check.max is None:
210
+ return []
211
+ kwargs: dict[str, Any] = {"column": name}
212
+ if check.min is not None:
213
+ kwargs["min_value"] = check.min
214
+ if check.max is not None:
215
+ kwargs["max_value"] = check.max
216
+ return [
217
+ {
218
+ "expectation_type": "expect_column_values_to_be_between",
219
+ "kwargs": kwargs,
220
+ }
221
+ ]
222
+ if check.type == "allowed_values" and check.allowed_values is not None:
223
+ return [
224
+ {
225
+ "expectation_type": "expect_column_values_to_be_in_set",
226
+ "kwargs": {"column": name, "value_set": check.allowed_values},
227
+ }
228
+ ]
229
+ if check.type == "not_null":
230
+ return [
231
+ {
232
+ "expectation_type": "expect_column_values_to_not_be_null",
233
+ "kwargs": {"column": name},
234
+ }
235
+ ]
236
+ if check.type == "unique":
237
+ return [
238
+ {
239
+ "expectation_type": "expect_column_values_to_be_unique",
240
+ "kwargs": {"column": name},
241
+ }
242
+ ]
243
+ return []
244
+
245
+
246
+ def _ge_rule_expectations(rule: Rule) -> list[dict[str, Any]]:
247
+ """顶层 Rule → GE expect_* 序列。"""
248
+ if not rule.columns:
249
+ return []
250
+ name = rule.columns[0]
251
+ if rule.type == RuleType.NOT_NULL:
252
+ return [
253
+ {
254
+ "expectation_type": "expect_column_values_to_not_be_null",
255
+ "kwargs": {"column": name},
256
+ }
257
+ ]
258
+ if rule.type == RuleType.UNIQUENESS:
259
+ return [
260
+ {
261
+ "expectation_type": "expect_column_values_to_be_unique",
262
+ "kwargs": {"column": name},
263
+ }
264
+ ]
265
+ if rule.when is None:
266
+ return []
267
+ cond = rule.when
268
+ if rule.type == RuleType.VALUE_RANGE:
269
+ if cond.operator == "between" and isinstance(cond.value, list) and len(cond.value) == 2:
270
+ return [
271
+ {
272
+ "expectation_type": "expect_column_values_to_be_between",
273
+ "kwargs": {
274
+ "column": name,
275
+ "min_value": cond.value[0],
276
+ "max_value": cond.value[1],
277
+ },
278
+ }
279
+ ]
280
+ if cond.operator in ("gte", "lte", "gt", "lt") and isinstance(cond.value, (int, float)):
281
+ exp_type = {
282
+ "gte": "expect_column_values_to_be_greater_than_or_equal_to",
283
+ "lte": "expect_column_values_to_be_less_than_or_equal_to",
284
+ "gt": "expect_column_values_to_be_greater_than",
285
+ "lt": "expect_column_values_to_be_less_than",
286
+ }[cond.operator]
287
+ return [{"expectation_type": exp_type, "kwargs": {"column": name, "value": cond.value}}]
288
+ return []
289
+ if rule.type == RuleType.ALLOWED_VALUES and cond.operator == "in":
290
+ if isinstance(cond.value, list):
291
+ return [
292
+ {
293
+ "expectation_type": "expect_column_values_to_be_in_set",
294
+ "kwargs": {"column": name, "value_set": cond.value},
295
+ }
296
+ ]
297
+ return []
298
+ if rule.type == RuleType.REGEX and cond.operator == "matches" and cond.value:
299
+ return [
300
+ {
301
+ "expectation_type": "expect_column_values_to_match_regex",
302
+ "kwargs": {"column": name, "regex": str(cond.value)},
303
+ }
304
+ ]
305
+ return []
306
+
307
+
308
+ def to_great_expectations(contract: Contract) -> dict[str, Any]:
309
+ """Contract → GE ExpectationSuite 字典(完整 suite 文档)。"""
310
+ expectations: list[dict[str, Any]] = []
311
+ for name, column in contract.columns.items():
312
+ expectations.extend(_ge_column_expectations(name, column))
313
+ if column.type in _GE_DTYPE:
314
+ expectations.append(
315
+ {
316
+ "expectation_type": "expect_column_values_to_be_of_type",
317
+ "kwargs": {"column": name, "type_": _GE_DTYPE[column.type]},
318
+ }
319
+ )
320
+ for rule in contract.rules:
321
+ expectations.extend(_ge_rule_expectations(rule))
322
+ return {
323
+ "expectation_suite_name": f"{contract.dataset.name}.datasentry",
324
+ "expectations": expectations,
325
+ "meta": {
326
+ "datasentry_version": contract.version,
327
+ "dataset": contract.dataset.name,
328
+ "description": contract.dataset.description,
329
+ },
330
+ }
@@ -0,0 +1,15 @@
1
+ """检测器包(Step 5 注册表 + Step 6 起接入确定性检测器)。"""
2
+
3
+ from datasentry_core.detectors.base import (
4
+ DetectionContext,
5
+ Detector,
6
+ DetectorRegistry,
7
+ filter_by_config,
8
+ )
9
+
10
+ __all__ = [
11
+ "DetectionContext",
12
+ "Detector",
13
+ "DetectorRegistry",
14
+ "filter_by_config",
15
+ ]
@@ -0,0 +1,120 @@
1
+ """检测器注册表(11.1):注册/查询/能力过滤/启停。
2
+
3
+ Detector 协议与规格 11 章一致;`metadata()` 为可选扩展(UI 与文档生成用)。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import builtins
9
+ import threading
10
+ from collections.abc import Sequence
11
+ from typing import ClassVar, Protocol, runtime_checkable
12
+
13
+ from datasentry_core.connectors.base import DataHandle
14
+ from datasentry_core.models.contract import TableReference
15
+ from datasentry_core.models.detector import DetectorMeta, IssueCandidate
16
+ from datasentry_core.models.enums import QualityDimension
17
+ from datasentry_core.models.profile import DatasetProfile
18
+ from datasentry_core.models.scan import ScanConfig
19
+
20
+
21
+ class DetectionContext:
22
+ """一次检测的上下文:句柄 + 扫描配置 + 可选画像/采样。
23
+
24
+ 懒加载字段由调度层注入(Step 7),检测器不得自行打开数据源。
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ dataset_id: str,
30
+ table_name: str | None,
31
+ columns: list[str],
32
+ handle: DataHandle,
33
+ config: ScanConfig | None = None,
34
+ profile: DatasetProfile | None = None,
35
+ sample_rows: int | None = None,
36
+ references: list[TableReference] | None = None,
37
+ ) -> None:
38
+ self.dataset_id = dataset_id
39
+ self.table_name = table_name
40
+ self.columns = columns
41
+ self.handle = handle
42
+ self.config = config
43
+ self.profile = profile
44
+ self.sample_rows = sample_rows
45
+ self.references: list[TableReference] = references or []
46
+
47
+
48
+ @runtime_checkable
49
+ class Detector(Protocol):
50
+ """检测器协议(11 章)。"""
51
+
52
+ detector_id: ClassVar[str]
53
+ detector_version: ClassVar[str]
54
+ quality_dimension: ClassVar[QualityDimension]
55
+
56
+ def supports(self, context: DetectionContext) -> bool: ...
57
+
58
+ def detect(self, context: DetectionContext) -> list[IssueCandidate]: ...
59
+
60
+ def metadata(self) -> DetectorMeta: ...
61
+
62
+
63
+ class DetectorRegistry:
64
+ """线程安全注册表:register/get/list/enable(11.1)。"""
65
+
66
+ def __init__(self) -> None:
67
+ self._detectors: dict[str, Detector] = {}
68
+ self._enabled: set[str] = set()
69
+ self._lock = threading.RLock()
70
+
71
+ def register(self, detector: Detector) -> None:
72
+ """注册检测器;重复 detector_id 抛 ValueError。"""
73
+ with self._lock:
74
+ if detector.detector_id in self._detectors:
75
+ raise ValueError(f"detector already registered: {detector.detector_id}")
76
+ self._detectors[detector.detector_id] = detector
77
+ self._enabled.add(detector.detector_id)
78
+
79
+ def get(self, detector_id: str) -> Detector:
80
+ with self._lock:
81
+ try:
82
+ return self._detectors[detector_id]
83
+ except KeyError as exc:
84
+ raise KeyError(f"detector not registered: {detector_id}") from exc
85
+
86
+ def list(self, capability: str | None = None) -> list[Detector]:
87
+ """按注册顺序返回;capability 按 DetectorCapabilities 字段名过滤(True)。"""
88
+ with self._lock:
89
+ detectors = list(self._detectors.values())
90
+ if capability is None:
91
+ return detectors
92
+ return [d for d in detectors if getattr(d.metadata().capabilities, capability, False)]
93
+
94
+ def enable(self, detector_id: str, enabled: bool) -> None:
95
+ with self._lock:
96
+ if detector_id not in self._detectors:
97
+ raise KeyError(f"detector not registered: {detector_id}")
98
+ if enabled:
99
+ self._enabled.add(detector_id)
100
+ else:
101
+ self._enabled.discard(detector_id)
102
+
103
+ def is_enabled(self, detector_id: str) -> bool:
104
+ with self._lock:
105
+ return detector_id in self._enabled
106
+
107
+ def list_active(self) -> builtins.list[Detector]:
108
+ """启用的检测器(按注册顺序);ScanConfig.detectors 过滤在调度层做。"""
109
+ with self._lock:
110
+ return [d for d in self._detectors.values() if d.detector_id in self._enabled]
111
+
112
+
113
+ def filter_by_config(
114
+ detectors: Sequence[Detector], config_detectors: list[str] | None
115
+ ) -> list[Detector]:
116
+ """ScanConfig.detectors 白名单过滤(None = 全部启用)。"""
117
+ if config_detectors is None:
118
+ return list(detectors)
119
+ selected = set(config_detectors)
120
+ return [d for d in detectors if d.detector_id in selected]
@@ -0,0 +1,156 @@
1
+ """确定性检测器公共设施:基类、SQL 工具、证据/候选构造(Step 6)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any, ClassVar
7
+
8
+ from datasentry_core.detectors.base import DetectionContext
9
+ from datasentry_core.models.detector import DetectorCapabilities, DetectorMeta, IssueCandidate
10
+ from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
11
+ from datasentry_core.models.evidence import Evidence
12
+
13
+
14
+ def quote_ident(column: str) -> str:
15
+ """SQL 标识符引号转义。"""
16
+ return '"' + column.replace('"', '""') + '"'
17
+
18
+
19
+ def quote_literal(value: str) -> str:
20
+ """SQL 单引号字面量转义。"""
21
+ return "'" + value.replace("'", "''") + "'"
22
+
23
+
24
+ def quote_re(pattern: str) -> str:
25
+ """正则字面量(SQL 单引号转义)。"""
26
+ return quote_literal(pattern)
27
+
28
+
29
+ def make_evidence(
30
+ detector_id: str,
31
+ detector_version: str,
32
+ evidence_type: EvidenceType,
33
+ description: str,
34
+ data: dict[str, Any] | None = None,
35
+ confidence: float = 1.0,
36
+ ) -> Evidence:
37
+ return Evidence(
38
+ evidence_id=f"ev_{uuid.uuid4().hex[:12]}",
39
+ evidence_type=evidence_type,
40
+ detector_id=detector_id,
41
+ detector_version=detector_version,
42
+ description=description,
43
+ data=data or {},
44
+ confidence=confidence,
45
+ )
46
+
47
+
48
+ def make_candidate(
49
+ detector_id: str,
50
+ detector_version: str,
51
+ context: DetectionContext,
52
+ issue_type: str,
53
+ columns: list[str],
54
+ affected_count: int,
55
+ evidence: list[Evidence],
56
+ raw_score: float,
57
+ confidence: float,
58
+ severity: Severity,
59
+ fpr: float = 0.1,
60
+ affected_rows: list[str] | None = None,
61
+ ) -> IssueCandidate:
62
+ return IssueCandidate(
63
+ issue_type=issue_type,
64
+ detector_id=detector_id,
65
+ detector_version=detector_version,
66
+ dataset_id=context.dataset_id,
67
+ table_name=context.table_name,
68
+ columns=columns,
69
+ affected_rows=affected_rows,
70
+ affected_count=affected_count,
71
+ evidence=evidence,
72
+ raw_score=raw_score,
73
+ confidence=confidence,
74
+ estimated_false_positive_risk=fpr,
75
+ suggested_severity=severity.value,
76
+ )
77
+
78
+
79
+ _STRING_TYPES = frozenset({"VARCHAR", "CHAR", "TEXT", "BPCHAR"})
80
+ # TIME 列不与 DATE/TIMESTAMP 互比(类型错误),MVP 不使用
81
+ _TEMPORAL_TYPES = frozenset({"DATE", "TIMESTAMP", "TIMESTAMPTZ"})
82
+ _NUMERIC_TYPES = frozenset(
83
+ {
84
+ "TINYINT",
85
+ "SMALLINT",
86
+ "INTEGER",
87
+ "BIGINT",
88
+ "HUGEINT",
89
+ "UTINYINT",
90
+ "USMALLINT",
91
+ "UINTEGER",
92
+ "UBIGINT",
93
+ "UHUGEINT",
94
+ "FLOAT",
95
+ "REAL",
96
+ "DOUBLE",
97
+ "DECIMAL",
98
+ }
99
+ )
100
+
101
+
102
+ def string_columns(context: DetectionContext) -> list[str]:
103
+ """字符串列名(按 schema 顺序)。"""
104
+ return [
105
+ c.name for c in context.handle.schema().columns if c.physical_type.upper() in _STRING_TYPES
106
+ ]
107
+
108
+
109
+ def numeric_columns(context: DetectionContext) -> list[str]:
110
+ """数值列名(按 schema 顺序)。"""
111
+ return [
112
+ c.name for c in context.handle.schema().columns if c.physical_type.upper() in _NUMERIC_TYPES
113
+ ]
114
+
115
+
116
+ def datetime_columns(context: DetectionContext) -> list[str]:
117
+ """日期时间列名(物理类型 DATE/TIMESTAMP/TIME,按 schema 顺序)。"""
118
+ return [
119
+ c.name
120
+ for c in context.handle.schema().columns
121
+ if c.physical_type.upper() in _TEMPORAL_TYPES
122
+ ]
123
+
124
+
125
+ class DetectorBase:
126
+ """检测器基类:类属性声明元数据,metadata() 统一构建。"""
127
+
128
+ detector_id: ClassVar[str] = ""
129
+ detector_version: ClassVar[str] = "1.0.0"
130
+ display_name: ClassVar[str] = ""
131
+ description: ClassVar[str] = ""
132
+ quality_dimension: ClassVar[QualityDimension] = QualityDimension.VALIDITY
133
+ capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities()
134
+ needs_llm: ClassVar[bool] = False
135
+ requires_reference: ClassVar[bool] = False
136
+ experimental: ClassVar[bool] = False
137
+ default_thresholds: ClassVar[dict[str, float | int | str]] = {}
138
+
139
+ def supports(self, context: DetectionContext) -> bool:
140
+ return bool(context.columns)
141
+
142
+ def detect(self, context: DetectionContext) -> list[IssueCandidate]:
143
+ raise NotImplementedError
144
+
145
+ def metadata(self) -> DetectorMeta:
146
+ return DetectorMeta(
147
+ detector_id=self.detector_id,
148
+ display_name=self.display_name,
149
+ description=self.description,
150
+ quality_dimension=self.quality_dimension,
151
+ capabilities=self.capabilities,
152
+ default_thresholds=dict(self.default_thresholds),
153
+ needs_llm=self.needs_llm,
154
+ requires_reference=self.requires_reference,
155
+ experimental=self.experimental,
156
+ )