datasentry-core 0.7.0__tar.gz → 0.8.0__tar.gz

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 (98) hide show
  1. datasentry_core-0.8.0/.gitignore +43 -0
  2. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/PKG-INFO +1 -1
  3. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/pyproject.toml +1 -1
  4. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/__init__.py +1 -1
  5. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/csv.py +19 -0
  6. datasentry_core-0.8.0/src/datasentry_core/connectors/sampling.py +141 -0
  7. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/xlsx.py +12 -1
  8. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/base.py +13 -0
  9. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/common.py +9 -0
  10. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/datetime.py +31 -12
  11. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/anomaly_ml.py +4 -1
  12. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/categorical.py +27 -13
  13. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/formula.py +6 -2
  14. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/missing.py +18 -9
  15. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/numeric.py +44 -21
  16. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/textual.py +42 -16
  17. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/uniqueness.py +12 -4
  18. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/runner.py +74 -10
  19. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/textual_variants.py +11 -4
  20. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/engine/profiler.py +27 -2
  21. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/repair.py +14 -1
  22. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/scan.py +1 -0
  23. datasentry_core-0.8.0/src/datasentry_core/plugin_fixtures.py +156 -0
  24. datasentry_core-0.8.0/src/datasentry_core/plugin_locks.py +187 -0
  25. datasentry_core-0.8.0/src/datasentry_core/plugins.py +402 -0
  26. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/repair/engine.py +43 -0
  27. datasentry_core-0.8.0/src/datasentry_core/reporting/column_profiles.py +204 -0
  28. datasentry_core-0.8.0/src/datasentry_core/reporting/evidence_desc.py +72 -0
  29. datasentry_core-0.8.0/src/datasentry_core/reporting/html.py +623 -0
  30. datasentry_core-0.8.0/src/datasentry_core/reporting/i18n.py +725 -0
  31. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/reporting/interactive.py +140 -34
  32. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/reporting/markdown.py +38 -21
  33. datasentry_core-0.8.0/src/datasentry_core/reporting/suggestions.py +101 -0
  34. datasentry_core-0.8.0/src/datasentry_core/reporting/translate.py +72 -0
  35. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/storage/paths.py +9 -0
  36. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/storage/schema.py +99 -7
  37. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/storage/store.py +13 -5
  38. datasentry_core-0.7.0/.gitignore +0 -32
  39. datasentry_core-0.7.0/src/datasentry_core/plugins.py +0 -177
  40. datasentry_core-0.7.0/src/datasentry_core/reporting/html.py +0 -267
  41. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/README.md +0 -0
  42. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/__init__.py +0 -0
  43. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/base.py +0 -0
  44. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/duckdb.py +0 -0
  45. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/errors.py +0 -0
  46. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/file_based.py +0 -0
  47. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/jsonl.py +0 -0
  48. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/mysql.py +0 -0
  49. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/parquet.py +0 -0
  50. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/postgres.py +0 -0
  51. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/registry.py +0 -0
  52. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/remote_file.py +0 -0
  53. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/spec.py +0 -0
  54. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/sqlite.py +0 -0
  55. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/contracts/__init__.py +0 -0
  56. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/contracts/exporters.py +0 -0
  57. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/__init__.py +0 -0
  58. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/cross_field.py +0 -0
  59. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/cross_table.py +0 -0
  60. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/__init__.py +0 -0
  61. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/missingness.py +0 -0
  62. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/safe_eval.py +0 -0
  63. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/drift/__init__.py +0 -0
  64. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/drift/engine.py +0 -0
  65. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/engine/__init__.py +0 -0
  66. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/engine/base.py +0 -0
  67. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/engine/duckdb.py +0 -0
  68. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/engine/fusion.py +0 -0
  69. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/engine/sql_guard.py +0 -0
  70. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/llm/provider.py +0 -0
  71. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/__init__.py +0 -0
  72. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/audit.py +0 -0
  73. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/contract.py +0 -0
  74. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/detector.py +0 -0
  75. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/drift.py +0 -0
  76. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/enums.py +0 -0
  77. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/evidence.py +0 -0
  78. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/feedback.py +0 -0
  79. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/fingerprint.py +0 -0
  80. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/issue.py +0 -0
  81. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/llm.py +0 -0
  82. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/profile.py +0 -0
  83. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/quality.py +0 -0
  84. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/rules.py +0 -0
  85. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/models/validation.py +0 -0
  86. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/privacy/redactor.py +0 -0
  87. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/repair/__init__.py +0 -0
  88. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/reporting/__init__.py +0 -0
  89. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/reporting/junit.py +0 -0
  90. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/reporting/sarif.py +0 -0
  91. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/rules/engine.py +0 -0
  92. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/scoring/__init__.py +0 -0
  93. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/scoring/engine.py +0 -0
  94. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/scoring/gate.py +0 -0
  95. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/scoring/quality.py +0 -0
  96. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/scoring/weights.py +0 -0
  97. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/secrets.py +0 -0
  98. {datasentry_core-0.7.0 → datasentry_core-0.8.0}/src/datasentry_core/storage/__init__.py +0 -0
