dsh-data-quality 0.2.0 → 0.3.1
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.
- package/CHANGELOG.md +14 -0
- package/README.es.md +51 -5
- package/README.hi.md +51 -5
- package/README.md +43 -3
- package/README.pt.md +51 -5
- package/README.zh.md +51 -5
- package/SECURITY.md +39 -0
- package/SUMMARY.md +147 -0
- package/lib/index.js +224 -25
- package/lib/types/events.d.ts +10 -10
- package/lib/types/events.js +10 -10
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +2 -0
- package/lib/types/index.js.map +1 -1
- package/lib/types/profile.d.ts +2 -0
- package/lib/types/profile.d.ts.map +1 -1
- package/lib/types/profile.js +2 -0
- package/lib/types/profile.js.map +1 -1
- package/lib/types/report-html.d.ts +41 -0
- package/lib/types/report-html.d.ts.map +1 -0
- package/lib/types/report-html.js +192 -0
- package/lib/types/report-html.js.map +1 -0
- package/lib/types/tools/profile-report-schema.d.ts +4 -0
- package/lib/types/tools/profile-report-schema.d.ts.map +1 -1
- package/lib/types/tools/profile-report-schema.js +1 -0
- package/lib/types/tools/profile-report-schema.js.map +1 -1
- package/lib/types/tools/report.d.ts.map +1 -1
- package/lib/types/tools/report.js +28 -2
- package/lib/types/tools/report.js.map +1 -1
- package/lib/types/version.d.ts +8 -1
- package/lib/types/version.d.ts.map +1 -1
- package/lib/types/version.js +8 -1
- package/lib/types/version.js.map +1 -1
- package/package.json +9 -2
- package/src/events.ts +11 -11
- package/src/index.ts +2 -0
- package/src/profile.ts +4 -0
- package/src/report-html.ts +208 -0
- package/src/tools/profile-report-schema.ts +1 -0
- package/src/tools/report.ts +31 -2
- package/src/version.ts +9 -1
package/src/profile.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { createHash } from 'node:crypto'
|
|
10
10
|
import { isMissing, parseBoolean, parseDate, parseNumeric, sampleRows, throwIfAborted, type EncodingInfo, type Row, type Table } from './dataset.ts'
|
|
11
11
|
import { computeScorecard, type DataQualityScorecard, type ScorecardDimensionName } from './scorecard.ts'
|
|
12
|
+
import { REPORT_SCHEMA_VERSION } from './version.ts'
|
|
12
13
|
|
|
13
14
|
/** Inferred column type from parsed cell classes. */
|
|
14
15
|
export type InferredType = 'number' | 'date' | 'boolean' | 'string' | 'empty' | 'mixed'
|
|
@@ -59,6 +60,8 @@ export interface DuplicateDetection {
|
|
|
59
60
|
|
|
60
61
|
/** The full profile report (also the persisted and tool-returned value). */
|
|
61
62
|
export interface ProfileReport {
|
|
63
|
+
/** The persisted-report schema version (see {@link REPORT_SCHEMA_VERSION}). */
|
|
64
|
+
readonly schemaVersion: number
|
|
62
65
|
readonly dataset: string
|
|
63
66
|
readonly rowCount: number
|
|
64
67
|
/** Whether column cards describe a systematic sample rather than every row. */
|
|
@@ -286,6 +289,7 @@ export function profileTable(
|
|
|
286
289
|
signal: options.signal,
|
|
287
290
|
})
|
|
288
291
|
return {
|
|
292
|
+
schemaVersion: REPORT_SCHEMA_VERSION,
|
|
289
293
|
dataset: options.dataset,
|
|
290
294
|
rowCount: table.rows.length,
|
|
291
295
|
sampled: profiled.length !== table.rows.length,
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained offline HTML renderers for persisted data-quality reports.
|
|
3
|
+
*
|
|
4
|
+
* `data_report` (with `format: html`) turns a profile or clean report into a
|
|
5
|
+
* single `.html` file that opens offline: every style rule and every script is
|
|
6
|
+
* inlined, there are no external requests (no CDN, no `<link>`, no `<script
|
|
7
|
+
* src>`), and all dataset values are HTML-escaped before they reach the
|
|
8
|
+
* markup. The document carries the DAMA six-dimension scorecard, the
|
|
9
|
+
* per-column profile summary, and (for clean reports) the per-rule cleaning
|
|
10
|
+
* summary table.
|
|
11
|
+
*
|
|
12
|
+
* Pure and deterministic: the only clock is the report's own `generatedAt`;
|
|
13
|
+
* nothing here reads the filesystem or the network.
|
|
14
|
+
* @module dsh-data-quality/report-html
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ProfileReport } from './profile.ts'
|
|
18
|
+
import { REPORT_SCHEMA_VERSION } from './version.ts'
|
|
19
|
+
|
|
20
|
+
/** Minimal structural view of a clean report's per-rule audit + row contract. */
|
|
21
|
+
export interface CleanReportHtml {
|
|
22
|
+
readonly inputRows: number
|
|
23
|
+
readonly outputRows: number
|
|
24
|
+
readonly logs: ReadonlyArray<{ readonly rule: string; readonly affectedRows: number; readonly detail: string }>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Escape text for safe embedding in an HTML document. */
|
|
28
|
+
function escapeHtml(text: string): string {
|
|
29
|
+
return text
|
|
30
|
+
.replace(/&/g, '&')
|
|
31
|
+
.replace(/</g, '<')
|
|
32
|
+
.replace(/>/g, '>')
|
|
33
|
+
.replace(/"/g, '"')
|
|
34
|
+
.replace(/'/g, ''')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Render a 0..1 rate as a percentage string, or `—` when undetermined. */
|
|
38
|
+
function pct(score: number | null): string {
|
|
39
|
+
return score === null ? '—' : `${(score * 100).toFixed(1)}%`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The shared document shell: inline CSS + inline JS, no external requests. */
|
|
43
|
+
function shell(title: string, body: string, script: string): string {
|
|
44
|
+
return [
|
|
45
|
+
'<!doctype html>',
|
|
46
|
+
'<html lang="en">',
|
|
47
|
+
'<head>',
|
|
48
|
+
'<meta charset="utf-8">',
|
|
49
|
+
`<title>${escapeHtml(title)}</title>`,
|
|
50
|
+
'<style>',
|
|
51
|
+
' :root { --ink: #1a1f2e; --muted: #6b7280; --line: #e5e7eb; --accent: #0f766e; --fail: #b91c1c; --warn: #b45309; --pass: #15803d; }',
|
|
52
|
+
' * { box-sizing: border-box; }',
|
|
53
|
+
' body { margin: 0; padding: 24px; font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f8fafc; }',
|
|
54
|
+
' header { margin-bottom: 20px; }',
|
|
55
|
+
' h1 { font-size: 20px; margin: 0 0 4px; }',
|
|
56
|
+
' .meta { color: var(--muted); font-size: 12px; }',
|
|
57
|
+
' section { background: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; margin-bottom: 16px; }',
|
|
58
|
+
' h2 { font-size: 15px; margin: 0 0 12px; }',
|
|
59
|
+
' table { border-collapse: collapse; width: 100%; font-size: 13px; }',
|
|
60
|
+
' th, td { text-align: left; padding: 6px 10px; border-top: 1px solid var(--line); vertical-align: top; }',
|
|
61
|
+
' th { color: var(--muted); font-weight: 600; }',
|
|
62
|
+
' .score-cell { font-variant-numeric: tabular-nums; }',
|
|
63
|
+
' .dim-fail { color: var(--fail); font-weight: 600; }',
|
|
64
|
+
' .dim-warn { color: var(--warn); }',
|
|
65
|
+
' .dim-pass { color: var(--pass); }',
|
|
66
|
+
' .dim-undetermined { color: var(--muted); }',
|
|
67
|
+
' button { cursor: pointer; font: inherit; }',
|
|
68
|
+
'</style>',
|
|
69
|
+
'</head>',
|
|
70
|
+
'<body>',
|
|
71
|
+
body,
|
|
72
|
+
'<script>',
|
|
73
|
+
script,
|
|
74
|
+
'</script>',
|
|
75
|
+
'</body>',
|
|
76
|
+
'</html>',
|
|
77
|
+
'',
|
|
78
|
+
].join('\n')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The DAMA six-dimension scorecard section. */
|
|
82
|
+
function scorecardSection(report: ProfileReport): string {
|
|
83
|
+
const rows = report.scorecard.dimensions.map((dimension) => {
|
|
84
|
+
const cls = dimension.score === null
|
|
85
|
+
? 'dim-undetermined'
|
|
86
|
+
: dimension.score >= 0.9 ? 'dim-pass'
|
|
87
|
+
: dimension.score >= 0.7 ? 'dim-warn'
|
|
88
|
+
: 'dim-fail'
|
|
89
|
+
return `<tr><td>${escapeHtml(dimension.name)}</td><td class="score-cell ${cls}">${pct(dimension.score)}</td><td>${escapeHtml(dimension.note)}</td></tr>`
|
|
90
|
+
}).join('\n')
|
|
91
|
+
const overall = report.scorecard.overall
|
|
92
|
+
const weighted = report.scorecard.weightedOverall
|
|
93
|
+
return [
|
|
94
|
+
'<section>',
|
|
95
|
+
'<h2>DAMA six-dimension quality scorecard</h2>',
|
|
96
|
+
'<table>',
|
|
97
|
+
'<thead><tr><th>Dimension</th><th>Score</th><th>Note</th></tr></thead>',
|
|
98
|
+
'<tbody>',
|
|
99
|
+
rows,
|
|
100
|
+
'</tbody>',
|
|
101
|
+
'</table>',
|
|
102
|
+
`<p class="meta" id="summary-text">overall ${pct(overall)} · weighted ${pct(weighted)}</p>`,
|
|
103
|
+
'</section>',
|
|
104
|
+
].join('\n')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The per-column profile summary table. */
|
|
108
|
+
function columnsSection(report: ProfileReport): string {
|
|
109
|
+
const rows = report.columns.map((column) => {
|
|
110
|
+
const numeric = column.numeric
|
|
111
|
+
const numericCell = numeric === undefined
|
|
112
|
+
? '—'
|
|
113
|
+
: `min ${numeric.min} · p25 ${numeric.p25} · median ${numeric.median} · p75 ${numeric.p75} · max ${numeric.max} · mean ${numeric.mean}${numeric.outliers > 0 ? ` · ${numeric.outliers} IQR outliers` : ''}`
|
|
114
|
+
const top = column.topValues === undefined
|
|
115
|
+
? '—'
|
|
116
|
+
: column.topValues.map((entry) => `${escapeHtml(entry.value)} ×${entry.count}`).join(', ')
|
|
117
|
+
const notes = column.notes.length === 0 ? '' : `<p class="meta">${column.notes.map(escapeHtml).join('; ')}</p>`
|
|
118
|
+
return [
|
|
119
|
+
'<tr>',
|
|
120
|
+
`<td>${escapeHtml(column.name)}</td>`,
|
|
121
|
+
`<td>${escapeHtml(column.inferredType)}</td>`,
|
|
122
|
+
`<td class="score-cell">${column.missing}</td>`,
|
|
123
|
+
`<td class="score-cell">${(column.missingRate * 100).toFixed(1)}%</td>`,
|
|
124
|
+
`<td class="score-cell">${column.unique}</td>`,
|
|
125
|
+
`<td>${numericCell}</td>`,
|
|
126
|
+
`<td>${top}</td>`,
|
|
127
|
+
`<td>${notes}</td>`,
|
|
128
|
+
'</tr>',
|
|
129
|
+
].join('')
|
|
130
|
+
}).join('\n')
|
|
131
|
+
return [
|
|
132
|
+
'<section>',
|
|
133
|
+
'<h2>Column profile</h2>',
|
|
134
|
+
'<table>',
|
|
135
|
+
'<thead><tr><th>Column</th><th>Type</th><th>Missing</th><th>Missing rate</th><th>Unique</th><th>Numeric distribution</th><th>Top values</th><th>Notes</th></tr></thead>',
|
|
136
|
+
'<tbody>',
|
|
137
|
+
rows,
|
|
138
|
+
'</tbody>',
|
|
139
|
+
'</table>',
|
|
140
|
+
'</section>',
|
|
141
|
+
].join('\n')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Render a profile report as a self-contained offline HTML document.
|
|
146
|
+
* @param report - the profile report (already persisted/returned by data_profile).
|
|
147
|
+
* @returns the complete single-file HTML.
|
|
148
|
+
*/
|
|
149
|
+
export function renderProfileHtml(report: ProfileReport): string {
|
|
150
|
+
const body = [
|
|
151
|
+
'<header>',
|
|
152
|
+
`<h1>Data profile: ${escapeHtml(report.dataset)}</h1>`,
|
|
153
|
+
`<p class="meta">${report.rowCount} rows × ${report.columnCount} columns · generated ${new Date(report.generatedAt).toISOString()} · schema v${REPORT_SCHEMA_VERSION} · report ${report.reportKey ?? '(unpersisted)'}</p>`,
|
|
154
|
+
'<button id="copy-summary" type="button">Copy summary</button>',
|
|
155
|
+
'</header>',
|
|
156
|
+
scorecardSection(report),
|
|
157
|
+
columnsSection(report),
|
|
158
|
+
].join('\n')
|
|
159
|
+
const script = [
|
|
160
|
+
"const button = document.getElementById('copy-summary');",
|
|
161
|
+
'if (button) {',
|
|
162
|
+
' button.addEventListener("click", () => {',
|
|
163
|
+
' const text = document.getElementById("summary-text");',
|
|
164
|
+
' if (text && navigator.clipboard) navigator.clipboard.writeText(text.textContent || "");',
|
|
165
|
+
' });',
|
|
166
|
+
'}',
|
|
167
|
+
].join('\n')
|
|
168
|
+
return shell(`Data profile: ${report.dataset}`, body, script)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The per-rule cleaning summary table (for clean/clean-diff reports). */
|
|
172
|
+
function cleaningSection(report: CleanReportHtml): string {
|
|
173
|
+
const rows = report.logs.map((log) => {
|
|
174
|
+
return `<tr><td>${escapeHtml(log.rule)}</td><td class="score-cell">${log.affectedRows}</td><td>${escapeHtml(log.detail)}</td></tr>`
|
|
175
|
+
}).join('\n')
|
|
176
|
+
const removed = report.inputRows - report.outputRows
|
|
177
|
+
return [
|
|
178
|
+
'<section>',
|
|
179
|
+
'<h2>Cleaning summary</h2>',
|
|
180
|
+
`<p class="meta" id="summary-text">input ${report.inputRows} rows · output ${report.outputRows} rows · removed ${removed} rows</p>`,
|
|
181
|
+
'<table>',
|
|
182
|
+
'<thead><tr><th>Rule</th><th>Affected rows</th><th>Detail</th></tr></thead>',
|
|
183
|
+
'<tbody>',
|
|
184
|
+
rows,
|
|
185
|
+
'</tbody>',
|
|
186
|
+
'</table>',
|
|
187
|
+
'</section>',
|
|
188
|
+
].join('\n')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Render a clean report as a self-contained offline HTML document (the
|
|
193
|
+
* per-rule cleaning summary table).
|
|
194
|
+
* @param report - the clean report (logs + input/output row counts).
|
|
195
|
+
* @param dataset - the dataset label for the document title.
|
|
196
|
+
* @returns the complete single-file HTML.
|
|
197
|
+
*/
|
|
198
|
+
export function renderCleanHtml(report: CleanReportHtml, dataset: string): string {
|
|
199
|
+
const body = [
|
|
200
|
+
'<header>',
|
|
201
|
+
`<h1>Cleaning report: ${escapeHtml(dataset)}</h1>`,
|
|
202
|
+
`<p class="meta">schema v${REPORT_SCHEMA_VERSION}</p>`,
|
|
203
|
+
'</header>',
|
|
204
|
+
cleaningSection(report),
|
|
205
|
+
].join('\n')
|
|
206
|
+
const script = "// no interactive behavior needed; everything renders without external requests\n"
|
|
207
|
+
return shell(`Cleaning report: ${dataset}`, body, script)
|
|
208
|
+
}
|
|
@@ -51,6 +51,7 @@ export const COLUMN_PROFILE_SCHEMA = {
|
|
|
51
51
|
export const PROFILE_REPORT_SCHEMA = {
|
|
52
52
|
type: 'object',
|
|
53
53
|
properties: {
|
|
54
|
+
schemaVersion: { type: 'number', required: true },
|
|
54
55
|
dataset: { type: 'string', required: true },
|
|
55
56
|
rowCount: { type: 'number', required: true },
|
|
56
57
|
sampled: { type: 'boolean', required: true },
|
package/src/tools/report.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
9
9
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
10
|
+
import type { ProfileReport } from '../profile.ts'
|
|
11
|
+
import { renderCleanHtml, renderProfileHtml, type CleanReportHtml } from '../report-html.ts'
|
|
10
12
|
import type { DataQualityService } from '../service.ts'
|
|
11
13
|
import type { ReportRecord, StoredReport } from '../store.ts'
|
|
12
14
|
|
|
@@ -27,6 +29,19 @@ interface DataReportValue {
|
|
|
27
29
|
readonly key?: string
|
|
28
30
|
readonly kind?: ReportRecord['kind']
|
|
29
31
|
readonly records: ReportView[]
|
|
32
|
+
/** Self-contained offline HTML (present only when `format: html`). */
|
|
33
|
+
readonly html?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Render one stored report as a self-contained HTML document (profile/clean only). */
|
|
37
|
+
function renderRecordHtml(record: StoredReport): string {
|
|
38
|
+
if (record.kind === 'profile') {
|
|
39
|
+
return renderProfileHtml(record.report as unknown as ProfileReport)
|
|
40
|
+
}
|
|
41
|
+
if (record.kind === 'clean' || record.kind === 'clean-diff') {
|
|
42
|
+
return renderCleanHtml(record.report as unknown as CleanReportHtml, record.dataset)
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`data_report html format does not support kind "${record.kind}" (profile/clean only)`)
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
/** Project a stored report into the canonical value (the stored report is already lossless JSON). */
|
|
@@ -64,6 +79,7 @@ export function defineReportTool(service: DataQualityService) {
|
|
|
64
79
|
parameters: {
|
|
65
80
|
key: { type: 'string', description: 'Exact storage reportKey (e.g. 20260819000000000-profile-1a2b3c4d); fetches that one report.' },
|
|
66
81
|
kind: { type: 'string', enum: [...REPORT_KINDS], description: 'Report kind to list (profile/clean/clean-diff/verify/citations).' },
|
|
82
|
+
format: { type: 'string', enum: ['json', 'html'], description: 'Output format. json (default) returns the report envelope(s); html renders one report as a self-contained offline HTML document (requires key; profile/clean only).' },
|
|
67
83
|
},
|
|
68
84
|
output: {
|
|
69
85
|
schema: {
|
|
@@ -86,10 +102,15 @@ export function defineReportTool(service: DataQualityService) {
|
|
|
86
102
|
},
|
|
87
103
|
required: true,
|
|
88
104
|
},
|
|
105
|
+
html: { type: 'string', description: 'Self-contained offline HTML (present only when format: html).' },
|
|
89
106
|
},
|
|
90
107
|
additionalProperties: false,
|
|
91
108
|
},
|
|
92
|
-
render: (_args, value) =>
|
|
109
|
+
render: (_args, value) => {
|
|
110
|
+
const view = value as unknown as DataReportValue
|
|
111
|
+
if (view.html !== undefined) return [{ type: 'text', text: view.html }]
|
|
112
|
+
return [{ type: 'text', text: renderReportText(view) }]
|
|
113
|
+
},
|
|
93
114
|
},
|
|
94
115
|
async execute(args, _exec): Promise<DataReportValue> {
|
|
95
116
|
const hasKey = args.key !== undefined
|
|
@@ -97,9 +118,17 @@ export function defineReportTool(service: DataQualityService) {
|
|
|
97
118
|
if (hasKey === hasKind) {
|
|
98
119
|
throw new Error('data_report needs exactly one of key/kind')
|
|
99
120
|
}
|
|
121
|
+
const format = (args.format ?? 'json') as 'json' | 'html'
|
|
100
122
|
if (hasKey) {
|
|
101
123
|
const record = await service.getReport(args.key as string)
|
|
102
|
-
return {
|
|
124
|
+
return {
|
|
125
|
+
key: args.key as string,
|
|
126
|
+
records: [toView(record)],
|
|
127
|
+
...(format === 'html' ? { html: renderRecordHtml(record) } : {}),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (format === 'html') {
|
|
131
|
+
throw new Error('data_report html format requires key (exactly one report)')
|
|
103
132
|
}
|
|
104
133
|
const records = await service.listReports(args.kind as ReportRecord['kind'])
|
|
105
134
|
return { kind: args.kind as ReportRecord['kind'], records: records.map(toView) }
|
package/src/version.ts
CHANGED
|
@@ -5,4 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/** The package version reported in persisted reports. */
|
|
8
|
-
export const VERSION = '0.
|
|
8
|
+
export const VERSION = '0.3.1'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Version of the persisted report schema. Bump it whenever a report's
|
|
12
|
+
* canonical shape changes in a way old consumers cannot read (the durable
|
|
13
|
+
* `data_quality` records keep their own `schemaVersion` so a future reader
|
|
14
|
+
* can detect and reject an incompatible record instead of misreading it).
|
|
15
|
+
*/
|
|
16
|
+
export const REPORT_SCHEMA_VERSION = 1
|