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.
Files changed (91) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +181 -0
  4. package/README.hi.md +181 -0
  5. package/README.md +181 -0
  6. package/README.pt.md +181 -0
  7. package/README.zh.md +181 -0
  8. package/THIRD_PARTY_NOTICES.md +20 -0
  9. package/cordis.patch.yml +46 -0
  10. package/lib/index.js +2458 -0
  11. package/lib/types/clean.d.ts +82 -0
  12. package/lib/types/clean.d.ts.map +1 -0
  13. package/lib/types/clean.js +351 -0
  14. package/lib/types/clean.js.map +1 -0
  15. package/lib/types/config.d.ts +47 -0
  16. package/lib/types/config.d.ts.map +1 -0
  17. package/lib/types/config.js +66 -0
  18. package/lib/types/config.js.map +1 -0
  19. package/lib/types/dataset.d.ts +133 -0
  20. package/lib/types/dataset.d.ts.map +1 -0
  21. package/lib/types/dataset.js +404 -0
  22. package/lib/types/dataset.js.map +1 -0
  23. package/lib/types/events.d.ts +73 -0
  24. package/lib/types/events.d.ts.map +1 -0
  25. package/lib/types/events.js +41 -0
  26. package/lib/types/events.js.map +1 -0
  27. package/lib/types/index.d.ts +45 -0
  28. package/lib/types/index.d.ts.map +1 -0
  29. package/lib/types/index.js +78 -0
  30. package/lib/types/index.js.map +1 -0
  31. package/lib/types/present.d.ts +24 -0
  32. package/lib/types/present.d.ts.map +1 -0
  33. package/lib/types/present.js +34 -0
  34. package/lib/types/present.js.map +1 -0
  35. package/lib/types/profile.d.ts +79 -0
  36. package/lib/types/profile.d.ts.map +1 -0
  37. package/lib/types/profile.js +196 -0
  38. package/lib/types/profile.js.map +1 -0
  39. package/lib/types/provider-local.d.ts +56 -0
  40. package/lib/types/provider-local.d.ts.map +1 -0
  41. package/lib/types/provider-local.js +163 -0
  42. package/lib/types/provider-local.js.map +1 -0
  43. package/lib/types/service.d.ts +160 -0
  44. package/lib/types/service.d.ts.map +1 -0
  45. package/lib/types/service.js +25 -0
  46. package/lib/types/service.js.map +1 -0
  47. package/lib/types/store.d.ts +61 -0
  48. package/lib/types/store.d.ts.map +1 -0
  49. package/lib/types/store.js +42 -0
  50. package/lib/types/store.js.map +1 -0
  51. package/lib/types/tools/clean.d.ts +14 -0
  52. package/lib/types/tools/clean.d.ts.map +1 -0
  53. package/lib/types/tools/clean.js +146 -0
  54. package/lib/types/tools/clean.js.map +1 -0
  55. package/lib/types/tools/profile.d.ts +13 -0
  56. package/lib/types/tools/profile.d.ts.map +1 -0
  57. package/lib/types/tools/profile.js +91 -0
  58. package/lib/types/tools/profile.js.map +1 -0
  59. package/lib/types/tools/shared.d.ts +19 -0
  60. package/lib/types/tools/shared.d.ts.map +1 -0
  61. package/lib/types/tools/shared.js +44 -0
  62. package/lib/types/tools/shared.js.map +1 -0
  63. package/lib/types/tools/verify.d.ts +14 -0
  64. package/lib/types/tools/verify.d.ts.map +1 -0
  65. package/lib/types/tools/verify.js +160 -0
  66. package/lib/types/tools/verify.js.map +1 -0
  67. package/lib/types/verify.d.ts +124 -0
  68. package/lib/types/verify.d.ts.map +1 -0
  69. package/lib/types/verify.js +391 -0
  70. package/lib/types/verify.js.map +1 -0
  71. package/lib/types/version.d.ts +8 -0
  72. package/lib/types/version.d.ts.map +1 -0
  73. package/lib/types/version.js +8 -0
  74. package/lib/types/version.js.map +1 -0
  75. package/package.json +137 -0
  76. package/src/clean.ts +382 -0
  77. package/src/config.ts +104 -0
  78. package/src/dataset.ts +445 -0
  79. package/src/events.ts +90 -0
  80. package/src/index.ts +115 -0
  81. package/src/present.ts +38 -0
  82. package/src/profile.ts +250 -0
  83. package/src/provider-local.ts +194 -0
  84. package/src/service.ts +172 -0
  85. package/src/store.ts +74 -0
  86. package/src/tools/clean.ts +150 -0
  87. package/src/tools/profile.ts +94 -0
  88. package/src/tools/shared.ts +47 -0
  89. package/src/tools/verify.ts +163 -0
  90. package/src/verify.ts +496 -0
  91. 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
+ }