@@ -0,0 +1,43 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ .coverage
9
+ htmlcov/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+ hypothesis/
14
+ .hypothesis/
15
+
16
+ # Project data
17
+ .datasentry/
18
+ *.db
19
+ *.sqlite3
20
+
21
+ # Env / secrets
22
+ .env
23
+ credentials.json
24
+
25
+ # OS
26
+ .DS_Store
27
+
28
+ # Editors
29
+ .idea/
30
+ .vscode/
31
+ ~$*
32
+ workspace/
33
+
34
+ # Debug scratch (agent sessions)
35
+ p/
36
+
37
+ # 开发任务书(prompt)——本地保留,不进仓库(V22 起约定)
38
+ docs/V*_DEV_PROMPT.md
39
+
40
+ # 完整产品设计与开发 Prompt(本地保留,不进仓库)
41
+ DataSentry AI 产品设计与开发 Prompt*.md
42
+ DataSentry AI 产品设计与开发 Prompt*.docx
43
+ DataSentry AI 增长与推广 Prompt.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: datasentry-core
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: DataSentry AI core domain models and engine (detectors, fusion, scoring, repairs).
5
5
  License: Apache-2.0
6
6
  Keywords: data-observability,data-quality,duckdb
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datasentry-core"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "DataSentry AI core domain models and engine (detectors, fusion, scoring, repairs)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -108,4 +108,4 @@ __all__ = [
108
108
  "ValidationResult",
109
109
  ]
110
110
 
111
- __version__ = "0.7.0"
111
+ __version__ = "1.0.0"
@@ -33,6 +33,9 @@ from datasentry_core.engine import DuckDBExecutor
33
33
  from datasentry_core.models.enums import Severity
34
34
  from datasentry_core.models.fingerprint import DatasetFingerprint
35
35
 
36
+ #: 非 utf-8 全文件入内存的警告阈值(Step 73/ADR-073)
37
+ _NON_UTF8_FULL_READ_WARN_BYTES = 512 * 1024 * 1024
38
+
36
39
  FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
37
40
  _WARNING_CAP = 100
38
41
 
@@ -132,6 +135,22 @@ class CsvDataHandle:
132
135
  f"delim={_sql_string_literal(self._delimiter)})"
133
136
  )
134
137
  else:
