dsh-data-quality 0.1.0
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 +16 -0
- package/LICENSE +201 -0
- package/README.es.md +181 -0
- package/README.hi.md +181 -0
- package/README.md +181 -0
- package/README.pt.md +181 -0
- package/README.zh.md +181 -0
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/cordis.patch.yml +46 -0
- package/lib/index.js +2458 -0
- package/lib/types/clean.d.ts +82 -0
- package/lib/types/clean.d.ts.map +1 -0
- package/lib/types/clean.js +351 -0
- package/lib/types/clean.js.map +1 -0
- package/lib/types/config.d.ts +47 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +66 -0
- package/lib/types/config.js.map +1 -0
- package/lib/types/dataset.d.ts +133 -0
- package/lib/types/dataset.d.ts.map +1 -0
- package/lib/types/dataset.js +404 -0
- package/lib/types/dataset.js.map +1 -0
- package/lib/types/events.d.ts +73 -0
- package/lib/types/events.d.ts.map +1 -0
- package/lib/types/events.js +41 -0
- package/lib/types/events.js.map +1 -0
- package/lib/types/index.d.ts +45 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +78 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/present.d.ts +24 -0
- package/lib/types/present.d.ts.map +1 -0
- package/lib/types/present.js +34 -0
- package/lib/types/present.js.map +1 -0
- package/lib/types/profile.d.ts +79 -0
- package/lib/types/profile.d.ts.map +1 -0
- package/lib/types/profile.js +196 -0
- package/lib/types/profile.js.map +1 -0
- package/lib/types/provider-local.d.ts +56 -0
- package/lib/types/provider-local.d.ts.map +1 -0
- package/lib/types/provider-local.js +163 -0
- package/lib/types/provider-local.js.map +1 -0
- package/lib/types/service.d.ts +160 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/service.js +25 -0
- package/lib/types/service.js.map +1 -0
- package/lib/types/store.d.ts +61 -0
- package/lib/types/store.d.ts.map +1 -0
- package/lib/types/store.js +42 -0
- package/lib/types/store.js.map +1 -0
- package/lib/types/tools/clean.d.ts +14 -0
- package/lib/types/tools/clean.d.ts.map +1 -0
- package/lib/types/tools/clean.js +146 -0
- package/lib/types/tools/clean.js.map +1 -0
- package/lib/types/tools/profile.d.ts +13 -0
- package/lib/types/tools/profile.d.ts.map +1 -0
- package/lib/types/tools/profile.js +91 -0
- package/lib/types/tools/profile.js.map +1 -0
- package/lib/types/tools/shared.d.ts +19 -0
- package/lib/types/tools/shared.d.ts.map +1 -0
- package/lib/types/tools/shared.js +44 -0
- package/lib/types/tools/shared.js.map +1 -0
- package/lib/types/tools/verify.d.ts +14 -0
- package/lib/types/tools/verify.d.ts.map +1 -0
- package/lib/types/tools/verify.js +160 -0
- package/lib/types/tools/verify.js.map +1 -0
- package/lib/types/verify.d.ts +124 -0
- package/lib/types/verify.d.ts.map +1 -0
- package/lib/types/verify.js +391 -0
- package/lib/types/verify.js.map +1 -0
- package/lib/types/version.d.ts +8 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/version.js +8 -0
- package/lib/types/version.js.map +1 -0
- package/package.json +137 -0
- package/src/clean.ts +382 -0
- package/src/config.ts +104 -0
- package/src/dataset.ts +445 -0
- package/src/events.ts +90 -0
- package/src/index.ts +115 -0
- package/src/present.ts +38 -0
- package/src/profile.ts +250 -0
- package/src/provider-local.ts +194 -0
- package/src/service.ts +172 -0
- package/src/store.ts +74 -0
- package/src/tools/clean.ts +150 -0
- package/src/tools/profile.ts +94 -0
- package/src/tools/shared.ts +47 -0
- package/src/tools/verify.ts +163 -0
- package/src/verify.ts +496 -0
- package/src/version.ts +8 -0
package/src/profile.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic dataset profiling: per-column type inference, missingness,
|
|
3
|
+
* cardinality, numeric distribution, IQR outlier counts, and duplicate-row
|
|
4
|
+
* detection. Pure functions over a parsed {@link Table} — no clock, no RNG,
|
|
5
|
+
* no I/O; `generatedAt` is injected by the caller.
|
|
6
|
+
* @module dsh-data-quality/profile
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { isMissing, parseBoolean, parseDate, parseNumeric, sampleRows, throwIfAborted, type Row, type Table } from './dataset.ts'
|
|
10
|
+
|
|
11
|
+
/** Inferred column type from parsed cell classes. */
|
|
12
|
+
export type InferredType = 'number' | 'date' | 'boolean' | 'string' | 'empty' | 'mixed'
|
|
13
|
+
|
|
14
|
+
/** Numeric distribution of one numeric column. */
|
|
15
|
+
export interface NumericProfile {
|
|
16
|
+
readonly min: number
|
|
17
|
+
readonly max: number
|
|
18
|
+
readonly mean: number
|
|
19
|
+
readonly median: number
|
|
20
|
+
readonly p25: number
|
|
21
|
+
readonly p75: number
|
|
22
|
+
/** Values outside `[p25 - 1.5*IQR, p75 + 1.5*IQR]`. */
|
|
23
|
+
readonly outliers: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One column's profile card. */
|
|
27
|
+
export interface ColumnProfile {
|
|
28
|
+
readonly name: string
|
|
29
|
+
readonly inferredType: InferredType
|
|
30
|
+
/** Missing cells over the profiled rows. */
|
|
31
|
+
readonly missing: number
|
|
32
|
+
/** `missing / profiledRows` (0 when the column has no rows). */
|
|
33
|
+
readonly missingRate: number
|
|
34
|
+
/** Distinct non-missing values over the profiled rows. */
|
|
35
|
+
readonly unique: number
|
|
36
|
+
/** Numeric distribution; present only for `number` columns with values. */
|
|
37
|
+
readonly numeric?: NumericProfile
|
|
38
|
+
/** Up to 5 most frequent values for low-cardinality (<= 10 distinct) string/boolean columns. */
|
|
39
|
+
topValues?: Array<{ readonly value: string; readonly count: number }>
|
|
40
|
+
/** Suspicion notes, e.g. mixed-type composition. */
|
|
41
|
+
notes: string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The full profile report (also the persisted and tool-returned value). */
|
|
45
|
+
export interface ProfileReport {
|
|
46
|
+
readonly dataset: string
|
|
47
|
+
readonly rowCount: number
|
|
48
|
+
/** Whether column cards describe a systematic sample rather than every row. */
|
|
49
|
+
readonly sampled: boolean
|
|
50
|
+
/** Rows the column cards were computed over. */
|
|
51
|
+
readonly profiledRows: number
|
|
52
|
+
readonly columnCount: number
|
|
53
|
+
/** Rows whose full content duplicates an earlier row (over ALL rows). */
|
|
54
|
+
readonly duplicateRows: number
|
|
55
|
+
columns: ColumnProfile[]
|
|
56
|
+
/** Storage-domain key of the persisted report, when persistence is on (set by the provider). */
|
|
57
|
+
readonly reportKey?: string
|
|
58
|
+
/** Injected generation timestamp (epoch ms). */
|
|
59
|
+
readonly generatedAt: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Round to 6 significant digits for stable, readable report numbers. */
|
|
63
|
+
function round6(value: number): number {
|
|
64
|
+
return Number(value.toPrecision(6))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Linear-interpolation quantile over an ascending-sorted array. */
|
|
68
|
+
function quantile(sorted: readonly number[], q: number): number {
|
|
69
|
+
const index = (sorted.length - 1) * q
|
|
70
|
+
const low = Math.floor(index)
|
|
71
|
+
const high = Math.ceil(index)
|
|
72
|
+
const lower = sorted[low] as number
|
|
73
|
+
const upper = sorted[high] as number
|
|
74
|
+
return lower + (upper - lower) * (index - low)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Compute the numeric distribution of already-parsed values. */
|
|
78
|
+
export function numericProfile(values: readonly number[]): NumericProfile | undefined {
|
|
79
|
+
if (values.length === 0) return undefined
|
|
80
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
81
|
+
const sum = sorted.reduce((acc, value) => acc + value, 0)
|
|
82
|
+
const p25 = quantile(sorted, 0.25)
|
|
83
|
+
const p75 = quantile(sorted, 0.75)
|
|
84
|
+
const iqr = p75 - p25
|
|
85
|
+
const lowFence = p25 - 1.5 * iqr
|
|
86
|
+
const highFence = p75 + 1.5 * iqr
|
|
87
|
+
const outliers = iqr === 0 ? 0 : sorted.filter((value) => value < lowFence || value > highFence).length
|
|
88
|
+
return {
|
|
89
|
+
min: round6(sorted[0] as number),
|
|
90
|
+
max: round6(sorted[sorted.length - 1] as number),
|
|
91
|
+
mean: round6(sum / sorted.length),
|
|
92
|
+
median: round6(quantile(sorted, 0.5)),
|
|
93
|
+
p25: round6(p25),
|
|
94
|
+
p75: round6(p75),
|
|
95
|
+
outliers,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Count rows whose full content duplicates an earlier row (first occurrence is not counted). */
|
|
100
|
+
export function countDuplicateRows(table: Table, signal?: AbortSignal): number {
|
|
101
|
+
const seen = new Set<string>()
|
|
102
|
+
let duplicates = 0
|
|
103
|
+
for (const [index, row] of table.rows.entries()) {
|
|
104
|
+
if (index % 1024 === 0) throwIfAborted(signal)
|
|
105
|
+
const key = JSON.stringify(table.columns.map((column) => row[column] ?? null))
|
|
106
|
+
if (seen.has(key)) {
|
|
107
|
+
duplicates += 1
|
|
108
|
+
} else {
|
|
109
|
+
seen.add(key)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return duplicates
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Profile one column over the given rows. */
|
|
116
|
+
function profileColumn(rows: readonly Row[], column: string, signal?: AbortSignal): ColumnProfile {
|
|
117
|
+
let missing = 0
|
|
118
|
+
let numbers = 0
|
|
119
|
+
let dates = 0
|
|
120
|
+
let booleans = 0
|
|
121
|
+
let strings = 0
|
|
122
|
+
const numericValues: number[] = []
|
|
123
|
+
const distinct = new Set<string>()
|
|
124
|
+
const frequencies = new Map<string, number>()
|
|
125
|
+
for (const [index, row] of rows.entries()) {
|
|
126
|
+
if (index % 1024 === 0) throwIfAborted(signal)
|
|
127
|
+
const cell = row[column]
|
|
128
|
+
if (isMissing(cell)) {
|
|
129
|
+
missing += 1
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
const text = typeof cell === 'string' ? cell : JSON.stringify(cell)
|
|
133
|
+
distinct.add(text)
|
|
134
|
+
frequencies.set(text, (frequencies.get(text) ?? 0) + 1)
|
|
135
|
+
const numeric = parseNumeric(cell)
|
|
136
|
+
if (numeric !== undefined) {
|
|
137
|
+
numbers += 1
|
|
138
|
+
numericValues.push(numeric)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (parseDate(cell) !== undefined) {
|
|
142
|
+
dates += 1
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (parseBoolean(cell) !== undefined) {
|
|
146
|
+
booleans += 1
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
strings += 1
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const present = rows.length - missing
|
|
153
|
+
const notes: string[] = []
|
|
154
|
+
let inferredType: InferredType
|
|
155
|
+
if (present === 0) {
|
|
156
|
+
inferredType = 'empty'
|
|
157
|
+
} else if (numbers === present) {
|
|
158
|
+
inferredType = 'number'
|
|
159
|
+
} else if (dates === present) {
|
|
160
|
+
inferredType = 'date'
|
|
161
|
+
} else if (booleans === present) {
|
|
162
|
+
inferredType = 'boolean'
|
|
163
|
+
} else if (strings === present) {
|
|
164
|
+
inferredType = 'string'
|
|
165
|
+
} else {
|
|
166
|
+
inferredType = 'mixed'
|
|
167
|
+
const parts = [
|
|
168
|
+
numbers > 0 ? `${numbers} numeric` : undefined,
|
|
169
|
+
dates > 0 ? `${dates} date` : undefined,
|
|
170
|
+
booleans > 0 ? `${booleans} boolean` : undefined,
|
|
171
|
+
strings > 0 ? `${strings} string` : undefined,
|
|
172
|
+
].filter((part) => part !== undefined)
|
|
173
|
+
notes.push(`mixed types among ${present} present values: ${parts.join(', ')}`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const profile: ColumnProfile = {
|
|
177
|
+
name: column,
|
|
178
|
+
inferredType,
|
|
179
|
+
missing,
|
|
180
|
+
missingRate: rows.length === 0 ? 0 : round6(missing / rows.length),
|
|
181
|
+
unique: distinct.size,
|
|
182
|
+
notes,
|
|
183
|
+
}
|
|
184
|
+
const numeric = inferredType === 'number' ? numericProfile(numericValues) : undefined
|
|
185
|
+
const topValues =
|
|
186
|
+
(inferredType === 'string' || inferredType === 'boolean') && distinct.size > 0 && distinct.size <= 10
|
|
187
|
+
? [...frequencies.entries()]
|
|
188
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
189
|
+
.slice(0, 5)
|
|
190
|
+
.map(([value, count]) => ({ value, count }))
|
|
191
|
+
: undefined
|
|
192
|
+
return {
|
|
193
|
+
...profile,
|
|
194
|
+
...(numeric !== undefined ? { numeric } : {}),
|
|
195
|
+
...(topValues !== undefined ? { topValues } : {}),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Profile a parsed table. Column cards are computed over a deterministic
|
|
201
|
+
* systematic sample when `sample` is given; row counts and duplicate counts
|
|
202
|
+
* always cover the full table.
|
|
203
|
+
* @param table - the parsed dataset.
|
|
204
|
+
* @param options - dataset label, optional sample size, injected timestamp, abort signal.
|
|
205
|
+
* @returns the profile report.
|
|
206
|
+
*/
|
|
207
|
+
export function profileTable(
|
|
208
|
+
table: Table,
|
|
209
|
+
options: { dataset: string; sample?: number | undefined; generatedAt: number; signal?: AbortSignal | undefined },
|
|
210
|
+
): ProfileReport {
|
|
211
|
+
throwIfAborted(options.signal)
|
|
212
|
+
const profiled = options.sample === undefined ? table.rows : sampleRows(table.rows, options.sample)
|
|
213
|
+
const columns = table.columns.map((column) => profileColumn(profiled, column, options.signal))
|
|
214
|
+
const duplicateRows = countDuplicateRows(table, options.signal)
|
|
215
|
+
return {
|
|
216
|
+
dataset: options.dataset,
|
|
217
|
+
rowCount: table.rows.length,
|
|
218
|
+
sampled: profiled.length !== table.rows.length,
|
|
219
|
+
profiledRows: profiled.length,
|
|
220
|
+
columnCount: table.columns.length,
|
|
221
|
+
duplicateRows,
|
|
222
|
+
columns,
|
|
223
|
+
generatedAt: options.generatedAt,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Human-readable profile summary for the tool's Native render. */
|
|
228
|
+
export function renderProfileText(report: ProfileReport): string {
|
|
229
|
+
const lines: string[] = []
|
|
230
|
+
lines.push(`Profile of ${report.dataset}: ${report.rowCount} rows x ${report.columnCount} columns` +
|
|
231
|
+
(report.sampled ? ` (column cards over a systematic sample of ${report.profiledRows} rows)` : ''))
|
|
232
|
+
if (report.duplicateRows > 0) lines.push(`Duplicate rows: ${report.duplicateRows}`)
|
|
233
|
+
for (const column of report.columns) {
|
|
234
|
+
const parts = [`${column.name}: ${column.inferredType}`]
|
|
235
|
+
if (column.missing > 0) parts.push(`missing ${column.missing} (${(column.missingRate * 100).toFixed(1)}%)`)
|
|
236
|
+
parts.push(`unique ${column.unique}`)
|
|
237
|
+
if (column.numeric !== undefined) {
|
|
238
|
+
parts.push(
|
|
239
|
+
`min ${column.numeric.min}, p25 ${column.numeric.p25}, median ${column.numeric.median}, p75 ${column.numeric.p75}, max ${column.numeric.max}, mean ${column.numeric.mean}` +
|
|
240
|
+
(column.numeric.outliers > 0 ? `, ${column.numeric.outliers} IQR outliers` : ''),
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
if (column.topValues !== undefined) {
|
|
244
|
+
parts.push(`top: ${column.topValues.map((entry) => `${JSON.stringify(entry.value)} x${entry.count}`).join(', ')}`)
|
|
245
|
+
}
|
|
246
|
+
for (const note of column.notes) parts.push(`note: ${note}`)
|
|
247
|
+
lines.push(`- ${parts.join('; ')}`)
|
|
248
|
+
}
|
|
249
|
+
return lines.join('\n')
|
|
250
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local deterministic Provider of the `ctx.dataQuality` seam: orchestrates
|
|
3
|
+
* dataset loading, the pure engines, durable report persistence, and the
|
|
4
|
+
* adaptive `data-quality/*` session events. All computation is TypeScript in
|
|
5
|
+
* this process — no model arithmetic, no external processes.
|
|
6
|
+
* @module dsh-data-quality/provider-local
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import type { ResolvedConfig } from './config.ts'
|
|
13
|
+
import { loadDocument, loadTable, resolveWorkspacePath, throwIfAborted } from './dataset.ts'
|
|
14
|
+
import { applyCleanRules, serializeDelimited } from './clean.ts'
|
|
15
|
+
import { profileTable } from './profile.ts'
|
|
16
|
+
import { checkCitations, verifyTable } from './verify.ts'
|
|
17
|
+
import { appendDataQualityEvent } from './events.ts'
|
|
18
|
+
import { reportKeyOf, type ReportRecord, type ReportStore } from './store.ts'
|
|
19
|
+
import { truncateRow } from './present.ts'
|
|
20
|
+
import {
|
|
21
|
+
DataQualityService,
|
|
22
|
+
type CitationCheckRequest,
|
|
23
|
+
type CitationCheckResult,
|
|
24
|
+
type CleanRequest,
|
|
25
|
+
type CleanRunReport,
|
|
26
|
+
type ProfileRequest,
|
|
27
|
+
type VerifyRequest,
|
|
28
|
+
} from './service.ts'
|
|
29
|
+
import type { ProfileReport } from './profile.ts'
|
|
30
|
+
import type { VerifyReport } from './verify.ts'
|
|
31
|
+
import type { Session } from '@deepseek-ai/dsh-session'
|
|
32
|
+
|
|
33
|
+
/** Provider construction dependencies. */
|
|
34
|
+
export interface ProviderDeps {
|
|
35
|
+
/** Report persistence; absent when `storeReports` is off. */
|
|
36
|
+
readonly store?: ReportStore | undefined
|
|
37
|
+
/** Injected clock — the single time source for reports and freshness defaults. */
|
|
38
|
+
readonly now: () => number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The local deterministic `dataQuality` implementation. Mounted by the plugin
|
|
43
|
+
* entry; third-party plugins consume it through `ctx.dataQuality`.
|
|
44
|
+
*/
|
|
45
|
+
export class LocalDataQualityService extends DataQualityService {
|
|
46
|
+
private readonly config: ResolvedConfig
|
|
47
|
+
private readonly deps: ProviderDeps
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param ctx - the plugin context.
|
|
51
|
+
* @param config - the resolved plugin config.
|
|
52
|
+
* @param deps - store handle plus the injected clock.
|
|
53
|
+
*/
|
|
54
|
+
constructor(ctx: Context, config: ResolvedConfig, deps: ProviderDeps) {
|
|
55
|
+
super(ctx)
|
|
56
|
+
this.config = config
|
|
57
|
+
this.deps = deps
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The absolute root for service-level calls without a session workspace. */
|
|
61
|
+
serviceRoot(): string {
|
|
62
|
+
return this.config.workspaceRoot !== '' ? path.resolve(this.config.workspaceRoot) : process.cwd()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Persist one report when persistence is on; returns the storage key. */
|
|
66
|
+
private async persist(kind: ReportRecord['kind'], dataset: string, report: Record<string, unknown>): Promise<string | undefined> {
|
|
67
|
+
const store = this.deps.store
|
|
68
|
+
if (store === undefined) return undefined
|
|
69
|
+
const record: ReportRecord = { kind, at: this.deps.now(), dataset, report }
|
|
70
|
+
const key = reportKeyOf(record)
|
|
71
|
+
await store.put({ ...record })
|
|
72
|
+
return key
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Emit the adaptive session event when the call carries a session. */
|
|
76
|
+
emitEvent(session: Session | undefined, kind: 'profile' | 'clean' | 'verify', dataset: string, reportKey: string | undefined, summary: { rows: number; columns?: number; rules?: number; failedRules?: number; passed?: boolean }): void {
|
|
77
|
+
if (session === undefined) return
|
|
78
|
+
appendDataQualityEvent(session, `data-quality/${kind}`, {
|
|
79
|
+
kind,
|
|
80
|
+
dataset,
|
|
81
|
+
...(reportKey !== undefined ? { reportKey } : {}),
|
|
82
|
+
summary,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @inheritdoc DataQualityService.profileDataset */
|
|
87
|
+
override async profileDataset(request: ProfileRequest): Promise<ProfileReport> {
|
|
88
|
+
throwIfAborted(request.signal)
|
|
89
|
+
const absolute = resolveWorkspacePath(request.workspace, request.dataset, this.config)
|
|
90
|
+
const table = await loadTable(absolute, this.config, request.signal)
|
|
91
|
+
const report = profileTable(table, {
|
|
92
|
+
dataset: request.dataset,
|
|
93
|
+
sample: request.sample,
|
|
94
|
+
generatedAt: this.deps.now(),
|
|
95
|
+
signal: request.signal,
|
|
96
|
+
})
|
|
97
|
+
const reportKey = await this.persist('profile', request.dataset, report as unknown as Record<string, unknown>)
|
|
98
|
+
this.emitEvent(request.session, 'profile', request.dataset, reportKey, { rows: report.rowCount, columns: report.columnCount })
|
|
99
|
+
return { ...report, ...(reportKey !== undefined ? { reportKey } : {}) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @inheritdoc DataQualityService.cleanDataset */
|
|
103
|
+
override async cleanDataset(request: CleanRequest): Promise<CleanRunReport> {
|
|
104
|
+
throwIfAborted(request.signal)
|
|
105
|
+
const absolute = resolveWorkspacePath(request.workspace, request.dataset, this.config)
|
|
106
|
+
const table = await loadTable(absolute, this.config, request.signal)
|
|
107
|
+
const result = applyCleanRules(table, request.rules, { signal: request.signal })
|
|
108
|
+
|
|
109
|
+
let writtenPath: string | undefined
|
|
110
|
+
if (request.outputPath !== undefined) {
|
|
111
|
+
const outputAbsolute = resolveWorkspacePath(request.workspace, request.outputPath, this.config)
|
|
112
|
+
if (outputAbsolute === absolute) {
|
|
113
|
+
throw new Error(`outputPath ${JSON.stringify(request.outputPath)} would overwrite the input dataset; choose a different path`)
|
|
114
|
+
}
|
|
115
|
+
const ext = path.extname(outputAbsolute).toLowerCase()
|
|
116
|
+
const text = ext === '.csv' || ext === '.tsv'
|
|
117
|
+
? serializeDelimited(result.columns, result.rows, ext === '.csv' ? ',' : '\t')
|
|
118
|
+
: ext === '.jsonl'
|
|
119
|
+
? `${result.rows.map((row) => JSON.stringify(row)).join('\n')}\n`
|
|
120
|
+
: `${JSON.stringify(result.rows, null, 2)}\n`
|
|
121
|
+
await mkdir(path.dirname(outputAbsolute), { recursive: true })
|
|
122
|
+
throwIfAborted(request.signal)
|
|
123
|
+
await writeFile(outputAbsolute, text, 'utf8')
|
|
124
|
+
writtenPath = request.outputPath
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const generatedAt = this.deps.now()
|
|
128
|
+
const reportKey = await this.persist('clean', request.dataset, {
|
|
129
|
+
dataset: request.dataset,
|
|
130
|
+
inputRows: result.inputRows,
|
|
131
|
+
outputRows: result.outputRows,
|
|
132
|
+
logs: result.logs,
|
|
133
|
+
...(writtenPath !== undefined ? { outputPath: writtenPath } : {}),
|
|
134
|
+
generatedAt,
|
|
135
|
+
} as unknown as Record<string, unknown>)
|
|
136
|
+
this.emitEvent(request.session, 'clean', request.dataset, reportKey, { rows: result.outputRows, columns: result.columns.length, rules: result.logs.length })
|
|
137
|
+
|
|
138
|
+
const previewRows = result.rows.slice(0, this.config.evidenceRowLimit).map((row) => truncateRow(row))
|
|
139
|
+
return {
|
|
140
|
+
dataset: request.dataset,
|
|
141
|
+
inputRows: result.inputRows,
|
|
142
|
+
outputRows: result.outputRows,
|
|
143
|
+
logs: result.logs,
|
|
144
|
+
preview: { columns: result.columns, rows: previewRows },
|
|
145
|
+
...(writtenPath !== undefined ? { outputPath: writtenPath } : {}),
|
|
146
|
+
...(reportKey !== undefined ? { reportKey } : {}),
|
|
147
|
+
generatedAt,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @inheritdoc DataQualityService.verifyDataset */
|
|
152
|
+
override async verifyDataset(request: VerifyRequest): Promise<VerifyReport> {
|
|
153
|
+
throwIfAborted(request.signal)
|
|
154
|
+
const absolute = resolveWorkspacePath(request.workspace, request.dataset, this.config)
|
|
155
|
+
const table = await loadTable(absolute, this.config, request.signal)
|
|
156
|
+
const outcome = verifyTable(table, request.rules, {
|
|
157
|
+
evidenceRowLimit: this.config.evidenceRowLimit,
|
|
158
|
+
now: this.deps.now,
|
|
159
|
+
signal: request.signal,
|
|
160
|
+
})
|
|
161
|
+
const report: VerifyReport = { dataset: request.dataset, ...outcome }
|
|
162
|
+
const failedRules = report.rules.filter((rule) => !rule.passed).length
|
|
163
|
+
const reportKey = await this.persist('verify', request.dataset, report as unknown as Record<string, unknown>)
|
|
164
|
+
this.emitEvent(request.session, 'verify', request.dataset, reportKey, {
|
|
165
|
+
rows: report.rowCount,
|
|
166
|
+
rules: report.rules.length,
|
|
167
|
+
failedRules,
|
|
168
|
+
passed: report.passed,
|
|
169
|
+
})
|
|
170
|
+
return { ...report, ...(reportKey !== undefined ? { reportKey } : {}) }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @inheritdoc DataQualityService.verifyCitations */
|
|
174
|
+
override async verifyCitations(request: CitationCheckRequest): Promise<CitationCheckResult> {
|
|
175
|
+
const absolute = resolveWorkspacePath(this.serviceRoot(), request.dataset, this.config)
|
|
176
|
+
const document = await loadDocument(absolute, this.config)
|
|
177
|
+
const result = checkCitations(document, request.citations, this.config.defaultTolerance)
|
|
178
|
+
const verified = result.results.filter((entry) => entry.status === 'verified').length
|
|
179
|
+
const mismatched = result.results.filter((entry) => entry.status === 'mismatch').length
|
|
180
|
+
const notFound = result.results.filter((entry) => entry.status === 'not-found').length
|
|
181
|
+
const unverifiable = result.results.filter((entry) => entry.status === 'unverifiable').length
|
|
182
|
+
await this.persist('citations', request.dataset, {
|
|
183
|
+
dataset: request.dataset,
|
|
184
|
+
checked: result.results.length,
|
|
185
|
+
verified,
|
|
186
|
+
mismatched,
|
|
187
|
+
notFound,
|
|
188
|
+
unverifiable,
|
|
189
|
+
results: result.results,
|
|
190
|
+
generatedAt: this.deps.now(),
|
|
191
|
+
} as unknown as Record<string, unknown>)
|
|
192
|
+
return result
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Definition of the `dsh-data-quality` capability seam: the
|
|
3
|
+
* `ctx.dataQuality` surface other plugins may optionally consume, plus the
|
|
4
|
+
* internal request/report types the local Provider and the tool Consumers
|
|
5
|
+
* share. The {@link CitationCheckRequest} / {@link CitationCheckResult}
|
|
6
|
+
* citation contract is FROZEN for cross-plugin consumers — change it only
|
|
7
|
+
* with a coordinated ecosystem migration.
|
|
8
|
+
* @module dsh-data-quality/service
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Service, type Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import type { JsonValue, Session } from '@deepseek-ai/dsh-session'
|
|
13
|
+
import type { CleanRule, CleanRuleLog } from './clean.ts'
|
|
14
|
+
import type { ProfileReport } from './profile.ts'
|
|
15
|
+
import type { VerifyReport, VerifyRule } from './verify.ts'
|
|
16
|
+
|
|
17
|
+
export interface CitationCheckRequest {
|
|
18
|
+
/** Workspace-relative path of the source dataset snapshot (CSV/JSON). */
|
|
19
|
+
dataset: string
|
|
20
|
+
/** Citations to verify against the dataset. */
|
|
21
|
+
citations: Array<{
|
|
22
|
+
/** Stable id chosen by the caller, echoed back in results. */
|
|
23
|
+
id: string
|
|
24
|
+
/** JSON-path-ish locator, e.g. "rows[3].nav" or "summary.annualReturn". */
|
|
25
|
+
path: string
|
|
26
|
+
/** The value as cited in the document. */
|
|
27
|
+
value: number | string
|
|
28
|
+
/** Optional relative tolerance for numeric comparison, e.g. 0.01 = 1%. */
|
|
29
|
+
tolerance?: number
|
|
30
|
+
}>
|
|
31
|
+
}
|
|
32
|
+
export interface CitationCheckResult {
|
|
33
|
+
results: Array<{
|
|
34
|
+
id: string
|
|
35
|
+
status: 'verified' | 'mismatch' | 'not-found' | 'unverifiable'
|
|
36
|
+
/** Actual value found at path, when found. */
|
|
37
|
+
actual?: number | string
|
|
38
|
+
/** Human-readable evidence note. */
|
|
39
|
+
note?: string
|
|
40
|
+
}>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Profile request through the service layer (tools pass the session workspace). */
|
|
44
|
+
export interface ProfileRequest {
|
|
45
|
+
/** Workspace-relative dataset path. */
|
|
46
|
+
readonly dataset: string
|
|
47
|
+
/** Optional deterministic systematic sample size for column cards. */
|
|
48
|
+
readonly sample?: number | undefined
|
|
49
|
+
/** Absolute workspace root the dataset resolves inside. */
|
|
50
|
+
readonly workspace: string
|
|
51
|
+
/** Calling session (receives the `data-quality/profile` event), when any. */
|
|
52
|
+
readonly session?: Session | undefined
|
|
53
|
+
/** Cancellation from the tool call. */
|
|
54
|
+
readonly signal?: AbortSignal | undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Clean request through the service layer. */
|
|
58
|
+
export interface CleanRequest {
|
|
59
|
+
/** Workspace-relative dataset path. */
|
|
60
|
+
readonly dataset: string
|
|
61
|
+
/** Non-empty declarative cleaning rule list, applied in order. */
|
|
62
|
+
readonly rules: readonly CleanRule[]
|
|
63
|
+
/** Workspace-relative output path; omitted = no disk write, preview only. */
|
|
64
|
+
readonly outputPath?: string | undefined
|
|
65
|
+
/** Absolute workspace root the dataset resolves inside. */
|
|
66
|
+
readonly workspace: string
|
|
67
|
+
/** Calling session (receives the `data-quality/clean` event), when any. */
|
|
68
|
+
readonly session?: Session | undefined
|
|
69
|
+
/** Cancellation from the tool call. */
|
|
70
|
+
readonly signal?: AbortSignal | undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Verify request through the service layer. */
|
|
74
|
+
export interface VerifyRequest {
|
|
75
|
+
/** Workspace-relative dataset path. */
|
|
76
|
+
readonly dataset: string
|
|
77
|
+
/** Non-empty declarative verification rule list. */
|
|
78
|
+
readonly rules: readonly VerifyRule[]
|
|
79
|
+
/** Absolute workspace root the dataset resolves inside. */
|
|
80
|
+
readonly workspace: string
|
|
81
|
+
/** Calling session (receives the `data-quality/verify` event), when any. */
|
|
82
|
+
readonly session?: Session | undefined
|
|
83
|
+
/** Cancellation from the tool call. */
|
|
84
|
+
readonly signal?: AbortSignal | undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The value a clean run returns (tool canonical value minus presentation). */
|
|
88
|
+
export interface CleanRunReport {
|
|
89
|
+
readonly dataset: string
|
|
90
|
+
readonly inputRows: number
|
|
91
|
+
readonly outputRows: number
|
|
92
|
+
readonly logs: CleanRuleLog[]
|
|
93
|
+
/** First `evidenceRowLimit` cleaned rows for inspection (display-truncated). */
|
|
94
|
+
readonly preview: { readonly columns: string[]; readonly rows: Array<Record<string, JsonValue>> }
|
|
95
|
+
/** Workspace-relative output path when the run wrote a file. */
|
|
96
|
+
readonly outputPath?: string
|
|
97
|
+
/** Storage-domain key of the persisted report, when persistence is on. */
|
|
98
|
+
readonly reportKey?: string
|
|
99
|
+
readonly generatedAt: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The value a verifyCitation call persists (report envelope kind `citations`). */
|
|
103
|
+
export interface CitationCheckPersisted {
|
|
104
|
+
readonly dataset: string
|
|
105
|
+
readonly checked: number
|
|
106
|
+
readonly verified: number
|
|
107
|
+
readonly mismatched: number
|
|
108
|
+
readonly notFound: number
|
|
109
|
+
readonly unverifiable: number
|
|
110
|
+
readonly results: CitationCheckResult['results']
|
|
111
|
+
readonly generatedAt: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The `ctx.dataQuality` service. The local Provider implements deterministic
|
|
116
|
+
* TypeScript computation; Consumers (the three tools and third-party plugins)
|
|
117
|
+
* never re-implement the math.
|
|
118
|
+
*/
|
|
119
|
+
export abstract class DataQualityService extends Service {
|
|
120
|
+
/**
|
|
121
|
+
* Register as `dataQuality` on the context.
|
|
122
|
+
* @param ctx - the plugin context.
|
|
123
|
+
*/
|
|
124
|
+
constructor(ctx: Context) {
|
|
125
|
+
super(ctx, 'dataQuality')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Frozen cross-plugin contract: verify document citations against a dataset
|
|
130
|
+
* snapshot. The dataset resolves against the configured service workspace
|
|
131
|
+
* root (`workspaceRoot`, defaulting to the harness launch directory).
|
|
132
|
+
* @param request - dataset path plus citations.
|
|
133
|
+
* @returns one result per citation, ids echoed.
|
|
134
|
+
*/
|
|
135
|
+
abstract verifyCitations(request: CitationCheckRequest): Promise<CitationCheckResult>
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Profile a workspace dataset (row/column counts, inferred types,
|
|
139
|
+
* missingness, cardinality, numeric distribution, suspected anomalies).
|
|
140
|
+
* @param request - dataset path, optional sample size, workspace root.
|
|
141
|
+
* @returns the profile report.
|
|
142
|
+
*/
|
|
143
|
+
abstract profileDataset(request: ProfileRequest): Promise<ProfileReport>
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Apply declarative cleaning rules to a workspace dataset. The source file
|
|
147
|
+
* is never overwritten; without `outputPath` nothing touches the disk.
|
|
148
|
+
* @param request - dataset path, ordered rules, optional output path, workspace root.
|
|
149
|
+
* @returns the cleaned preview plus the per-rule audit log.
|
|
150
|
+
*/
|
|
151
|
+
abstract cleanDataset(request: CleanRequest): Promise<CleanRunReport>
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Apply declarative verification rules to a workspace dataset. A failing
|
|
155
|
+
* dataset is a normal result (`passed: false`), never a thrown error.
|
|
156
|
+
* @param request - dataset path, rules, workspace root.
|
|
157
|
+
* @returns the verify report.
|
|
158
|
+
*/
|
|
159
|
+
abstract verifyDataset(request: VerifyRequest): Promise<VerifyReport>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
declare module '@deepseek-ai/cordis' {
|
|
163
|
+
interface Context {
|
|
164
|
+
/** Deterministic data profiling / cleaning / verification, when the dsh-data-quality bundle is mounted. */
|
|
165
|
+
dataQuality: DataQualityService
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Re-exports so consumers pull the whole seam vocabulary from one module. */
|
|
170
|
+
export type { CleanResult, CleanRule, CleanRuleLog } from './clean.ts'
|
|
171
|
+
export type { ProfileReport } from './profile.ts'
|
|
172
|
+
export type { VerifyReport, VerifyRule } from './verify.ts'
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable report storage over the harness storage domain. The `data_quality`
|
|
3
|
+
* domain keeps one record per profile/clean/verify/citations run, keyed by an
|
|
4
|
+
* injected timestamp plus a path fingerprint, so reports survive a restart
|
|
5
|
+
* and stay queryable without touching the session log.
|
|
6
|
+
* @module dsh-data-quality/store
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import z from 'zod'
|
|
10
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
|
11
|
+
import { pathFingerprint } from './dataset.ts'
|
|
12
|
+
|
|
13
|
+
/** Zod schema of one persisted report record (durable-boundary validation). */
|
|
14
|
+
export const reportRecordSchema = z.object({
|
|
15
|
+
kind: z.enum(['profile', 'clean', 'verify', 'citations']),
|
|
16
|
+
at: z.number().int().nonnegative(),
|
|
17
|
+
dataset: z.string(),
|
|
18
|
+
report: z.record(z.string(), z.unknown()),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/** One persisted report record. */
|
|
22
|
+
export interface ReportRecord {
|
|
23
|
+
readonly kind: 'profile' | 'clean' | 'verify' | 'citations'
|
|
24
|
+
/** Injected run timestamp (epoch ms). */
|
|
25
|
+
readonly at: number
|
|
26
|
+
/** Workspace-relative dataset path as the caller gave it. */
|
|
27
|
+
readonly dataset: string
|
|
28
|
+
/** The full run report (profile/clean/verify/citations shape by `kind`). */
|
|
29
|
+
readonly report: Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The `dsh-data-quality` storage-domain declaration. */
|
|
33
|
+
export const dataQualityDomainSpec = defineDomain({
|
|
34
|
+
name: 'data_quality',
|
|
35
|
+
version: 1,
|
|
36
|
+
tables: {
|
|
37
|
+
reports: domainTable<string, ReportRecord>(reportRecordSchema),
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** Handle over the reports table with deterministic key construction. */
|
|
42
|
+
export interface ReportStore {
|
|
43
|
+
/**
|
|
44
|
+
* Persist one run report.
|
|
45
|
+
* @param record - the report envelope (kind/dataset/at/report).
|
|
46
|
+
* @returns the storage key the record was written under.
|
|
47
|
+
*/
|
|
48
|
+
put(record: ReportRecord): Promise<string>
|
|
49
|
+
/**
|
|
50
|
+
* Read one persisted report.
|
|
51
|
+
* @param key - the key {@link put} returned.
|
|
52
|
+
* @returns the record, or `undefined` when absent.
|
|
53
|
+
*/
|
|
54
|
+
get(key: string): ReportRecord | undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Pad to two digits for the key timestamp. */
|
|
58
|
+
function pad2(value: number): string {
|
|
59
|
+
return String(value).padStart(2, '0')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the storage key for one run: `YYYYMMDDHHmmssSSS-<kind>-<pathFp>`.
|
|
64
|
+
* UTC digits keep the key timezone-independent and filename-safe everywhere.
|
|
65
|
+
* @param record - the report envelope.
|
|
66
|
+
* @returns the deterministic key.
|
|
67
|
+
*/
|
|
68
|
+
export function reportKeyOf(record: ReportRecord): string {
|
|
69
|
+
const date = new Date(record.at)
|
|
70
|
+
const stamp =
|
|
71
|
+
`${date.getUTCFullYear()}${pad2(date.getUTCMonth() + 1)}${pad2(date.getUTCDate())}` +
|
|
72
|
+
`${pad2(date.getUTCHours())}${pad2(date.getUTCMinutes())}${pad2(date.getUTCSeconds())}${String(date.getUTCMilliseconds()).padStart(3, '0')}`
|
|
73
|
+
return `${stamp}-${record.kind}-${pathFingerprint(record.dataset)}`
|
|
74
|
+
}
|