dsh-data-cleaning-agent 0.2.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/lib/engine.js ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * 数据清洗补全引擎(纯函数,无 DSH 依赖,可在 node:test 中直接测试)。
3
+ *
4
+ * 设计约束:
5
+ * - 输入统一为「表头 + 行对象数组」,所有值先规范化为 string。
6
+ * - 清洗(clean)只会「丢弃 + 规范化」,绝不编造数据。
7
+ * - 补全(complete)只补「可由确定性规则推出的值」,补不出的字段留在
8
+ * `_incomplete` 列表里明示,绝不编造。
9
+ * - 引擎层返回完整明细(供下载/入库),模型工具层只回摘要(安全边界在工具层)。
10
+ */
11
+
12
+ /** 去掉 BOM、按 CSV/RFC4180 子集解析为 { headers, rows }。 */
13
+ export function parseCsv(text) {
14
+ const src = String(text ?? '').replace(/^\uFEFF/, '');
15
+ const records = [];
16
+ let field = '';
17
+ let row = [];
18
+ let inQuotes = false;
19
+
20
+ const pushField = () => { row.push(field); field = ''; };
21
+ const pushRow = () => { records.push(row); row = []; };
22
+
23
+ for (let i = 0; i < src.length; i += 1) {
24
+ const ch = src[i];
25
+ if (inQuotes) {
26
+ if (ch === '"') {
27
+ if (src[i + 1] === '"') { field += '"'; i += 1; } else { inQuotes = false; }
28
+ } else {
29
+ field += ch;
30
+ }
31
+ } else if (ch === '"') {
32
+ inQuotes = true;
33
+ } else if (ch === ',') {
34
+ pushField();
35
+ } else if (ch === '\n') {
36
+ pushField(); pushRow();
37
+ } else if (ch === '\r') {
38
+ if (src[i + 1] === '\n') i += 1;
39
+ pushField(); pushRow();
40
+ } else {
41
+ field += ch;
42
+ }
43
+ }
44
+ // 末尾无换行时的最后一行
45
+ if (field !== '' || row.length > 0) { pushField(); pushRow(); }
46
+
47
+ // 去掉全空行
48
+ const nonEmpty = records.filter((r) => r.some((c) => c.trim() !== ''));
49
+ if (nonEmpty.length === 0) return { headers: [], rows: [] };
50
+
51
+ const headers = nonEmpty[0].map((h, i) => (String(h).trim() || `col_${i + 1}`));
52
+ const rows = nonEmpty.slice(1).map((r) => {
53
+ const o = {};
54
+ headers.forEach((h, i) => { o[h] = r[i] === undefined ? '' : String(r[i]); });
55
+ return o;
56
+ });
57
+ return { headers, rows };
58
+ }
59
+
60
+ /** 懒加载 xlsx:仅在真正解析 XLS/XLSX 时才 require,headless 无此依赖也不受影响。 */
61
+ export async function parseXlsx(buffer) {
62
+ let XLSX;
63
+ try {
64
+ ({ default: XLSX } = await import('xlsx'));
65
+ } catch {
66
+ const err = new Error('xlsx dependency not available in this composition');
67
+ err.code = 'XLSX_UNAVAILABLE';
68
+ throw err;
69
+ }
70
+ const wb = XLSX.read(buffer, { type: 'buffer' });
71
+ const sheetName = wb.SheetNames[0];
72
+ if (!sheetName) return { headers: [], rows: [] };
73
+ const aoa = XLSX.utils.sheet_to_json(wb.Sheets[sheetName], { header: 1, defval: '', raw: false });
74
+ if (!aoa.length) return { headers: [], rows: [] };
75
+ const headers = aoa[0].map((h, i) => (String(h ?? '').trim() || `col_${i + 1}`));
76
+ const rows = aoa.slice(1).map((r) => {
77
+ const o = {};
78
+ headers.forEach((h, i) => { o[h] = r[i] === undefined || r[i] === null ? '' : String(r[i]); });
79
+ return o;
80
+ });
81
+ return { headers, rows };
82
+ }
83
+
84
+ export function detectFormat(filename) {
85
+ const name = String(filename ?? '').toLowerCase();
86
+ if (name.endsWith('.xlsx') || name.endsWith('.xls')) return 'xlsx';
87
+ if (name.endsWith('.csv') || name.endsWith('.txt')) return 'csv';
88
+ if (name.endsWith('.json')) return 'json';
89
+ return 'csv'; // 默认按 CSV 文本处理
90
+ }
91
+
92
+ export function parseJson(text) {
93
+ let parsed;
94
+ try { parsed = JSON.parse(String(text ?? '')); } catch {
95
+ const err = new Error('invalid JSON body'); err.code = 'BAD_JSON'; throw err;
96
+ }
97
+ const rows = Array.isArray(parsed) ? parsed : (parsed?.rows ?? []);
98
+ if (!rows.length) return { headers: [], rows: [] };
99
+ const headers = [...new Set(rows.flatMap((r) => (r && typeof r === 'object' ? Object.keys(r) : [])))];
100
+ const norm = rows.map((r) => {
101
+ const o = {};
102
+ headers.forEach((h) => { o[h] = r?.[h] === undefined || r?.[h] === null ? '' : String(r[h]); });
103
+ return o;
104
+ });
105
+ return { headers, rows: norm };
106
+ }
107
+
108
+ /** 规范化单个字符串值:trim、把 undefined/null 归一为 ''。 */
109
+ function norm(value) {
110
+ if (value === undefined || value === null) return '';
111
+ return String(value).trim();
112
+ }
113
+
114
+ /** 规范化手机号:去空格/连字符,保留数字。 */
115
+ export function normalizePhone(value) {
116
+ return norm(value).replace(/[\s-]/g, '');
117
+ }
118
+
119
+ /**
120
+ * 清洗一批行。opts:
121
+ * - required: string[],缺失即丢弃(默认 ['name','phone'])
122
+ * - amountField: string(默认 'amount')
123
+ * - dedupeOn: string|null,按该字段去重、保留首个(默认 'phone')
124
+ * - phoneField: string|null,需要做手机号规范化的列(默认 'phone')
125
+ */
126
+ export function cleanRows(rows, opts = {}) {
127
+ const required = opts.required ?? ['name', 'phone'];
128
+ const amountField = opts.amountField ?? 'amount';
129
+ const dedupeOn = opts.dedupeOn === undefined ? 'phone' : opts.dedupeOn;
130
+ const phoneField = opts.phoneField === undefined ? 'phone' : opts.phoneField;
131
+
132
+ const input = Array.isArray(rows) ? rows : [];
133
+ const cleaned = [];
134
+ const seen = new Set();
135
+ let badMissing = 0;
136
+ let badAmount = 0;
137
+ let badDuplicate = 0;
138
+
139
+ for (const raw of input) {
140
+ const row = {};
141
+ for (const [k, v] of Object.entries(raw ?? {})) row[k] = norm(v);
142
+ if (phoneField && phoneField in row) row[phoneField] = normalizePhone(row[phoneField]);
143
+
144
+ // 1) 必填字段
145
+ const missing = required.filter((f) => !row[f]);
146
+ if (missing.length > 0) { badMissing += 1; continue; }
147
+
148
+ // 2) 金额合法(非数字或负值丢弃)
149
+ if (amountField in row && row[amountField] !== '') {
150
+ const amount = Number(row[amountField]);
151
+ if (!Number.isFinite(amount) || amount < 0) { badAmount += 1; continue; }
152
+ }
153
+
154
+ // 3) 去重
155
+ if (dedupeOn && row[dedupeOn]) {
156
+ const key = row[dedupeOn];
157
+ if (seen.has(key)) { badDuplicate += 1; continue; }
158
+ seen.add(key);
159
+ }
160
+
161
+ cleaned.push(row);
162
+ }
163
+
164
+ return {
165
+ total: input.length,
166
+ kept: cleaned.length,
167
+ dropped: badMissing + badAmount + badDuplicate,
168
+ badMissing,
169
+ badAmount,
170
+ badDuplicate,
171
+ cleaned,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * 补全一批行(确定性规则,不编造)。
177
+ * - amount 空 → '0'
178
+ * - name 空 → placeholder(默认 '未命名')
179
+ * - phone 仅做规范化;补不出真实号码的记入 `_incomplete`
180
+ * 返回完整明细 + 分字段补全统计。
181
+ */
182
+ export function completeRows(rows, opts = {}) {
183
+ const amountField = opts.amountField ?? 'amount';
184
+ const phoneField = opts.phoneField === undefined ? 'phone' : opts.phoneField;
185
+ const namePlaceholder = opts.namePlaceholder ?? '未命名';
186
+ const fillableName = opts.fillableName === undefined ? true : opts.fillableName;
187
+
188
+ const input = Array.isArray(rows) ? rows : [];
189
+ const completed = [];
190
+ const fillStats = { name: 0, amount: 0, phoneNormalized: 0 };
191
+ const incomplete = [];
192
+
193
+ for (let i = 0; i < input.length; i += 1) {
194
+ const raw = input[i] ?? {};
195
+ const row = {};
196
+ let incompleteFields = [];
197
+
198
+ for (const [k, v] of Object.entries(raw)) {
199
+ const s = norm(v);
200
+ if (k === amountField && s === '') { row[k] = '0'; fillStats.amount += 1; }
201
+ else if (k === 'name' && s === '' && fillableName) { row[k] = namePlaceholder; fillStats.name += 1; }
202
+ else row[k] = s;
203
+ }
204
+
205
+ if (phoneField in row) {
206
+ const before = row[phoneField];
207
+ row[phoneField] = normalizePhone(row[phoneField]);
208
+ if (row[phoneField] !== before) fillStats.phoneNormalized += 1;
209
+ if (!row[phoneField]) incompleteFields.push(phoneField);
210
+ }
211
+ if ('name' in row && !row.name) incompleteFields.push('name');
212
+ if (amountField in row && row[amountField] === '') incompleteFields.push(amountField);
213
+
214
+ row._rowIndex = i;
215
+ if (incompleteFields.length > 0) {
216
+ row._incomplete = incompleteFields;
217
+ incomplete.push({ rowIndex: i, fields: incompleteFields });
218
+ }
219
+ completed.push(row);
220
+ }
221
+
222
+ return {
223
+ total: input.length,
224
+ completed: completed.length,
225
+ incompleteCount: incomplete.length,
226
+ fillStats,
227
+ incomplete,
228
+ completed,
229
+ };
230
+ }
231
+
232
+ /** 概览统计:列级缺失/去重 + amount 数值分布。 */
233
+ export function profileRows(rows, opts = {}) {
234
+ const amountField = opts.amountField ?? 'amount';
235
+ const input = Array.isArray(rows) ? rows : [];
236
+ const columns = new Map();
237
+ const amounts = [];
238
+
239
+ for (const raw of input) {
240
+ const entries = Object.entries(raw ?? {});
241
+ for (const [k, v] of entries) {
242
+ if (!columns.has(k)) columns.set(k, { present: 0, missing: 0, distinct: new Set() });
243
+ const s = norm(v);
244
+ const col = columns.get(k);
245
+ if (s === '') col.missing += 1;
246
+ else { col.present += 1; col.distinct.add(s); }
247
+ }
248
+ if (amountField in (raw ?? {})) {
249
+ const n = Number(norm(raw[amountField]));
250
+ if (Number.isFinite(n)) amounts.push(n);
251
+ }
252
+ }
253
+
254
+ const columnStats = [...columns.entries()].map(([name, c]) => ({
255
+ name,
256
+ present: c.present,
257
+ missing: c.missing,
258
+ distinct: c.distinct.size,
259
+ }));
260
+
261
+ let amountStats = null;
262
+ if (amounts.length > 0) {
263
+ const sorted = [...amounts].sort((a, b) => a - b);
264
+ const sum = sorted.reduce((a, b) => a + b, 0);
265
+ amountStats = {
266
+ count: sorted.length,
267
+ min: sorted[0],
268
+ max: sorted[sorted.length - 1],
269
+ sum,
270
+ mean: sum / sorted.length,
271
+ };
272
+ }
273
+
274
+ return { rowCount: input.length, columnCount: columns.size, columns: columnStats, amountStats };
275
+ }
276
+
277
+ /** 明细 → CSV 文本(下载用)。 */
278
+ export function toCsv(headers, rows) {
279
+ const hs = headers && headers.length ? headers : (rows[0] ? Object.keys(rows[0]) : []);
280
+ const escape = (v) => {
281
+ const s = String(v ?? '');
282
+ if (s.includes('"') || s.includes(',') || s.includes('\n') || s.includes('\r')) {
283
+ return `"${s.replace(/"/g, '""')}"`;
284
+ }
285
+ return s;
286
+ };
287
+ const lines = [hs.map(escape).join(',')];
288
+ for (const r of rows) lines.push(hs.map((h) => escape(r[h] ?? '')).join(','));
289
+ return `${lines.join('\r\n')}\r\n`;
290
+ }
package/lib/index.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 数据清洗补全智能体 · MVP · host 半区。
3
+ *
4
+ * 组成(全部为 Spike #1–#6 已实测同构的 seam):
5
+ * 1. ctx.tools —— 注册 data_clean_rows / data_complete_rows / data_profile
6
+ * 2. ctx.skills —— 注册内嵌 Skill `data-cleaning`(正文指引模型调上述工具)
7
+ * 3. webServer/webRuntime —— 挂载上传/解析/同步清洗补全/异步任务/UI 路由
8
+ * 4. ctx.jobs + ctx.storageDomain —— 异步任务状态机(web 组合内可用)
9
+ *
10
+ * headless 组合无 webServer/webRuntime:用 ctx.get() 存在性守卫跳过 web 半区,
11
+ * 工具与 Skill 照常注册(端到端真实模型路径依赖它们)。
12
+ */
13
+ import { mountWebRoutes } from './web.js';
14
+ import { registerTools, TOOL_CLEAN } from './tools.js';
15
+ import { registerSkill, SKILL_NAME } from './skill.js';
16
+
17
+ export const name = 'data-cleaning-agent';
18
+ export const inject = [];
19
+
20
+ export function apply(ctx, config) {
21
+ const report = {
22
+ tools: 'not-checked',
23
+ skills: 'not-checked',
24
+ toolRegistered: false,
25
+ skillRegistered: false,
26
+ webMounted: false,
27
+ webSkipped: false,
28
+ };
29
+ const disposers = [];
30
+
31
+ // 1. 模型工具
32
+ try {
33
+ ctx.inject(['tools'], (tctx) => {
34
+ report.tools = 'present';
35
+ report.toolRegister = typeof tctx.tools?.register === 'function' ? 'ok' : String(typeof tctx.tools?.register);
36
+ disposers.push(...registerTools(tctx.tools));
37
+ report.toolRegistered = true;
38
+ });
39
+ } catch (error) {
40
+ report.tools = `absent: ${error instanceof Error ? error.message : String(error)}`;
41
+ }
42
+
43
+ // 2. 内嵌 Skill
44
+ try {
45
+ ctx.inject(['skills'], (sctx) => {
46
+ report.skills = 'present';
47
+ report.skillRegister = typeof sctx.skills?.register === 'function' ? 'ok' : String(typeof sctx.skills?.register);
48
+ disposers.push(registerSkill(sctx.skills));
49
+ report.skillRegistered = true;
50
+ });
51
+ } catch (error) {
52
+ report.skills = `absent: ${error instanceof Error ? error.message : String(error)}`;
53
+ }
54
+
55
+ // 3. web 半区(仅 web 组合存在;headless 组合无 webServer/webRuntime,inject 会失败)
56
+ try {
57
+ ctx.inject(['webServer', 'webRuntime', 'tools', 'skills', 'jobs', 'storageDomain'], (wctx) => {
58
+ const dispose = mountWebRoutes(wctx, { logger: ctx.logger, report, TOOL_NAME: TOOL_CLEAN, SKILL_NAME });
59
+ if (typeof dispose === 'function' && typeof ctx.effect === 'function') {
60
+ ctx.effect(() => () => dispose(), 'data-cleaning-agent: web routes');
61
+ }
62
+ report.webMounted = true;
63
+ });
64
+ } catch (error) {
65
+ report.webSkipped = true;
66
+ console.warn(`[dc-agent] web half not mounted (headless?): ${error instanceof Error ? error.message : String(error)}`);
67
+ }
68
+
69
+ // eslint-disable-next-line no-console
70
+ console.log('[dc-agent] host apply() ran');
71
+ ctx.__DC_MVP_REPORT__ = report;
72
+ ctx.__DC_MVP_DISPOSERS__ = disposers;
73
+ }
package/lib/jobs.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Job / Storage 状态机(Spike #4 实测同构)。
3
+ * - `ctx.jobs`:后台任务生命周期(start/wait/read/kill/list),host bundle 需先
4
+ * `attachController()`(无主 job 否则抛 "no job controller serves this agent")。
5
+ * - `ctx.storageDomain`:schema 校验的持久 KV,落盘 `DSH_HOME/storages/<domain>.json`。
6
+ *
7
+ * 任务记录持久化到 domain `dc_tasks_v1`(table `jobs`),状态机:
8
+ * queued → running → completed | failed | killed
9
+ */
10
+ import { cleanRows, completeRows, profileRows } from './engine.js';
11
+
12
+ const DOMAIN_NAME = 'dc_tasks_v1';
13
+ const DOMAIN_VERSION = 1;
14
+ const permissiveSchema = {
15
+ parse: (v) => v,
16
+ safeParse: (v) => ({ success: true, data: v }),
17
+ };
18
+
19
+ const domainSpec = () => ({
20
+ name: DOMAIN_NAME,
21
+ version: DOMAIN_VERSION,
22
+ tables: { jobs: { valueSchema: permissiveSchema } },
23
+ });
24
+
25
+ function now() {
26
+ return new Date().toISOString();
27
+ }
28
+
29
+ /** 同步执行一次清洗/补全/概览,返回完整明细(供下载)与摘要。 */
30
+ export function runSync(kind, rows, opts = {}) {
31
+ switch (kind) {
32
+ case 'clean': {
33
+ const r = cleanRows(rows, opts);
34
+ return { kind, summary: { total: r.total, kept: r.kept, dropped: r.dropped, badMissing: r.badMissing, badAmount: r.badAmount, badDuplicate: r.badDuplicate }, rows: r.cleaned, headers: opts.headers ?? [] };
35
+ }
36
+ case 'complete': {
37
+ const r = completeRows(rows, opts);
38
+ return { kind, summary: { total: r.total, completed: r.completed, incompleteCount: r.incompleteCount, name: r.fillStats.name, amount: r.fillStats.amount, phoneNormalized: r.fillStats.phoneNormalized }, rows: r.completed, headers: opts.headers ?? [] };
39
+ }
40
+ case 'profile': {
41
+ const r = profileRows(rows, opts);
42
+ return { kind, summary: { rowCount: r.rowCount, columnCount: r.columnCount, columns: r.columns, amountStats: r.amountStats }, rows: [], headers: opts.headers ?? [] };
43
+ }
44
+ default:
45
+ throw new Error(`unknown kind: ${kind}`);
46
+ }
47
+ }
48
+
49
+ export class DataCleaningJobs {
50
+ constructor({ jobs, storageDomain, logger }) {
51
+ this.jobs = jobs;
52
+ this.storageDomain = storageDomain;
53
+ this.logger = logger ?? console;
54
+ this.access = null;
55
+ this.detachController = null;
56
+ }
57
+
58
+ async init() {
59
+ if (!this.jobs) throw new Error('jobs service unavailable');
60
+ if (!this.storageDomain) throw new Error('storageDomain service unavailable');
61
+ // host bundle 提供自己的后台执行器
62
+ this.detachController = this.jobs.attachController('data-cleaning-agent-mvp');
63
+ this.access = await this.storageDomain.open(domainSpec());
64
+ this.logger.info('[dc-agent] jobs/storage state machine ready');
65
+ return this;
66
+ }
67
+
68
+ table() {
69
+ if (!this.access) throw new Error('state machine not initialized');
70
+ return this.access.table('jobs');
71
+ }
72
+
73
+ async start({ kind, rows, headers, opts = {} }) {
74
+ const id = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
75
+ const record = {
76
+ id,
77
+ kind,
78
+ state: 'queued',
79
+ rowsIn: Array.isArray(rows) ? rows.length : 0,
80
+ summary: null,
81
+ rowsOut: 0,
82
+ error: null,
83
+ createdAt: now(),
84
+ startedAt: null,
85
+ finishedAt: null,
86
+ };
87
+ await this.table().put(id, record);
88
+
89
+ // 将明细暂存在内存闭包,任务完成时写回摘要(不把原始行写进持久 KV,避免膨胀)。
90
+ const taskRows = Array.isArray(rows) ? rows : [];
91
+
92
+ this.jobs.start({
93
+ kind: `dc-${kind}`,
94
+ label: `data-cleaning ${kind} (${record.rowsIn} rows)`,
95
+ run: () => {
96
+ let resolveDone;
97
+ const done = new Promise((res) => { resolveDone = res; });
98
+ const finish = async (patch) => {
99
+ try {
100
+ await this.table().update(id, (rec) => ({ ...rec, ...patch, finishedAt: now() }));
101
+ } catch (e) {
102
+ this.logger.warn(`[dc-agent] update failed for ${id}: ${e?.message ?? e}`);
103
+ }
104
+ };
105
+ // 同步执行(MVP 体量直接跑;大文件异步化留给产品阶段)
106
+ queueMicrotask(async () => {
107
+ try {
108
+ await this.table().update(id, (rec) => ({ ...rec, state: 'running', startedAt: now() }));
109
+ const result = runSync(kind, taskRows, { ...opts, headers });
110
+ await finish({ state: 'completed', summary: result.summary, rowsOut: result.rows.length });
111
+ resolveDone({ status: 'completed', output: result });
112
+ } catch (error) {
113
+ await finish({ state: 'failed', error: error instanceof Error ? error.message : String(error) });
114
+ resolveDone({ status: 'failed', detail: error instanceof Error ? error.message : String(error) });
115
+ }
116
+ });
117
+ return {
118
+ done,
119
+ readOutput: () => '',
120
+ cancel: (reason) => {
121
+ finish({ state: 'killed', error: String(reason ?? 'cancelled') }).catch(() => {});
122
+ resolveDone({ status: 'killed', detail: String(reason ?? 'cancelled') });
123
+ },
124
+ };
125
+ },
126
+ });
127
+
128
+ return id;
129
+ }
130
+
131
+ async list() {
132
+ const entries = this.table().entries();
133
+ return [...entries].map(([, rec]) => rec).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
134
+ }
135
+
136
+ async get(id) {
137
+ return this.table().get(id) ?? null;
138
+ }
139
+
140
+ async dispose() {
141
+ if (this.access) { try { await this.access.close(); } catch {} this.access = null; }
142
+ if (this.detachController) { try { this.detachController(); } catch {} this.detachController = null; }
143
+ }
144
+ }
package/lib/skill.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 内嵌 Skill:`data-cleaning`。
3
+ * 正文只描述工作流,把「工具选择」交给模型;安全约束:绝不回传原始行、绝不编造数据。
4
+ */
5
+ import { TOOL_CLEAN, TOOL_COMPLETE, TOOL_PROFILE } from './tools.js';
6
+
7
+ export const SKILL_NAME = 'data-cleaning';
8
+
9
+ export function registerSkill(skills) {
10
+ return skills.register({
11
+ name: SKILL_NAME,
12
+ description:
13
+ 'Clean, complete, and profile a batch of raw tabular data rows (name / phone / amount and similar columns).',
14
+ whenToUse:
15
+ 'When the user asks to clean, complete, validate, de-duplicate, or summarize a batch of raw data rows, CSV records, or table-like data.',
16
+ source: 'dsh-data-cleaning-agent',
17
+ content: [
18
+ 'You are a data cleaning and completion assistant. Work only on the rows the user actually provided; never invent, pad, or fabricate extra rows.',
19
+ '',
20
+ 'Workflow:',
21
+ `1. (Optional) Run \`${TOOL_PROFILE}\` on the batch to understand columns and amount distribution.`,
22
+ `2. Run \`${TOOL_CLEAN}\` to trim, normalize phone, drop rows with missing required fields, drop non-numeric/negative amounts, and de-duplicate.`,
23
+ `3. If the user also asks to fill gaps, run \`${TOOL_COMPLETE}\` on the kept rows: it fills empty amount with 0 and empty name with a placeholder, and reports anything it cannot deterministically complete.`,
24
+ '4. Report only the returned summaries (total / kept / dropped / incomplete). Never echo raw rows or full detail rows back to the user.',
25
+ '',
26
+ 'Safety rules:',
27
+ '- Never return raw input rows or the full cleaned/completed rows in your reply — summaries only.',
28
+ '- Never invent a phone number, name, or amount. If a value cannot be derived deterministically, say it is incomplete.',
29
+ '',
30
+ ].join('\n'),
31
+ });
32
+ }
package/lib/tools.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 模型工具定义:`data_clean_rows` / `data_complete_rows` / `data_profile`。
3
+ * 安全边界:模型工具只回摘要,绝不回传原始行或明细行;明细仅通过 web 下载链路暴露。
4
+ * 手写 definition 与 Spike #5 结论一致:`parameters`/`output.schema` 用对象级
5
+ * `required: [...]`,不得把 `required:true` 写进 property 内部。
6
+ */
7
+ import { cleanRows, completeRows, profileRows } from './engine.js';
8
+
9
+ export const TOOL_CLEAN = 'data_clean_rows';
10
+ export const TOOL_COMPLETE = 'data_complete_rows';
11
+ export const TOOL_PROFILE = 'data_profile';
12
+
13
+ function rowsProperty() {
14
+ return {
15
+ type: 'array',
16
+ items: { type: 'object', additionalProperties: true },
17
+ };
18
+ }
19
+
20
+ function summarySchema(extraProps = {}) {
21
+ return {
22
+ type: 'object',
23
+ additionalProperties: false,
24
+ properties: {
25
+ total: { type: 'integer' },
26
+ kept: { type: 'integer' },
27
+ dropped: { type: 'integer' },
28
+ badMissing: { type: 'integer' },
29
+ badAmount: { type: 'integer' },
30
+ badDuplicate: { type: 'integer' },
31
+ ...extraProps,
32
+ },
33
+ required: ['total', 'kept', 'dropped', 'badMissing', 'badAmount', 'badDuplicate'],
34
+ };
35
+ }
36
+
37
+ export function registerTools(tools) {
38
+ const disposers = [];
39
+
40
+ disposers.push(tools.register({
41
+ name: TOOL_CLEAN,
42
+ description:
43
+ 'Clean one batch of raw data rows: trim values, normalize phone numbers, drop rows missing a required field, drop rows with non-numeric or negative amount, and de-duplicate. Returns a summary only (never raw rows).',
44
+ parameters: {
45
+ type: 'object',
46
+ additionalProperties: false,
47
+ properties: { rows: rowsProperty() },
48
+ required: ['rows'],
49
+ },
50
+ output: {
51
+ schema: summarySchema(),
52
+ render: (_args, value) => [{
53
+ type: 'text',
54
+ text: `cleaned ${value.total} rows: kept ${value.kept}, dropped ${value.dropped} (missing ${value.badMissing}, bad-amount ${value.badAmount}, duplicate ${value.badDuplicate})`,
55
+ }],
56
+ },
57
+ async execute(args) {
58
+ const r = cleanRows(Array.isArray(args.rows) ? args.rows : []);
59
+ return {
60
+ total: r.total,
61
+ kept: r.kept,
62
+ dropped: r.dropped,
63
+ badMissing: r.badMissing,
64
+ badAmount: r.badAmount,
65
+ badDuplicate: r.badDuplicate,
66
+ };
67
+ },
68
+ }));
69
+
70
+ disposers.push(tools.register({
71
+ name: TOOL_COMPLETE,
72
+ description:
73
+ 'Complete one batch of raw data rows with deterministic rules only: fill empty amount with 0, fill empty name with a placeholder, normalize phone numbers. Fields that cannot be deterministically completed are reported as incomplete (by row index + field). Returns a summary only, never raw rows.',
74
+ parameters: {
75
+ type: 'object',
76
+ additionalProperties: false,
77
+ properties: { rows: rowsProperty() },
78
+ required: ['rows'],
79
+ },
80
+ output: {
81
+ schema: {
82
+ type: 'object',
83
+ additionalProperties: false,
84
+ properties: {
85
+ total: { type: 'integer' },
86
+ completed: { type: 'integer' },
87
+ incompleteCount: { type: 'integer' },
88
+ name: { type: 'integer' },
89
+ amount: { type: 'integer' },
90
+ phoneNormalized: { type: 'integer' },
91
+ },
92
+ required: ['total', 'completed', 'incompleteCount', 'name', 'amount', 'phoneNormalized'],
93
+ },
94
+ render: (_args, value) => [{
95
+ type: 'text',
96
+ text: `completed ${value.total} rows: ${value.completed} done, ${value.incompleteCount} still incomplete (filled name ${value.name}, amount ${value.amount}, normalized phone ${value.phoneNormalized})`,
97
+ }],
98
+ },
99
+ async execute(args) {
100
+ const r = completeRows(Array.isArray(args.rows) ? args.rows : []);
101
+ return {
102
+ total: r.total,
103
+ completed: r.completed,
104
+ incompleteCount: r.incompleteCount,
105
+ name: r.fillStats.name,
106
+ amount: r.fillStats.amount,
107
+ phoneNormalized: r.fillStats.phoneNormalized,
108
+ };
109
+ },
110
+ }));
111
+
112
+ disposers.push(tools.register({
113
+ name: TOOL_PROFILE,
114
+ description:
115
+ 'Profile one batch of raw data rows: column presence/missing/distinct counts and amount distribution. Returns a summary only.',
116
+ parameters: {
117
+ type: 'object',
118
+ additionalProperties: false,
119
+ properties: { rows: rowsProperty() },
120
+ required: ['rows'],
121
+ },
122
+ output: {
123
+ schema: {
124
+ type: 'object',
125
+ additionalProperties: false,
126
+ properties: {
127
+ rowCount: { type: 'integer' },
128
+ columnCount: { type: 'integer' },
129
+ },
130
+ required: ['rowCount', 'columnCount'],
131
+ },
132
+ render: (_args, value) => [{
133
+ type: 'text',
134
+ text: `profiled ${value.rowCount} rows across ${value.columnCount} columns`,
135
+ }],
136
+ },
137
+ async execute(args) {
138
+ const r = profileRows(Array.isArray(args.rows) ? args.rows : []);
139
+ return { rowCount: r.rowCount, columnCount: r.columnCount };
140
+ },
141
+ }));
142
+
143
+ return disposers;
144
+ }