138
+ # Step 73/ADR-073:非 utf-8 全文件入内存(pyarrow 无流式编码读取),
139
+ # 超阈值时预置提示 warning,引导 --sampling 或转码
140
+ if self._path.stat().st_size > _NON_UTF8_FULL_READ_WARN_BYTES:
141
+ self._warnings = [
142
+ LoadWarning(
143
+ row=0,
144
+ column="",
145
+ message=(
146
+ "non-utf-8 csv is fully loaded into memory "
147
+ f"({self._path.stat().st_size} bytes > "
148
+ f"{_NON_UTF8_FULL_READ_WARN_BYTES}); "
149
+ "use --sampling or re-encode as utf-8"
150
+ ),
151
+ severity=Severity.LOW,
152
+ )
153
+ ]
135
154
  table = pa_csv.read_csv(
136
155
  self._path,
137
156
  read_options=pa_csv.ReadOptions(encoding=self._encoding),
@@ -0,0 +1,141 @@
1
+ """抽样数据句柄(Step 71/73,ADR-071/073):检测器 SQL 顶层 FROM data 改写。
2
+
3
+ 零连接器改动:与底层句柄共享 executor,首次数据访问时把抽样子集物化为
4
+ TEMP TABLE(reservoir + REPEATABLE(seed) 可复现,20.3),后续检测器
5
+ 查询直接读内存表(ADR-073:避免每检测器重复 reservoir 重扫 1e6 行,
6
+ bench 抽样档 52s→~15s、峰值 449MB→<300MB);无 executor 的连接器
7
+ (协议兜底)退回抽样子查询重写。
8
+ count_rows() 返回抽样行数(该句柄实际可观测的行数)。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from collections.abc import Iterator
15
+ from pathlib import Path
16
+
17
+ from datasentry_core.connectors.base import (
18
+ DataHandle,
19
+ FingerprintMode,
20
+ FrameBatch,
21
+ LoadWarning,
22
+ SamplingMethod,
23
+ SchemaInfo,
24
+ )
25
+ from datasentry_core.connectors.spec import DataSourceType
26
+ from datasentry_core.models.fingerprint import DatasetFingerprint
27
+
28
+ #: 仓库内检测器/规则/画像 SQL 一律以顶层 FROM data 引用视图(50 处均为该形态)。
29
+ #: 重写后残留(子查询内部)不匹配 lookahead,见 _rewrite 守卫。
30
+ _FROM_DATA_RE = re.compile(r"\bFROM data\b")
31
+
32
+ #: 物化 TEMP TABLE 名(与 data 视图同 executor 连接内,无命名冲突)
33
+ _SAMPLED_TABLE = "sampled_data"
34
+
35
+
36
+ class SampledDataHandle:
37
+ """包装句柄:把对 data 视图的查询改写为抽样数据(顶层 FROM data 单形态)。
38
+
39
+ 首次数据访问物化 TEMP TABLE(每次检测器查询复用,避免重复 reservoir
40
+ 重扫);不支持 read_batches(抽样语义面向 SQL 下推);read_sample
41
+ 委托底层(对抽样数据再抽样无意义,交由调用方决定)。
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ handle: DataHandle,
47
+ n: int,
48
+ seed: int = 42,
49
+ method: SamplingMethod = "reservoir",
50
+ ) -> None:
51
+ if n < 1:
52
+ raise ValueError("n must be >= 1")
53
+ self._inner = handle
54
+ self._n = n
55
+ self._seed = seed
56
+ self._method = method
57
+ self._executor = getattr(handle, "_executor", None)
58
+ self._sampled_ready = False
59
+
60
+ def _ensure_sampled_table(self) -> None:
61
+ if self._sampled_ready or self._executor is None:
62
+ return
63
+ # 惰性视图:建表前确保 data 视图已注册(count_rows 触发各连接器
64
+ # _ensure_view;CSV 1e6 行计数 ~0.2s,可接受)
65
+ self._inner.count_rows()
66
+ if self._method == "none":
67
+ ddl = (
68
+ f"CREATE OR REPLACE TEMP TABLE {_SAMPLED_TABLE} AS "
69
+ f"SELECT * FROM data LIMIT {self._n}"
70
+ )
71
+ else:
72
+ # SAMPLE 子句不支持预编译参数,n/seed 均为代码内整数(已校验),安全内联
73
+ ddl = (
74
+ f"CREATE OR REPLACE TEMP TABLE {_SAMPLED_TABLE} AS "
75
+ f"SELECT * FROM data USING SAMPLE reservoir({self._n} ROWS) "
76
+ f"REPEATABLE ({self._seed})"
77
+ )
78
+ self._executor.execute_setup(ddl)
79
+ self._sampled_ready = True
80
+
81
+ def _sampled_subquery(self) -> str:
82
+ if self._method == "none":
83
+ return f"(SELECT * FROM data LIMIT {self._n})"
84
+ # SAMPLE 子句不支持预编译参数,n/seed 均为代码内整数(已校验),安全内联
85
+ return (
86
+ f"(SELECT * FROM data USING SAMPLE reservoir({self._n} ROWS) REPEATABLE ({self._seed}))"
87
+ )
88
+
89
+ def _rewrite(self, sql: str) -> str:
90
+ if self._executor is not None:
91
+ self._ensure_sampled_table()
92
+ target = f"FROM {_SAMPLED_TABLE}"
93
+ else:
94
+ target = f"FROM {self._sampled_subquery()}"
95
+ rewritten = _FROM_DATA_RE.sub(target, sql)
96
+ # 守卫:重写后不允许残留裸 FROM data(防未覆盖 SQL 形态,如别名/join)
97
+ if re.search(r"\bFROM data\b(?! USING SAMPLE)", rewritten):
98
+ raise ValueError(f"cannot rewrite sampled SQL: {sql!r}")
99
+ return rewritten
100
+
101
+ @property
102
+ def source_type(self) -> DataSourceType:
103
+ return self._inner.source_type
104
+
105
+ @property
106
+ def source_path(self) -> Path | str | None:
107
+ return self._inner.source_path
108
+
109
+ @property
110
+ def table_name(self) -> str | None:
111
+ return self._inner.table_name
112
+
113
+ def schema(self) -> SchemaInfo:
114
+ return self._inner.schema()
115
+
116
+ def read_batches(self, batch_size: int = 65536) -> Iterator[FrameBatch]:
117
+ return self._inner.read_batches(batch_size)
118
+
119
+ def read_sample(self, n: int, method: SamplingMethod = "random") -> FrameBatch:
120
+ return self._inner.read_sample(n, method)
121
+
122
+ def sql_aggregate(self, sql: str, params: dict[str, object] | None = None) -> FrameBatch:
123
+ return self._inner.sql_aggregate(self._rewrite(sql), params)
124
+
125
+ def count_rows(self) -> int:
126
+ return min(self._n, self._inner.count_rows())
127
+
128
+ def fingerprint(self, mode: FingerprintMode = "full") -> DatasetFingerprint:
129
+ return self._inner.fingerprint(mode)
130
+
131
+ def content_fingerprint(self) -> str:
132
+ return self._inner.content_fingerprint()
133
+
134
+ def stats_fingerprint(self) -> str:
135
+ return self._inner.stats_fingerprint()
136
+
137
+ def warnings(self) -> list[LoadWarning]:
138
+ return self._inner.warnings()
139
+
140
+ def close(self) -> None:
141
+ self._inner.close()
@@ -12,9 +12,13 @@ import pyarrow as pa
12
12
  from openpyxl import load_workbook
