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.
Files changed (83) hide show
  1. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/PKG-INFO +2 -2
  2. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/pyproject.toml +1 -1
  3. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/__init__.py +1 -1
  4. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/llm.py +1 -0
  5. datasentry_core-0.3.0/src/datasentry_core/plugins.py +177 -0
  6. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/reporting/__init__.py +16 -0
  7. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/reporting/html.py +66 -29
  8. datasentry_core-0.3.0/src/datasentry_core/reporting/interactive.py +369 -0
  9. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/reporting/markdown.py +3 -3
  10. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/storage/schema.py +49 -1
  11. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/storage/store.py +76 -2
  12. datasentry_core-0.1.0/src/datasentry_core/plugins.py +0 -91
  13. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/.gitignore +0 -0
  14. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/README.md +0 -0
  15. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/__init__.py +0 -0
  16. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/base.py +0 -0
  17. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/csv.py +0 -0
  18. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/duckdb.py +0 -0
  19. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/errors.py +0 -0
  20. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/file_based.py +0 -0
  21. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/jsonl.py +0 -0
  22. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/parquet.py +0 -0
  23. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/registry.py +0 -0
  24. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/spec.py +0 -0
  25. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/connectors/xlsx.py +0 -0
  26. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/contracts/__init__.py +0 -0
  27. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/contracts/exporters.py +0 -0
  28. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/__init__.py +0 -0
  29. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/base.py +0 -0
  30. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/common.py +0 -0
  31. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/cross_field.py +0 -0
  32. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/cross_table.py +0 -0
  33. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/datetime.py +0 -0
  34. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/__init__.py +0 -0
  35. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/anomaly_ml.py +0 -0
  36. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/categorical.py +0 -0
  37. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/formula.py +0 -0
  38. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/missing.py +0 -0
  39. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/numeric.py +0 -0
  40. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/textual.py +0 -0
  41. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/initial/uniqueness.py +0 -0
  42. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/missingness.py +0 -0
  43. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/runner.py +0 -0
  44. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/safe_eval.py +0 -0
  45. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/detectors/textual_variants.py +0 -0
  46. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/drift/__init__.py +0 -0
  47. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/drift/engine.py +0 -0
  48. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/engine/__init__.py +0 -0
  49. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/engine/base.py +0 -0
  50. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/engine/duckdb.py +0 -0
  51. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/engine/fusion.py +0 -0
  52. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/engine/profiler.py +0 -0
  53. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/engine/sql_guard.py +0 -0
  54. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/llm/provider.py +0 -0
  55. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/__init__.py +0 -0
  56. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/audit.py +0 -0
  57. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/contract.py +0 -0
  58. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/detector.py +0 -0
  59. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/drift.py +0 -0
  60. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/enums.py +0 -0
  61. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/evidence.py +0 -0
  62. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/feedback.py +0 -0
  63. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/fingerprint.py +0 -0
  64. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/issue.py +0 -0
  65. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/profile.py +0 -0
  66. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/quality.py +0 -0
  67. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/repair.py +0 -0
  68. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/rules.py +0 -0
  69. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/scan.py +0 -0
  70. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/models/validation.py +0 -0
  71. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/privacy/redactor.py +0 -0
  72. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/repair/__init__.py +0 -0
  73. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/repair/engine.py +0 -0
  74. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/reporting/junit.py +0 -0
  75. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/reporting/sarif.py +0 -0
  76. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/rules/engine.py +0 -0
  77. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/scoring/__init__.py +0 -0
  78. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/scoring/engine.py +0 -0
  79. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/scoring/gate.py +0 -0
  80. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/scoring/quality.py +0 -0
  81. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/scoring/weights.py +0 -0
  82. {datasentry_core-0.1.0 → datasentry_core-0.3.0}/src/datasentry_core/storage/__init__.py +0 -0
  83. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: datasentry-core
3
- Version: 0.1.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datasentry-core"
3
- version = "0.1.0"
3
+ version = "0.3.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.1.0"
111
+ __version__ = "0.3.0"
@@ -66,4 +66,5 @@ class LLMInvocation(BaseModel):
66
66
  masked_sample_count: int = Field(default=0, ge=0)
67
67
  injection_flagged: bool = False
68
68
  error_message: str | None = None
69
+ pii_session_id: str | None = None
69
70
  created_at: datetime = Field(default_factory=utcnow)
@@ -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(report: Report) -> str:
48
- """26 章报告 → 自包含 HTML(内嵌 CSS,无外部资源)。"""
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 _issue_breakdown(report: Report) -> str:
145
- rows = [
146
- "<tr><th>Severity</th><th>Priority</th><th>Title</th>"
147
- "<th>Columns</th><th>Detectors</th><th>Affected</th></tr>"
148
- ]
149
- for issue in report["issues"]:
150
- rows.append(
151
- "<tr>"
152
- f'<td class="badge-{escape(issue["severity"])}">{escape(issue["severity"])}</td>'
153
- f"<td>{issue['priority_score']:.1f}</td>"
154
- f"<td>{escape(issue['title'])}</td>"
155
- f"<td>{escape(', '.join(issue['columns']))}</td>"
156
- f"<td>{escape(', '.join(issue['detector_ids']))}</td>"
157
- f"<td>{issue['affected_count']} ({issue['affected_ratio']:.4f})</td>"
158
- "</tr>"
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'])} &mdash; priority {issue['priority_score']:.1f}, "
209
+ f"{escape(mask_text_pii(issue['title']))} &mdash; "
210
+ f"priority {issue['priority_score']:.1f}, "
174
211
  f"{issue['affected_count']} rows affected"
175
212
  "</li>"
176
213
  )