datasentry-core 0.1.0__tar.gz → 0.3.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.
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/PKG-INFO +2 -2
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/pyproject.toml +1 -1
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/__init__.py +1 -1
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/llm.py +1 -0
- datasentry_core-0.3.0/src/datasentry_core/plugins.py +177 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/reporting/__init__.py +16 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/reporting/html.py +66 -29
- datasentry_core-0.3.0/src/datasentry_core/reporting/interactive.py +369 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/reporting/markdown.py +3 -3
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/storage/schema.py +49 -1
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/storage/store.py +76 -2
- datasentry_core-0.1.0/src/datasentry_core/plugins.py +0 -91
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/.gitignore +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/README.md +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/__init__.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/base.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/csv.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/duckdb.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/errors.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/file_based.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/jsonl.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/parquet.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/registry.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/spec.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/xlsx.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/contracts/__init__.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/contracts/exporters.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/__init__.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/base.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/common.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/cross_field.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/cross_table.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/datetime.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/__init__.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/anomaly_ml.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/categorical.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/formula.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/missing.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/numeric.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/textual.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/uniqueness.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/missingness.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/runner.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/safe_eval.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/textual_variants.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/drift/__init__.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/drift/engine.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/engine/__init__.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/engine/base.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/engine/duckdb.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/engine/fusion.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/engine/profiler.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/engine/sql_guard.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/llm/provider.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/__init__.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/audit.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/contract.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/detector.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/drift.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/enums.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/evidence.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/feedback.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/fingerprint.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/issue.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/profile.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/quality.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/repair.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/rules.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/scan.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/validation.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/privacy/redactor.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/repair/__init__.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/repair/engine.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/reporting/junit.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/reporting/sarif.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/rules/engine.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/scoring/__init__.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/scoring/engine.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/scoring/gate.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/scoring/quality.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/scoring/weights.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/storage/__init__.py +0 -0
- {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/storage/paths.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: datasentry-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""检测器插件加载(Step 31 目录版 + Step 50 entry points 版,ADR-031 / ADR-050)。
|
|
2
|
+
|
|
3
|
+
- `load_plugin_detectors(registry, dirs)`:从目录加载 Python 模块(Step 31,
|
|
4
|
+
ADR-031,fail-fast 语义保持不破坏)。
|
|
5
|
+
- `discover_entrypoint_detectors(registry)`:`importlib.metadata.entry_points`
|
|
6
|
+
发现已安装插件包(Step 50,V2-C):entry point group `datasentry.detectors`。
|
|
7
|
+
与目录版不同,入口点版**优雅降级**:单个入口加载/实例化/注册失败只记录
|
|
8
|
+
`PluginError`,不影响其他入口与内置检测器(缺依赖给明确报错不崩)。
|
|
9
|
+
- 入口值形态(`ep.load()` 返回):Detector 实例 / Detector 子类(无参实例化)/
|
|
10
|
+
返回 Detector 实例的工厂函数;其余形态记为错误。
|
|
11
|
+
- 加载语义(目录版沿用 Step 31):只加载 `*.py`,跳过 `_`/`.` 前缀文件;
|
|
12
|
+
模块级非 Detector 属性忽略;冲突抛 `PluginLoadError`。
|
|
13
|
+
- 安全边界:插件是**用户本机可信代码**(与内置检测器同权),不做沙箱——
|
|
14
|
+
11.10/ADR-015 的安全表达式求值只约束规则表达式;仅加载已安装包
|
|
15
|
+
(`importlib.metadata` 只枚举已安装发行版),不执行任意文件路径。
|
|
16
|
+
|
|
17
|
+
稳定性承诺(ADR-031 扩展,ADR-050):`Detector` 协议、`DetectionContext`、
|
|
18
|
+
`DetectorRegistry`、`load_plugin_detectors` 签名保持 v1 稳定;新增
|
|
19
|
+
`discover_entrypoint_detectors` / `PluginLoadReport` / `PluginError` 作为
|
|
20
|
+
V2-C 插件生态 v1 接口。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import importlib.metadata
|
|
26
|
+
import importlib.util
|
|
27
|
+
import traceback
|
|
28
|
+
from collections.abc import Iterator, Sequence
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, cast
|
|
32
|
+
|
|
33
|
+
from datasentry_core.detectors.base import Detector, DetectorRegistry
|
|
34
|
+
|
|
35
|
+
#: 检测器插件的 entry point group(V2-C,ADR-050)。
|
|
36
|
+
DETECTOR_ENTRY_POINT_GROUP = "datasentry.detectors"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PluginLoadError(Exception):
|
|
40
|
+
"""插件加载失败:import/实例化/注册冲突,message 含文件与原因。"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class PluginError:
|
|
45
|
+
"""单个入口点插件的加载失败记录(优雅降级:不中断其他插件)。"""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
message: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class PluginLoadReport:
|
|
53
|
+
"""entry points 发现结果:成功注册的 detector_id + 失败项列表。"""
|
|
54
|
+
|
|
55
|
+
loaded: list[str] = field(default_factory=list)
|
|
56
|
+
errors: list[PluginError] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
def __bool__(self) -> bool:
|
|
59
|
+
return bool(self.loaded or self.errors)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _module_paths(directory: Path) -> list[Path]:
|
|
63
|
+
if not directory.is_dir():
|
|
64
|
+
return []
|
|
65
|
+
return sorted(
|
|
66
|
+
p
|
|
67
|
+
for p in directory.iterdir()
|
|
68
|
+
if p.is_file() and p.suffix == ".py" and not p.name.startswith(("_", "."))
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load_module(path: Path, module_name: str) -> Any:
|
|
73
|
+
try:
|
|
74
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
75
|
+
if spec is None or spec.loader is None:
|
|
76
|
+
raise PluginLoadError(f"cannot create module spec for {path.name}")
|
|
77
|
+
module = importlib.util.module_from_spec(spec)
|
|
78
|
+
spec.loader.exec_module(module)
|
|
79
|
+
return module
|
|
80
|
+
except PluginLoadError:
|
|
81
|
+
raise
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
raise PluginLoadError(
|
|
84
|
+
f"failed to import plugin {path.name}: {exc}\n{traceback.format_exc(limit=3)}"
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _iter_detector_classes(module: Any) -> Iterator[type[Detector]]:
|
|
89
|
+
for obj in vars(module).values():
|
|
90
|
+
if isinstance(obj, type) and isinstance(obj, Detector):
|
|
91
|
+
yield cast(type[Detector], obj)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_plugin_detectors(
|
|
95
|
+
registry: DetectorRegistry,
|
|
96
|
+
plugin_dirs: Sequence[str | Path],
|
|
97
|
+
) -> list[str]:
|
|
98
|
+
"""从目录加载插件检测器并注册,返回新注册的 detector_id 列表。
|
|
99
|
+
|
|
100
|
+
加载顺序:目录按给定顺序,文件按名称排序(确定性)。
|
|
101
|
+
"""
|
|
102
|
+
loaded: list[str] = []
|
|
103
|
+
for directory in plugin_dirs:
|
|
104
|
+
path = Path(directory)
|
|
105
|
+
for file_path in _module_paths(path):
|
|
106
|
+
module_name = f"datasentry_plugin_{path.name}_{file_path.stem}"
|
|
107
|
+
module = _load_module(file_path, module_name)
|
|
108
|
+
for cls in _iter_detector_classes(module):
|
|
109
|
+
try:
|
|
110
|
+
detector = cls()
|
|
111
|
+
registry.register(detector)
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
raise PluginLoadError(
|
|
114
|
+
f"failed to register detector from {file_path.name}: {exc}"
|
|
115
|
+
) from exc
|
|
116
|
+
loaded.append(detector.detector_id)
|
|
117
|
+
return loaded
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _entry_points_for(group: str) -> Sequence[Any]:
|
|
121
|
+
"""importlib.metadata.entry_points 按组筛选(3.10+ 均支持 select)。"""
|
|
122
|
+
return importlib.metadata.entry_points().select(group=group)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _coerce_entry_value(value: Any, name: str) -> Detector:
|
|
126
|
+
"""entry 值 → Detector 实例:类无参实例化 / 实例直通 / 工厂调用。"""
|
|
127
|
+
if isinstance(value, type):
|
|
128
|
+
return cast(Detector, value())
|
|
129
|
+
if isinstance(value, Detector):
|
|
130
|
+
return value
|
|
131
|
+
if callable(value):
|
|
132
|
+
detector = cast(Detector, value())
|
|
133
|
+
if not isinstance(detector, Detector):
|
|
134
|
+
raise TypeError(f"factory returned {type(detector).__name__}, expected Detector")
|
|
135
|
+
return detector
|
|
136
|
+
raise TypeError(f"entry value is {type(value).__name__}, expected Detector/class/factory")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def discover_entrypoint_detectors(
|
|
140
|
+
registry: DetectorRegistry,
|
|
141
|
+
group: str = DETECTOR_ENTRY_POINT_GROUP,
|
|
142
|
+
) -> PluginLoadReport:
|
|
143
|
+
"""发现并注册已安装包的检测器(entry points,V2-C / ADR-050)。
|
|
144
|
+
|
|
145
|
+
优雅降级:单个入口加载/实例化/注册失败记录到 `errors`(含入口名与原因),
|
|
146
|
+
其余入口与内置检测器不受影响;不会中断扫描。
|
|
147
|
+
"""
|
|
148
|
+
report = PluginLoadReport()
|
|
149
|
+
for entry_point in _entry_points_for(group):
|
|
150
|
+
name = entry_point.name
|
|
151
|
+
try:
|
|
152
|
+
detector = _coerce_entry_value(entry_point.load(), name)
|
|
153
|
+
try:
|
|
154
|
+
registry.register(detector)
|
|
155
|
+
except ValueError as exc:
|
|
156
|
+
report.errors.append(PluginError(name=name, message=str(exc)))
|
|
157
|
+
continue
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
report.errors.append(
|
|
160
|
+
PluginError(
|
|
161
|
+
name=name,
|
|
162
|
+
message=f"{type(exc).__name__}: {exc}\n{traceback.format_exc(limit=3)}",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
continue
|
|
166
|
+
report.loaded.append(detector.detector_id)
|
|
167
|
+
return report
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = [
|
|
171
|
+
"DETECTOR_ENTRY_POINT_GROUP",
|
|
172
|
+
"PluginError",
|
|
173
|
+
"PluginLoadError",
|
|
174
|
+
"PluginLoadReport",
|
|
175
|
+
"discover_entrypoint_detectors",
|
|
176
|
+
"load_plugin_detectors",
|
|
177
|
+
]
|
|
@@ -12,6 +12,7 @@ HTML/Markdown 为同一 dict 的纯函数渲染(无外部依赖,HTML 单文
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import re
|
|
15
16
|
from datetime import UTC, datetime
|
|
16
17
|
from typing import Any
|
|
17
18
|
|
|
@@ -19,6 +20,7 @@ from datasentry_core import __version__
|
|
|
19
20
|
from datasentry_core.models.issue import Issue
|
|
20
21
|
from datasentry_core.models.quality import QualityScore
|
|
21
22
|
from datasentry_core.models.scan import DetectorRun, ScanRun
|
|
23
|
+
from datasentry_core.privacy.redactor import redact
|
|
22
24
|
|
|
23
25
|
#: 26 章规范报告结构(build_report 返回,JSON 机器契约)
|
|
24
26
|
type Report = dict[str, Any]
|
|
@@ -66,6 +68,20 @@ def _severity_rank(severity: str) -> int:
|
|
|
66
68
|
return {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}.get(severity, 9)
|
|
67
69
|
|
|
68
70
|
|
|
71
|
+
_PII_HOLDER_RE = re.compile(r"\{\{REDACTED:\w+:\d+\}\}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def mask_text_pii(text: str) -> str:
|
|
75
|
+
"""人类可读输出前的 PII 防御性掩码(Step 48,ADR-048)。
|
|
76
|
+
|
|
77
|
+
命中的 PII(email/手机号/身份证/IP/URL)替换为 [REDACTED];
|
|
78
|
+
无 PII 文本原样返回。JSON 机器契约不调用(保留完整证据链)。
|
|
79
|
+
"""
|
|
80
|
+
if not text:
|
|
81
|
+
return text
|
|
82
|
+
return _PII_HOLDER_RE.sub("[REDACTED]", redact(text).masked)
|
|
83
|
+
|
|
84
|
+
|
|
69
85
|
def critical_findings(report: Report) -> list[dict[str, Any]]:
|
|
70
86
|
"""Critical Findings:按严重度与 priority_score 排序取前 N。"""
|
|
71
87
|
return sorted(
|
|
@@ -4,6 +4,9 @@ MVP 节(26 章清单可填充部分):Executive Summary / Dataset Overview
|
|
|
4
4
|
Quality Score(含 27.3 维度条形分解与「哪些 Issue 扣分」悬停)/ Issue Breakdown /
|
|
5
5
|
Critical Findings / Column Profiles / Methodology / Reproducibility。
|
|
6
6
|
Drift / Suggested Rules / Repair History 归 V1。
|
|
7
|
+
Step 49(V2-B,ADR-049):Issue Breakdown 升级为交互表格(severity/维度筛选、
|
|
8
|
+
列排序、详情折叠、分页,原生 JS 零依赖内联);可选 Quality Trends 迷你 SVG
|
|
9
|
+
(消费 trends.py 序列化数据);server_base_url 非空时 issue 行联动修复工作台。
|
|
7
10
|
"""
|
|
8
11
|
|
|
9
12
|
from __future__ import annotations
|
|
@@ -11,7 +14,8 @@ from __future__ import annotations
|
|
|
11
14
|
from html import escape
|
|
12
15
|
from typing import Any
|
|
13
16
|
|
|
14
|
-
from datasentry_core.reporting import HTML_SECTIONS, Report, critical_findings
|
|
17
|
+
from datasentry_core.reporting import HTML_SECTIONS, Report, critical_findings, mask_text_pii
|
|
18
|
+
from datasentry_core.reporting.interactive import render_interactive_issue_table, render_trend_svg
|
|
15
19
|
|
|
16
20
|
_CSS = """
|
|
17
21
|
:root { color-scheme: light; }
|
|
@@ -32,6 +36,24 @@ th { background: #f6f8fa; }
|
|
|
32
36
|
footer { margin-top: 3rem; font-size: .8rem; color: #57606a; border-top: 1px solid #d0d7de; }
|
|
33
37
|
.notes { font-size: .85rem; color: #57606a; background: #f6f8fa; padding: .6rem;
|
|
34
38
|
border-radius: .4rem; }
|
|
39
|
+
.issue-controls { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap;
|
|
40
|
+
margin: .6rem 0; }
|
|
41
|
+
.issue-controls select, .issue-controls input[type=search] { padding: .25rem .45rem;
|
|
42
|
+
font-size: .85rem; }
|
|
43
|
+
.issue-row td { cursor: pointer; }
|
|
44
|
+
.issue-detail td { background: #f6f8fa; font-size: .85rem; }
|
|
45
|
+
.issue-detail.collapsed { display: none; }
|
|
46
|
+
th[data-key] { cursor: pointer; user-select: none; white-space: nowrap; }
|
|
47
|
+
th[data-key]::after { content: " \\21C5"; font-size: .7rem; color: #8c959f; }
|
|
48
|
+
th[data-key].sorted-asc::after { content: " \\2191"; }
|
|
49
|
+
th[data-key].sorted-desc::after { content: " \\2193"; }
|
|
50
|
+
.trend-block { margin: 1rem 0; }
|
|
51
|
+
.trend-svg { display: block; background: #fff; border: 1px solid #d0d7de;
|
|
52
|
+
border-radius: .4rem; padding: .3rem; }
|
|
53
|
+
.issue-pager { display: flex; gap: .6rem; align-items: center; margin: .6rem 0; }
|
|
54
|
+
.issue-pager button { background: #f6f8fa; color: #0969da; border: 1px solid #d0d7de;
|
|
55
|
+
border-radius: .3rem; padding: .2rem .7rem; cursor: pointer; }
|
|
56
|
+
.workbench-link { margin-left: .6rem; font-size: .8rem; }
|
|
35
57
|
"""
|
|
36
58
|
|
|
37
59
|
_BAR_COLORS = [
|
|
@@ -44,8 +66,18 @@ _BAR_COLORS = [
|
|
|
44
66
|
]
|
|
45
67
|
|
|
46
68
|
|
|
47
|
-
def render_html(
|
|
48
|
-
|
|
69
|
+
def render_html(
|
|
70
|
+
report: Report,
|
|
71
|
+
*,
|
|
72
|
+
trends: list[dict[str, Any]] | None = None,
|
|
73
|
+
page_size: int = 25,
|
|
74
|
+
server_base_url: str | None = None,
|
|
75
|
+
) -> str:
|
|
76
|
+
"""26 章报告 → 自包含 HTML(内嵌 CSS/JS,无外部资源)。
|
|
77
|
+
|
|
78
|
+
trends:trends.py `DatasetTrend.to_report_dict()` 列表 → Quality Trends 迷你 SVG;
|
|
79
|
+
server_base_url:非空时 issue 行附带修复工作台链接(server 模式联动 REST API)。
|
|
80
|
+
"""
|
|
49
81
|
parts = [
|
|
50
82
|
"<!DOCTYPE html>",
|
|
51
83
|
'<html lang="en"><head><meta charset="utf-8">',
|
|
@@ -55,14 +87,20 @@ def render_html(report: Report) -> str:
|
|
|
55
87
|
_meta(report),
|
|
56
88
|
_executive_summary(report),
|
|
57
89
|
_quality_score(report),
|
|
58
|
-
_issue_breakdown(report),
|
|
59
|
-
_critical_findings(report),
|
|
60
|
-
_dataset_overview(report),
|
|
61
|
-
_methodology(report),
|
|
62
|
-
_reproducibility(report),
|
|
63
|
-
_footer(report),
|
|
64
|
-
"</body></html>",
|
|
65
90
|
]
|
|
91
|
+
if trends:
|
|
92
|
+
parts.append(_trends_section(trends))
|
|
93
|
+
parts.extend(
|
|
94
|
+
[
|
|
95
|
+
_issue_breakdown(report, page_size=page_size, server_base_url=server_base_url),
|
|
96
|
+
_critical_findings(report),
|
|
97
|
+
_dataset_overview(report),
|
|
98
|
+
_methodology(report),
|
|
99
|
+
_reproducibility(report),
|
|
100
|
+
_footer(report),
|
|
101
|
+
"</body></html>",
|
|
102
|
+
]
|
|
103
|
+
)
|
|
66
104
|
return "\n".join(parts)
|
|
67
105
|
|
|
68
106
|
|
|
@@ -141,24 +179,22 @@ def _contributions_tooltip(quality: dict[str, Any], dim: str) -> str:
|
|
|
141
179
|
return f"{dim}: " + "; ".join(f"{i}: {v:.4f}" for i, v in items.items())
|
|
142
180
|
|
|
143
181
|
|
|
144
|
-
def
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
body = "".join(rows) if report["issues"] else '<tr><td colspan="6">no issues</td></tr>'
|
|
161
|
-
return f'<h2 id="issue_breakdown">Issue Breakdown</h2><table>{body}</table>'
|
|
182
|
+
def _trends_section(trends: list[dict[str, Any]]) -> str:
|
|
183
|
+
svgs = [svg for svg in (render_trend_svg(t) for t in trends) if svg]
|
|
184
|
+
if not svgs:
|
|
185
|
+
return ""
|
|
186
|
+
return '<h2 id="quality_trends">Quality Trends</h2>' + "".join(svgs)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _issue_breakdown(
|
|
190
|
+
report: Report,
|
|
191
|
+
*,
|
|
192
|
+
page_size: int = 25,
|
|
193
|
+
server_base_url: str | None = None,
|
|
194
|
+
) -> str:
|
|
195
|
+
return render_interactive_issue_table(
|
|
196
|
+
report, page_size=page_size, server_base_url=server_base_url
|
|
197
|
+
)
|
|
162
198
|
|
|
163
199
|
|
|
164
200
|
def _critical_findings(report: Report) -> str:
|
|
@@ -170,7 +206,8 @@ def _critical_findings(report: Report) -> str:
|
|
|
170
206
|
items.append(
|
|
171
207
|
"<li>"
|
|
172
208
|
f'<span class="badge-{escape(issue["severity"])}">[{escape(issue["severity"])}]</span> '
|
|
173
|
-
f"{escape(issue['title'])} —
|
|
209
|
+
f"{escape(mask_text_pii(issue['title']))} — "
|
|
210
|
+
f"priority {issue['priority_score']:.1f}, "
|
|
174
211
|
f"{issue['affected_count']} rows affected"
|
|
175
212
|
"</li>"
|
|
176
213
|
)
|