13
13
 
14
14
  from datasentry_core.connectors.base import FrameBatch
15
+ from datasentry_core.connectors.errors import ConnectorError
15
16
  from datasentry_core.connectors.file_based import FileDataHandle
16
17
  from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
17
18
 
19
+ #: 整 sheet 入内存的行预算(ADR-019 MVP 语义,Step 73/ADR-073 显式化)
20
+ _XLSX_ROW_BUDGET = 1_000_000
21
+
18
22
 
19
23
  def _rows_to_table(names: list[str], rows: list[list[object]]) -> pa.Table:
20
24
  """行值列表 → pyarrow 表;混合类型列自动推断失败时回退全字符串(ADR-019)。"""
@@ -51,7 +55,14 @@ class XlsxDataHandle(FileDataHandle):
51
55
  sheet = workbook.worksheets[sheet_ref]
52
56
  else:
53
57
  sheet = workbook[str(sheet_ref)]
54
- rows = list(sheet.iter_rows(values_only=True))
58
+ rows: list[tuple[object, ...]] = []
59
+ for row in sheet.iter_rows(values_only=True):
60
+ if len(rows) >= _XLSX_ROW_BUDGET:
61
+ raise ConnectorError(
62
+ f"xlsx sheet exceeds row budget {_XLSX_ROW_BUDGET} "
63
+ "(ADR-019/ADR-073); split the sheet or use --sampling"
64
+ )
65
+ rows.append(row)
55
66
  finally:
