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,424 @@
1
+ """缺失模式检测器(11.4 核心,Step 16):sudden/conditional/group/correlated。
2
+
3
+ 四个检测器全部 SQL pushdown、列级统计证据(双列/分组/时间桶场景不取行级证据,
4
+ ADR-017):
5
+ - sudden_missingness:时间桶缺失率突变(3× 整体或 ≥0.2,桶样本 ≥10)
6
+ - group_missingness:按类别列分组的目标列缺失率异常组
7
+ - conditional_missingness:定向级联缺失(A 缺失时 B 缺失率 ≥0.8)
8
+ - correlated_missingness:双列缺失高共现(共现率 ≥0.5)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import ClassVar
14
+
15
+ from datasentry_core.detectors.base import DetectionContext
16
+ from datasentry_core.detectors.common import (
17
+ DetectorBase,
18
+ datetime_columns,
19
+ make_candidate,
20
+ make_evidence,
21
+ quote_ident,
22
+ string_columns,
23
+ )
24
+ from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
25
+ from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
26
+
27
+ # 列对/列预算:防组合爆炸(性能预算见 ADR-017)
28
+ _NULL_RATE_MIN = 0.02
29
+ _PAIR_COL_CAP = 15
30
+ _PAIR_CAP = 50
31
+ _GROUP_COL_CAP = 10
32
+ _TARGET_COL_CAP = 5
33
+
34
+ # 时间桶粒度自适应:桶数不足则升级粒度
35
+ _BUCKET_GRANULARITIES = (("%Y-%m-%d", "day"), ("%Y-%m", "month"), ("%Y", "year"))
36
+ _BUCKET_MIN_COUNT = 5
37
+ _BUCKET_MIN_ROWS = 10
38
+
39
+ # 字符串时间列名特征(created_at / event_date 等)
40
+ _TIME_STR_HINTS = ("_at", "_time", "date", "time")
41
+
42
+ _ISO_DT_RE = r"^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?$"
43
+
44
+ # 阈值固定为规格默认值;可配置化经契约引擎(V1,ADR-004)接入
45
+ # 异常判定 = 桶/组缺失率 ≥ max(0.2, 整体 + 0.2):
46
+ # 相对 3× 在整体缺失率 >1/3 时会超 1 永不触发,故用绝对差(ADR-017)
47
+ _GROUP_MIN_RATIO = 0.2
48
+ _GROUP_MIN_GAP = 0.2
49
+ _CONDITIONAL_COVERAGE = 0.8
50
+ _CORRELATED_COVERAGE = 0.5
51
+ _MIN_COOCCURRENCE = 5
52
+
53
+
54
+ def _row_count(context: DetectionContext) -> int:
55
+ table = context.handle.sql_aggregate("SELECT count(*) AS n FROM data").table
56
+ return int(table.column("n").to_pylist()[0])
57
+
58
+
59
+ def _column_nulls(context: DetectionContext, column: str) -> tuple[int, int]:
60
+ q = quote_ident(column)
61
+ table = context.handle.sql_aggregate(
62
+ f"SELECT count(*) AS n, sum({q} IS NULL) AS nulls FROM data"
63
+ ).table
64
+ total = int(table.column("n").to_pylist()[0])
65
+ nulls = int(table.column("nulls").to_pylist()[0])
66
+ return total, nulls
67
+
68
+
69
+ def _null_rates(context: DetectionContext) -> dict[str, float]:
70
+ """全列 null 率(总行数统一)。"""
71
+ total = _row_count(context)
72
+ if total <= 0:
73
+ return {}
74
+ rates: dict[str, float] = {}
75
+ for col in context.columns:
76
+ _, nulls = _column_nulls(context, col)
77
+ rates[col] = nulls / total
78
+ return rates
79
+
80
+
81
+ def _sparse_columns(context: DetectionContext, rates: dict[str, float]) -> list[str]:
82
+ """缺失率 ≥ _NULL_RATE_MIN 的列(截断至 _PAIR_COL_CAP,按缺失率降序)。"""
83
+ ordered = [c for c in context.columns if rates.get(c, 0.0) >= _NULL_RATE_MIN]
84
+ ordered.sort(key=lambda c: rates[c], reverse=True)
85
+ return ordered[:_PAIR_COL_CAP]
86
+
87
+
88
+ def _anomalous_threshold(overall: float) -> float:
89
+ """桶/组缺失率异常阈值(绝对差判定,见模块 docstring)。"""
90
+ return max(_GROUP_MIN_RATIO, overall + _GROUP_MIN_GAP)
91
+
92
+
93
+ def _column_pairs(columns: list[str]) -> list[tuple[str, str]]:
94
+ """有序列对(按缺失率降序的列序),截断至 _PAIR_CAP。"""
95
+ pairs = [(a, b) for i, a in enumerate(columns) for b in columns[i + 1 :]]
96
+ return pairs[:_PAIR_CAP]
97
+
98
+
99
+ class CorrelatedMissingnessDetector(DetectorBase):
100
+ """缺失共现(11.4):两列同时缺失的行显著(对称,共现率 ≥0.5)。"""
101
+
102
+ detector_id = "correlated_missingness"
103
+ display_name = "Correlated Missingness"
104
+ description = "Reports column pairs that are missing together."
105
+ quality_dimension = QualityDimension.COMPLETENESS
106
+ capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
107
+
108
+ def supports(self, context: DetectionContext) -> bool:
109
+ return len(context.columns) >= 2
110
+
111
+ def detect(self, context: DetectionContext) -> list[IssueCandidate]:
112
+ rates = _null_rates(context)
113
+ candidates: list[IssueCandidate] = []
114
+ for a, b in _column_pairs(_sparse_columns(context, rates)):
115
+ qa, qb = quote_ident(a), quote_ident(b)
116
+ table = context.handle.sql_aggregate(
117
+ f"SELECT count(*) AS n, "
118
+ f"sum({qa} IS NULL AND {qb} IS NULL) AS both_null, "
119
+ f"sum({qa} IS NULL) AS a_null, "
120
+ f"sum({qb} IS NULL) AS b_null "
121
+ f"FROM data"
122
+ ).table
123
+ both_null = int(table.column("both_null").to_pylist()[0])
124
+ a_null = int(table.column("a_null").to_pylist()[0])
125
+ b_null = int(table.column("b_null").to_pylist()[0])
126
+ if both_null < _MIN_COOCCURRENCE:
127
+ continue
128
+ denominator = min(a_null, b_null)
129
+ coverage = both_null / denominator if denominator else 0.0
130
+ if coverage >= _CORRELATED_COVERAGE:
131
+ candidates.append(
132
+ make_candidate(
133
+ detector_id=self.detector_id,
134
+ detector_version=self.detector_version,
135
+ context=context,
136
+ issue_type="correlated_missingness",
137
+ columns=[a, b],
138
+ affected_count=both_null,
139
+ evidence=[
140
+ make_evidence(
141
+ detector_id=self.detector_id,
142
+ detector_version=self.detector_version,
143
+ evidence_type=EvidenceType.STATISTICAL_MEASURE,
144
+ description=(
145
+ f"{both_null} rows are missing in both {a} and {b} "
146
+ f"(coverage={coverage:.2f})"
147
+ ),
148
+ data={
149
+ "column_a": a,
150
+ "column_b": b,
151
+ "both_null": both_null,
152
+ "a_null": a_null,
153
+ "b_null": b_null,
154
+ "coverage": round(coverage, 6),
155
+ },
156
+ )
157
+ ],
158
+ raw_score=coverage,
159
+ confidence=0.85,
160
+ severity=Severity.LOW,
161
+ fpr=0.15,
162
+ )
163
+ )
164
+ return candidates
165
+
166
+
167
+ class ConditionalMissingnessDetector(DetectorBase):
168
+ """条件性缺失(11.4):A 缺失 ⟹ B 缺失 的级联(定向覆盖率 ≥0.8)。"""
169
+
170
+ detector_id = "conditional_missingness"
171
+ display_name = "Conditional Missingness"
172
+ description = "Reports columns whose values are missing whenever another is."
173
+ quality_dimension = QualityDimension.COMPLETENESS
174
+ capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
175
+
176
+ def supports(self, context: DetectionContext) -> bool:
177
+ return len(context.columns) >= 2
178
+
179
+ def detect(self, context: DetectionContext) -> list[IssueCandidate]:
180
+ rates = _null_rates(context)
181
+ candidates: list[IssueCandidate] = []
182
+ for a, b in _column_pairs(_sparse_columns(context, rates)):
183
+ qa, qb = quote_ident(a), quote_ident(b)
184
+ table = context.handle.sql_aggregate(
185
+ f"SELECT count(*) AS n, "
186
+ f"sum({qa} IS NULL AND {qb} IS NULL) AS both_null, "
187
+ f"sum({qa} IS NULL) AS a_null "
188
+ f"FROM data"
189
+ ).table
190
+ both_null = int(table.column("both_null").to_pylist()[0])
191
+ a_null = int(table.column("a_null").to_pylist()[0])
192
+ if a_null < 10:
193
+ continue
194
+ coverage = both_null / a_null
195
+ if coverage >= _CONDITIONAL_COVERAGE:
196
+ candidates.append(
197
+ make_candidate(
198
+ detector_id=self.detector_id,
199
+ detector_version=self.detector_version,
200
+ context=context,
201
+ issue_type="conditional_missingness",
202
+ columns=[a, b],
203
+ affected_count=both_null,
204
+ evidence=[
205
+ make_evidence(
206
+ detector_id=self.detector_id,
207
+ detector_version=self.detector_version,
208
+ evidence_type=EvidenceType.STATISTICAL_MEASURE,
209
+ description=(
210
+ f"{both_null} of {a_null} missing rows of {a} are also "
211
+ f"missing in {b} (coverage={coverage:.2f})"
212
+ ),
213
+ data={
214
+ "condition_column": a,
215
+ "target_column": b,
216
+ "both_null": both_null,
217
+ "condition_null": a_null,
218
+ "coverage": round(coverage, 6),
219
+ },
220
+ )
221
+ ],
222
+ raw_score=coverage,
223
+ confidence=0.85,
224
+ severity=Severity.MEDIUM,
225
+ fpr=0.15,
226
+ )
227
+ )
228
+ return candidates
229
+
230
+
231
+ def _distinct_counts(context: DetectionContext, columns: list[str]) -> dict[str, int]:
232
+ counts: dict[str, int] = {}
233
+ for col in columns:
234
+ q = quote_ident(col)
235
+ table = context.handle.sql_aggregate(
236
+ f"SELECT count(DISTINCT {q}) AS n FROM data WHERE {q} IS NOT NULL"
237
+ ).table
238
+ counts[col] = int(table.column("n").to_pylist()[0])
239
+ return counts
240
+
241
+
242
+ def _group_columns(context: DetectionContext) -> list[str]:
243
+ """类别分组列候选:字符串列中 distinct ∈ [2, 50](截断 _GROUP_COL_CAP)。"""
244
+ cols = [c for c in string_columns(context) if len(c) <= 64]
245
+ if len(cols) > _GROUP_COL_CAP:
246
+ cols = cols[:_GROUP_COL_CAP]
247
+ counts = _distinct_counts(context, cols)
248
+ return [c for c in cols if 2 <= counts[c] <= 50]
249
+
250
+
251
+ class GroupMissingnessDetector(DetectorBase):
252
+ """分组缺失(11.4):按类别列分组后目标列缺失率异常组(≥max(0.2, 3×整体))。"""
253
+
254
+ detector_id = "group_missingness"
255
+ display_name = "Grouped Missingness"
256
+ description = "Reports groups whose values are missing much more than usual."
257
+ quality_dimension = QualityDimension.COMPLETENESS
258
+ capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
259
+
260
+ def supports(self, context: DetectionContext) -> bool:
261
+ return len(context.columns) >= 2
262
+
263
+ def detect(self, context: DetectionContext) -> list[IssueCandidate]:
264
+ rates = _null_rates(context)
265
+ targets = [c for c in context.columns if rates.get(c, 0.0) > 0.0]
266
+ group_cols = [c for c in _group_columns(context) if c not in targets]
267
+ candidates: list[IssueCandidate] = []
268
+ for group_col in group_cols:
269
+ for target in targets:
270
+ if len(candidates) >= _TARGET_COL_CAP * len(group_cols):
271
+ break
272
+ qg, qt = quote_ident(group_col), quote_ident(target)
273
+ overall = rates.get(target, 0.0)
274
+ table = context.handle.sql_aggregate(
275
+ f"SELECT {qg} AS gv, count(*) AS n, sum({qt} IS NULL) AS nulls "
276
+ f"FROM data GROUP BY 1"
277
+ ).table
278
+ for row in table.to_pylist():
279
+ n = int(row["n"])
280
+ nulls = int(row["nulls"])
281
+ ratio = nulls / n if n else 0.0
282
+ if n < _BUCKET_MIN_ROWS or ratio < _anomalous_threshold(overall):
283
+ continue
284
+ candidates.append(
285
+ make_candidate(
286
+ detector_id=self.detector_id,
287
+ detector_version=self.detector_version,
288
+ context=context,
289
+ issue_type="group_missingness",
290
+ columns=[target],
291
+ affected_count=nulls,
292
+ evidence=[
293
+ make_evidence(
294
+ detector_id=self.detector_id,
295
+ detector_version=self.detector_version,
296
+ evidence_type=EvidenceType.STATISTICAL_MEASURE,
297
+ description=(
298
+ f"{target} missing in {nulls}/{n} rows where "
299
+ f"{group_col}={row['gv']!r} (overall {overall:.4f})"
300
+ ),
301
+ data={
302
+ "group_column": group_col,
303
+ "group_value": str(row["gv"]),
304
+ "target_column": target,
305
+ "nulls": nulls,
306
+ "group_size": n,
307
+ "null_ratio": round(ratio, 6),
308
+ "overall_ratio": round(overall, 6),
309
+ },
310
+ )
311
+ ],
312
+ raw_score=ratio,
313
+ confidence=0.85,
314
+ severity=Severity.MEDIUM,
315
+ fpr=0.15,
316
+ )
317
+ )
318
+ return candidates
319
+
320
+
321
+ def _time_string_columns(context: DetectionContext) -> list[str]:
322
+ """字符串时间列候选(列名含 _at/_time/date 特征)。"""
323
+ return [
324
+ col for col in string_columns(context) if any(h in col.lower() for h in _TIME_STR_HINTS)
325
+ ]
326
+
327
+
328
+ def _bucket_sql(column: str, granularity: str) -> str:
329
+ q = quote_ident(column)
330
+ return f"strftime({q}, '{granularity}') AS bucket"
331
+
332
+
333
+ def _time_bucket_counts(
334
+ context: DetectionContext, column: str, target: str, granularity: str
335
+ ) -> tuple[list[dict[str, int | str]], int]:
336
+ """按桶统计(target 缺失行数),返回 (桶列表, 有效桶数)。"""
337
+ qt = quote_ident(target)
338
+ bucket_expr = _bucket_sql(column, granularity)
339
+ table = context.handle.sql_aggregate(
340
+ f"SELECT {bucket_expr}, count(*) AS n, sum({qt} IS NULL) AS nulls FROM data GROUP BY 1"
341
+ ).table
342
+ buckets: list[dict[str, int | str]] = []
343
+ for row in table.to_pylist():
344
+ if row["bucket"] is None:
345
+ continue
346
+ buckets.append(
347
+ {"bucket": str(row["bucket"]), "n": int(row["n"]), "nulls": int(row["nulls"])}
348
+ )
349
+ return buckets, len(buckets)
350
+
351
+
352
+ class SuddenMissingnessDetector(DetectorBase):
353
+ """突变缺失(11.4):时间桶缺失率骤增(≥max(0.2, 3×整体),桶样本 ≥10)。"""
354
+
355
+ detector_id = "sudden_missingness"
356
+ display_name = "Sudden Missingness"
357
+ description = "Reports time buckets where missingness spikes abruptly."
358
+ quality_dimension = QualityDimension.COMPLETENESS
359
+ capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
360
+
361
+ def supports(self, context: DetectionContext) -> bool:
362
+ return bool(datetime_columns(context) or _time_string_columns(context))
363
+
364
+ def detect(self, context: DetectionContext) -> list[IssueCandidate]:
365
+ rates = _null_rates(context)
366
+ targets = [c for c in context.columns if rates.get(c, 0.0) >= 0.01]
367
+ targets = targets[:_TARGET_COL_CAP] if targets else []
368
+ time_cols = datetime_columns(context) + _time_string_columns(context)
369
+ if not time_cols or not targets:
370
+ return []
371
+ candidates: list[IssueCandidate] = []
372
+ for time_col in time_cols:
373
+ for target in targets:
374
+ overall = rates.get(target, 0.0)
375
+ granularity = _BUCKET_GRANULARITIES[0][0]
376
+ buckets, count = _time_bucket_counts(context, time_col, target, granularity)
377
+ if count < _BUCKET_MIN_COUNT:
378
+ granularity = _BUCKET_GRANULARITIES[1][0]
379
+ buckets, count = _time_bucket_counts(context, time_col, target, granularity)
380
+ if count < _BUCKET_MIN_COUNT:
381
+ granularity = _BUCKET_GRANULARITIES[2][0]
382
+ buckets, count = _time_bucket_counts(context, time_col, target, granularity)
383
+ threshold = _anomalous_threshold(overall)
384
+ for b in buckets:
385
+ n, nulls = int(b["n"]), int(b["nulls"])
386
+ ratio = nulls / n if n else 0.0
387
+ if n < _BUCKET_MIN_ROWS or ratio < threshold:
388
+ continue
389
+ candidates.append(
390
+ make_candidate(
391
+ detector_id=self.detector_id,
392
+ detector_version=self.detector_version,
393
+ context=context,
394
+ issue_type="sudden_missingness",
395
+ columns=[target],
396
+ affected_count=nulls,
397
+ evidence=[
398
+ make_evidence(
399
+ detector_id=self.detector_id,
400
+ detector_version=self.detector_version,
401
+ evidence_type=EvidenceType.STATISTICAL_MEASURE,
402
+ description=(
403
+ f"{target} missing in {nulls}/{n} rows in "
404
+ f"bucket {b['bucket']} (overall {overall:.4f})"
405
+ ),
406
+ data={
407
+ "time_column": time_col,
408
+ "bucket": str(b["bucket"]),
409
+ "granularity": granularity,
410
+ "target_column": target,
411
+ "nulls": nulls,
412
+ "bucket_size": n,
413
+ "null_ratio": round(ratio, 6),
414
+ "overall_ratio": round(overall, 6),
415
+ },
416
+ )
417
+ ],
418
+ raw_score=ratio,
419
+ confidence=0.9,
420
+ severity=Severity.MEDIUM,
421
+ fpr=0.15,
422
+ )
423
+ )
424
+ return candidates
@@ -0,0 +1,228 @@
1
+ """扫描调度(Step 7 配套):跑检测器 → DetectorRun 记录 → 融合 → 评分 → ScanRun 组装。
2
+
3
+ Step 35:ScanConfig.custom_rules(契约 rules)接入扫描主流程——逐规则
4
+ run_preflight,违规行生成 IssueCandidate 参与融合(issue_type=rule.id)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ import uuid
11
+ from collections import Counter
12
+ from datetime import UTC, datetime
13
+ from typing import Literal
14
+
15
+ from datasentry_core import __version__
16
+ from datasentry_core.detectors.base import (
17
+ DetectionContext,
18
+ Detector,
19
+ DetectorRegistry,
20
+ filter_by_config,
21
+ )
22
+ from datasentry_core.detectors.common import make_candidate, make_evidence
23
+ from datasentry_core.engine.fusion import EvidenceFusionEngine
24
+ from datasentry_core.models.detector import IssueCandidate
25
+ from datasentry_core.models.enums import EvidenceType, Severity
26
+ from datasentry_core.models.issue import Issue
27
+ from datasentry_core.models.rules import Rule
28
+ from datasentry_core.models.scan import DetectorRun, ReproducibilityInfo, ScanConfig, ScanRun
29
+ from datasentry_core.rules.engine import run_preflight
30
+ from datasentry_core.scoring import ScoringEngine
31
+ from datasentry_core.scoring.quality import QualityScoreEngine
32
+
33
+
34
+ def _count_by_severity(issues: list[Issue]) -> dict[Severity, int]:
35
+ """各严重度 Issue 计数(ScanRun.issues_count,18.2)。"""
36
+ counts = Counter(issue.severity for issue in issues)
37
+ return {severity: counts.get(severity, 0) for severity in Severity}
38
+
39
+
40
+ class ScanRunner:
41
+ """单数据集扫描器:支持过滤 → detect → DetectorRun → 融合 → 评分。"""
42
+
43
+ def __init__(self, registry: DetectorRegistry) -> None:
44
+ self._registry = registry
45
+ self._fusion = EvidenceFusionEngine()
46
+ self._scoring = ScoringEngine()
47
+ self._quality = QualityScoreEngine()
48
+
49
+ def run(
50
+ self,
51
+ context: DetectionContext,
52
+ config: ScanConfig,
53
+ scan_run_id: str,
54
+ ) -> tuple[list[DetectorRun], list[Issue]]:
55
+ detectors = filter_by_config(self._registry.list_active(), config.detectors)
56
+ runs: list[DetectorRun] = []
57
+ candidates: list[IssueCandidate] = []
58
+ for detector in detectors:
59
+ run = self._run_detector(detector, context, scan_run_id)
60
+ runs.append(run)
61
+ if run.status == "completed":
62
+ candidates.extend(detector.detect(context))
63
+ if config.custom_rules:
64
+ runs.append(
65
+ self._run_contract_rules(context, config.custom_rules, candidates, scan_run_id)
66
+ )
67
+ fused = self._fusion.fuse(candidates, scan_run_id, row_count=context.handle.count_rows())
68
+ issues = [self._scoring.apply(issue) for issue in fused]
69
+ return runs, issues
70
+
71
+ def _run_contract_rules(
72
+ self,
73
+ context: DetectionContext,
74
+ rules: list[Rule],
75
+ candidates: list[IssueCandidate],
76
+ scan_run_id: str,
77
+ ) -> DetectorRun:
78
+ """契约规则执行(Step 35):逐规则 run_preflight,违规进融合。
79
+
80
+ schema/表达式无效的规则记为该 DetectorRun 失败(scan 随之 failed)。
81
+ """
82
+ started = time.monotonic()
83
+ issues_candidates = len(rules)
84
+ failed_rules: list[str] = []
85
+ for rule in rules:
86
+ report = run_preflight(rule, context.handle)
87
+ if not report.valid:
88
+ reason = "missing columns" if not report.schema_valid else "invalid expression"
89
+ failed_rules.append(f"{rule.id}: {reason}")
90
+ continue
91
+ sample = report.sample_run
92
+ if sample is None or sample.failures <= 0:
93
+ continue
94
+ evidence = [
95
+ make_evidence(
96
+ detector_id="contract_rule",
97
+ detector_version=str(rule.version),
98
+ evidence_type=EvidenceType.RULE_VIOLATION,
99
+ description=(
100
+ f"rule {rule.id}: {sample.failures} of {sample.rows_tested} rows "
101
+ f"violate ({sample.failure_ratio:.4f})"
102
+ ),
103
+ data={
104
+ "rule_id": rule.id,
105
+ "rule_version": rule.version,
106
+ "failures": sample.failures,
107
+ "rows_tested": sample.rows_tested,
108
+ "examples": sample.example_rows[:5],
109
+ },
110
+ )
111
+ ]
112
+ candidates.append(
113
+ make_candidate(
114
+ detector_id="contract_rule",
115
+ detector_version=str(rule.version),
116
+ context=context,
117
+ issue_type=f"rule:{rule.id}",
118
+ columns=[rule.when.column] if rule.when else list(rule.columns),
119
+ affected_count=sample.failures,
120
+ affected_rows=None,
121
+ evidence=evidence,
122
+ raw_score=sample.failure_ratio,
123
+ confidence=0.95,
124
+ severity=rule.severity,
125
+ )
126
+ )
127
+ status: Literal["completed", "failed"] = "failed" if failed_rules else "completed"
128
+ error = "; ".join(failed_rules) if failed_rules else None
129
+ return DetectorRun(
130
+ id=f"det_{uuid.uuid4().hex[:12]}",
131
+ scan_run_id=scan_run_id,
132
+ detector_id="contract_rule",
133
+ detector_version="1.0",
134
+ status=status,
135
+ rows_scanned=context.handle.count_rows(),
136
+ duration_ms=int((time.monotonic() - started) * 1000),
137
+ issues_candidates=issues_candidates,
138
+ error=error,
139
+ )
140
+
141
+ def run_scan(
142
+ self,
143
+ context: DetectionContext,
144
+ config: ScanConfig | None = None,
145
+ ) -> tuple[ScanRun, list[DetectorRun], list[Issue]]:
146
+ """完整扫描入口:跑检测器 → 融合 → 评分 → 组装 ScanRun(18.2)。
147
+
148
+ 返回 (scan_run, detector_runs, issues),便于调用方持久化与展示。
149
+ """
150
+ config = config or ScanConfig()
151
+ scan_run_id = f"scan_{uuid.uuid4().hex[:12]}"
152
+ started_at = datetime.now(UTC)
153
+ runs, issues = self.run(context, config, scan_run_id)
154
+ failed = [r for r in runs if r.status == "failed"]
155
+ status: Literal["completed", "failed"] = "failed" if failed else "completed"
156
+ error = "; ".join(f"{r.detector_id}: {r.error}" for r in failed) if failed else None
157
+ ran_dimensions = set()
158
+ for run in runs:
159
+ if run.status != "completed":
160
+ continue
161
+ try:
162
+ detector = self._registry.get(run.detector_id)
163
+ except KeyError:
164
+ continue # 契约规则 run(非注册检测器)不参与维度统计
165
+ ran_dimensions.add(detector.quality_dimension)
166
+ try:
167
+ quality_score = self._quality.score(issues, ran_dimensions=ran_dimensions)
168
+ except ValueError: # 无任何检测器运行(空白名单)→ 未评分
169
+ quality_score = None
170
+ scan_run = ScanRun(
171
+ id=scan_run_id,
172
+ dataset_id=context.dataset_id,
173
+ status=status,
174
+ config=config,
175
+ fingerprint=context.handle.fingerprint(),
176
+ issues_count=_count_by_severity(issues),
177
+ started_at=started_at,
178
+ finished_at=datetime.now(UTC),
179
+ error=error,
180
+ quality_score=quality_score,
181
+ reproducibility=ReproducibilityInfo(
182
+ datasentry_version=__version__,
183
+ detector_versions={r.detector_id: r.detector_version for r in runs},
184
+ seed=config.seed,
185
+ scanned_at=datetime.now(UTC),
186
+ ),
187
+ )
188
+ return scan_run, runs, issues
189
+
190
+ def _run_detector(
191
+ self, detector: Detector, context: DetectionContext, scan_run_id: str
192
+ ) -> DetectorRun:
193
+ started = time.perf_counter()
194
+ try:
195
+ if not detector.supports(context):
196
+ return DetectorRun(
197
+ id=f"dr_{uuid.uuid4().hex[:12]}",
198
+ scan_run_id=scan_run_id,
199
+ detector_id=detector.detector_id,
200
+ detector_version=detector.detector_version,
201
+ status="skipped",
202
+ rows_scanned=0,
203
+ duration_ms=0,
204
+ issues_candidates=0,
205
+ )
206
+ candidates = detector.detect(context)
207
+ return DetectorRun(
208
+ id=f"dr_{uuid.uuid4().hex[:12]}",
209
+ scan_run_id=scan_run_id,
210
+ detector_id=detector.detector_id,
211
+ detector_version=detector.detector_version,
212
+ status="completed",
213
+ rows_scanned=context.handle.count_rows(),
214
+ duration_ms=int((time.perf_counter() - started) * 1000),
215
+ issues_candidates=len(candidates),
216
+ )
217
+ except Exception as exc: # 单检测器失败不中断整次扫描
218
+ return DetectorRun(
219
+ id=f"dr_{uuid.uuid4().hex[:12]}",
220
+ scan_run_id=scan_run_id,
221
+ detector_id=detector.detector_id,
222
+ detector_version=detector.detector_version,
223
+ status="failed",
224
+ rows_scanned=0,
225
+ duration_ms=int((time.perf_counter() - started) * 1000),
226
+ issues_candidates=0,
227
+ error=str(exc),
228
+ )