56
67
  workbook.close()
57
68
  if header_row >= len(rows):
@@ -44,6 +44,19 @@ class DetectionContext:
44
44
  self.sample_rows = sample_rows
45
45
  self.references: list[TableReference] = references or []
46
46
 
47
+ def with_handle(self, handle: DataHandle) -> DetectionContext:
48
+ """返回换句柄的新上下文(Step 71:抽样支撑检测器注入抽样句柄)。"""
49
+ return DetectionContext(
50
+ dataset_id=self.dataset_id,
51
+ table_name=self.table_name,
52
+ columns=self.columns,
53
+ handle=handle,
54
+ config=self.config,
55
+ profile=self.profile,
56
+ sample_rows=self.sample_rows,
57
+ references=self.references,
58
+ )
59
+
47
60
 
48
61
  @runtime_checkable
49
62
  class Detector(Protocol):
@@ -34,6 +34,15 @@ def make_evidence(
34
34
  data: dict[str, Any] | None = None,
35
35
  confidence: float = 1.0,
36
36
  ) -> Evidence:
37
+ from datasentry_core.reporting.evidence_desc import EvText
38
+
39
+ if isinstance(description, EvText):
40
+ data = {
41
+ **description.base,
42
+ **(data or {}),
43
+ "_text_key": description.key,
44
+ "_params": description.params,
45
+ }
37
46
  return Evidence(
38
47
  evidence_id=f"ev_{uuid.uuid4().hex[:12]}",
39
48
  evidence_type=evidence_type,
@@ -18,6 +18,7 @@ from datasentry_core.detectors.common import (
18
18
  )
19
19
  from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
20
20
  from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
21
+ from datasentry_core.reporting.evidence_desc import ev
21
22
 
22
23
  # 字符串日期列判定(列名特征;VARCHAR 列才适用 invalid/impossible/mixed)
23
24
  # 注意:时间戳列(created_at 等,物理 TIMESTAMP 或含时间值)不归此组,
@@ -74,8 +75,11 @@ class InvalidDateDetector(DetectorBase):
74
75
  detector_id=self.detector_id,
75
76
  detector_version=self.detector_version,
76
77
  evidence_type=EvidenceType.PATTERN_MATCH,
77
- description=f"{count} values fail ISO date pattern",
78
- data={"count": count, "pattern": _DATETIME_RE},
78
+ description=ev(
79
+ "datetime.iso",
80
+ {"count": count, "pattern": _DATETIME_RE},
81
+ count=count,
82
+ ),
79
83
  )
80
84
  ],
81
85
  raw_score=count,
@@ -123,8 +127,11 @@ class ImpossibleDateDetector(DetectorBase):
123
127
  detector_id=self.detector_id,
124
128
  detector_version=self.detector_version,
125
129
  evidence_type=EvidenceType.PATTERN_MATCH,
126
- description=f"{count} calendar-invalid dates",
127
- data={"count": count, "pattern": _DATETIME_RE},
130
+ description=ev(
131
+ "datetime.invalid",
132
+ {"count": count, "pattern": _DATETIME_RE},
133
+ count=count,
134
+ ),
128
135
  )
129
136
  ],
130
137
  raw_score=count,
@@ -168,8 +175,11 @@ class FutureDateDetector(DetectorBase):
168
175
  detector_id=self.detector_id,
169
176
  detector_version=self.detector_version,
170
177
  evidence_type=EvidenceType.CONSTRAINT_VIOLATION,
171
- description=f"{count} dates beyond today + 1 day",
172
- data={"count": count, "max_future_days": 1},
178
+ description=ev(
179
+ "datetime.future",
180
+ {"count": count, "max_future_days": 1},
181
+ count=count,
182
+ ),
173
183
  )
174
184
  ],
175
185
  raw_score=count,
@@ -215,8 +225,11 @@ class StaleDateDetector(DetectorBase):
215
225
  detector_id=self.detector_id,
216
226
  detector_version=self.detector_version,
217
227
  evidence_type=EvidenceType.CONSTRAINT_VIOLATION,
218
- description=f"{count} dates older than 365 days",
219
- data={"count": count, "max_age_days": 365},
228
+ description=ev(
229
+ "datetime.old",
230
+ {"count": count, "max_age_days": 365},
231
+ count=count,
232
+ ),
220
233
  )
221
234
  ],
222
235
  raw_score=count,
@@ -283,8 +296,11 @@ class MixedDateFormatDetector(DetectorBase):
283
296
  detector_id=self.detector_id,
284
297
  detector_version=self.detector_version,
285
298
  evidence_type=EvidenceType.STATISTICAL_MEASURE,
286
- description=f"{len(major)} date formats each >2%",
287
- data={"formats": {k: round(v, 4) for k, v in formats.items()}},
299
+ description=ev(
300
+ "datetime.formats",
301
+ {"formats": {k: round(v, 4) for k, v in formats.items()}},
302
+ len=len(major),
303
+ ),
288
304
  )
289
305
  ],
290
306
  raw_score=len(major),
@@ -331,8 +347,11 @@ class DuplicateTimestampDetector(DetectorBase):
331
347
  detector_id=self.detector_id,
332
348
  detector_version=self.detector_version,
333
349
  evidence_type=EvidenceType.DUPLICATE_MATCH,
334
- description=f"{len(counts)} timestamps appear more than twice",
335
- data={"groups": len(counts), "affected": affected},
350
+ description=ev(
351
+ "datetime.timestamps",
352
+ {"groups": len(counts), "affected": affected},
353
+ len=len(counts),
354
+ ),
336
355
  )
337
356
  ],
338
357
  raw_score=len(counts),
@@ -153,8 +153,11 @@ class ModelOutlierDetector(DetectorBase):
153
153
  for column in _infer_columns(context):
154
154
  q = quote_ident(column)
155
155
  try:
156
+ # SQL 侧 reservoir 抽样(Step 72/ADR-072):物化前限制行数;
157
+ # 行数 < max_samples 时 reservoir 等价全量,语义不变
156
158
  table = context.handle.sql_aggregate(
157
- f"SELECT {q} AS v FROM data WHERE {q} IS NOT NULL"
159
+ f"SELECT {q} AS v FROM data WHERE {q} IS NOT NULL "
160
+ f"USING SAMPLE reservoir({max_samples} ROWS) REPEATABLE ({seed})"
158
161
  ).table
159
162
  except Exception:
160
163
  continue
@@ -16,6 +16,7 @@ from datasentry_core.detectors.common import (
16
16
  )
17
17
  from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
18
18
  from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
19
+ from datasentry_core.reporting.evidence_desc import ev
19
20
 
20
21
 
21
22
  def _json_safe(value: Any) -> Any:
@@ -67,8 +68,11 @@ class SuspiciousPlaceholderDetector(DetectorBase):
67
68
  detector_id=self.detector_id,
68
69
  detector_version=self.detector_version,
69
70
  evidence_type=EvidenceType.PATTERN_MATCH,
70
- description=f"{count} placeholder values",
71
- data={"count": count},
71
+ description=ev(
72
+ "categorical.placeholder",
73
+ {"count": count},
74
+ count=count,
75
+ ),
72
76
  )
73
77
  ],
74
78
  raw_score=count,
@@ -131,13 +135,16 @@ class RareCategoryDetector(DetectorBase):
131
135
  detector_id=self.detector_id,
132
136
  detector_version=self.detector_version,
133
137
  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
- },
138
+ description=ev(
139
+ "categorical.rare",
140
+ {
141
+ "categories": [
142
+ (_json_safe(v), int(c))
143
+ for v, c in zip(values, counts, strict=True)
144
+ ]
145
+ },
146
+ len=len(values),
147
+ ),
141
148
  )
142
149
  ],
143
150
  raw_score=len(values),
@@ -188,8 +195,11 @@ class CategoryExplosionDetector(DetectorBase):
188
195
  detector_id=self.detector_id,
189
196
  detector_version=self.detector_version,
190
197
  evidence_type=EvidenceType.STATISTICAL_MEASURE,
191
- description=f"unique_ratio={ratio:.3f} > 0.9",
192
- data={"unique_ratio": round(ratio, 6), "distinct": distinct},
198
+ description=ev(
199
+ "categorical.high_unique",
200
+ {"unique_ratio": round(ratio, 6), "distinct": distinct},
201
+ ratio=ratio,
202
+ ),
193
203
  )
194
204
  ],
195
205
  raw_score=ratio,
@@ -240,8 +250,12 @@ class InconsistentCaseDetector(DetectorBase):
240
250
  detector_id=self.detector_id,
241
251
  detector_version=self.detector_version,
242
252
  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))},
253
+ description=ev(
254
+ "categorical.case_variant",
255
+ {"groups": list(zip(keys, counts, forms, strict=True))},
256
+ affected=affected,
257
+ len=len(keys),
258
+ ),
245
259
  )
246
260
  ],
247
261
  raw_score=len(keys),
@@ -15,6 +15,7 @@ from datasentry_core.detectors.common import (
15
15
  )
16
16
  from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
17
17
  from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
18
+ from datasentry_core.reporting.evidence_desc import ev
18
19
 
19
20
  FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
20
21
 
@@ -54,8 +55,11 @@ class FormulaInjectionDetector(DetectorBase):
54
55
  detector_id=self.detector_id,
55
56
  detector_version=self.detector_version,
56
57
  evidence_type=EvidenceType.PATTERN_MATCH,
57
- description=f"{count} values start with formula-injection prefixes",
58
- data={"count": count},
58
+ description=ev(
59
+ "formula.injection",
60
+ {"count": count},
61
+ count=count,
62
+ ),
59
63
  )
60
64
  ],
61
65
  raw_score=count,
@@ -15,6 +15,7 @@ from datasentry_core.detectors.common import (
15
15
  )
16
16
  from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
17
17
  from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
18
+ from datasentry_core.reporting.evidence_desc import ev
18
19
 
19
20
  _MISSING_TOKENS = (
20
21
  "na",
@@ -79,13 +80,17 @@ class ExcessiveNullRateDetector(DetectorBase):
79
80
  detector_id=self.detector_id,
80
81
  detector_version=self.detector_version,
81
82
  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
- },
83
+ description=ev(
84
+ "missing.null_ratio",
85
+ {
86
+ "null_ratio": round(ratio, 6),
87
+ "threshold": threshold,
88
+ "nulls": nulls,
89
+ "total": total,
90
+ },
91
+ ratio=ratio,
92
+ threshold=threshold,
93
+ ),
89
94
  )
90
95
  ],
91
96
  raw_score=ratio,
@@ -138,8 +143,12 @@ class SuspiciousMissingTokenDetector(DetectorBase):
138
143
  detector_id=self.detector_id,
139
144
  detector_version=self.detector_version,
140
145
  evidence_type=EvidenceType.PATTERN_MATCH,
141
- description=f"{count} values are missing stand-ins ({ratio:.4f})",
142
- data={"count": count, "ratio": round(ratio, 6)},
146
+ description=ev(
147
+ "missing.standin",
148
+ {"count": count, "ratio": round(ratio, 6)},
149
+ count=count,
150
+ ratio=ratio,
151
+ ),
143
152
  )
144
153
  ],
145
154
  raw_score=ratio,