dsh-data-cleaning-agent 0.3.0 → 0.4.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.
@@ -9,34 +9,54 @@
9
9
  * 方案 A(模型中介式):模型亲自调用 `mcp__qcc-company__*` / `mcp__qcc-risk__*`
10
10
  * 完成消歧 → 工商详情 → 风险标签,再组装结果。本插件零后端改动。
11
11
  */
12
+ import {
13
+ QCC_PHASE2_COMPANY_TOOLS,
14
+ QCC_PHASE2_DIMENSION_GROUPS,
15
+ QCC_PHASE2_HISTORY_TOOLS,
16
+ } from './qcc-phase2.js';
17
+
12
18
  export const ENRICH_SKILL_NAME = 'enterprise-enrichment';
13
19
 
20
+ const companyTools = QCC_PHASE2_COMPANY_TOOLS;
21
+ const historyTools = QCC_PHASE2_HISTORY_TOOLS;
22
+
23
+ const groupSummary = Object.entries(QCC_PHASE2_DIMENSION_GROUPS)
24
+ .map(([id, group]) => ` - \`${id}\`(${group.label}): ${group.tools.join(', ')}`)
25
+ .join('\n');
26
+
14
27
  export function registerEnrichSkill(skills) {
15
28
  return skills.register({
16
29
  name: ENRICH_SKILL_NAME,
17
30
  description:
18
- 'Enrich a list of company names with the latest Qichacha (QCC) business-registration fields via the QCC MCP tools.',
31
+ 'Enrich company lists with Qichacha (QCC) registration, company panorama, ownership, governance, and optional historical-business dimensions.',
19
32
  whenToUse:
20
- 'When the user gives a list of company names (possibly fuzzy or incomplete) and asks to fill in credit code / legal representative / registered capital / establishment date / registration & business status / risk tags, or asks to "enrich / complete with Qichacha (企查查)".',
33
+ 'When the user gives company names and asks to enrich / complete them with Qichacha (企查查), including registration fields, company panorama, ownership penetration, governance, historical changes, or risk tags.',
21
34
  source: 'dsh-data-cleaning-agent',
22
35
  content: [
23
- 'You are an enterprise-list enrichment assistant. You fill a list of company names with the latest Qichacha (QCC) business-registration fields by calling the QCC MCP tools. Never invent, pad, or fabricate any field.',
36
+ 'You are an enterprise-list enrichment assistant. You fill company lists with Qichacha (QCC) data by calling QCC MCP tools. Never invent, pad, or fabricate any field.',
24
37
  '',
25
38
  'Workflow:',
26
39
  '1. Check QCC availability first: run `qcc_oauth_status`. If not connected, tell the user to run `qcc_oauth_connect` first and stop. If the token is expired, guide the user to `qcc_oauth_connect` (it reuses the grant and refreshes without a new authorization page).',
27
40
  '2. Parse the company-name list from what the user gave (pasted text / CSV / JSON / inline list). Keep only the distinct company-name column.',
28
- '3. For EACH name: run `mcp__qcc-company__get_company_by_query`.',
41
+ `3. For EACH name: run \`${companyTools.resolveEntity}\`.`,
29
42
  ' - Unique exact match → lock that entity and keep its credit code.',
30
43
  ' - Multiple candidates → DO NOT auto-pick the first. List the candidates (name + region + credit code) and ask the user which one to use.',
31
44
  ' - No match → mark that row as `unresolved` and continue.',
32
- '4. For each locked entity: run `mcp__qcc-company__get_company_registration_info` to fill `credit_no` / `legal_rep` / `reg_capital` / `establish_date` / `reg_status` / `biz_status`. When the user also wants risk tags, run `mcp__qcc-risk__get_company_risk_scan` and fill `risk_tags` from the hit dimensions + counts only.',
33
- '5. Assemble the enriched table. NEVER invent a field if QCC returns no value, leave it empty and mark the row/field `unresolved`.',
34
- '6. Report a one-line summary (enriched N / unresolved M / multi-candidate K) plus the enriched table as Markdown. For large lists (dozens of rows or more), process in batches and report progress per batch; do not drop rows silently.',
45
+ `4. For each locked entity, always run \`${companyTools.registration}\`. Run \`${companyTools.verifyIdentity}\` when the input includes a credit code or the user asks for identity verification.`,
46
+ '5. Determine requested dimension groups from the user request. If it is not explicit, ask the user to choose `panorama`, `ownership`, `governance`, and/or `history`; do not invoke every 0.4.0 tool by default. The verified group contract is:',
47
+ groupSummary,
48
+ '6. Call only the tools required by the selected groups. Process large lists in explicit batches, announce the next batch before paid calls, preserve input row order, and never drop a row silently.',
49
+ `7. The \`history\` group requires an enterprise-certified account. Only call ${Object.values(historyTools).map((tool) => `\`${tool}\``).join(', ')} when the user requested history and the account is eligible. If a history tool is unavailable or returns a permission error, mark the group \`permission_required\` or \`not_available\`, continue current-data groups, and never replace history with guessed values.`,
50
+ '8. When risk tags are explicitly requested, run `mcp__qcc-risk__get_company_risk_scan` and use hit dimensions + returned counts only. Risk is outside the 0.4.0 panorama contract and must not be called implicitly.',
51
+ '9. Assemble each requested dimension with `value`, `status`, and `source_tool`. Missing values are `unresolved`; an absent field never means "none" or zero.',
52
+ '10. Report enriched / unresolved / ambiguous / permission-required counts and a small requested preview. Do not paste a full sensitive list into chat. Use a same-origin Host download or artifact when that capability is available; otherwise say that no downloadable artifact was created instead of pretending one exists.',
35
53
  '',
36
54
  'Safety rules:',
37
55
  '- Never fabricate a credit code, legal representative, capital, amount, ratio, or status.',
38
56
  '- Never auto-select among ambiguous candidates — always confirm with the user.',
39
- '- Quote amounts / ratios / counts exactly as the QCC tool returned them; never recompute or estimate.',
57
+ '- Quote amounts / ratios / counts exactly as the QCC tool returned them; never recompute, multiply ownership chains, aggregate, or estimate.',
58
+ '- Preserve provenance: every populated dimension must identify the QCC source tool that returned it.',
59
+ '- Do not continue a paid batch after cancellation, authorization failure, or an unresolved ambiguity that affects entity identity.',
40
60
  '- Never expose QCC tokens or credentials.',
41
61
  ].join('\n'),
42
62
  });
package/lib/web.js CHANGED
@@ -9,6 +9,8 @@
9
9
  */
10
10
  import { parseCsv, parseXlsx, parseJson, detectFormat, toCsv } from './engine.js';
11
11
  import { runSync, DataCleaningJobs } from './jobs.js';
12
+ import { QccBridgeError, QccHostBridge } from './qcc.js';
13
+ import { fingerprintRequest, G5RunStore } from './qcc-runs.js';
12
14
 
13
15
  const MAX_BODY = 16 * 1024 * 1024; // 16 MiB 上传上限(MVP)
14
16
 
@@ -52,6 +54,49 @@ async function readBody(req, max = MAX_BODY) {
52
54
  return Buffer.concat(chunks);
53
55
  }
54
56
 
57
+ function requirePaidConfirmation(payload) {
58
+ if (payload?.confirmPaidCalls !== true) {
59
+ throw new QccBridgeError(
60
+ 'QCC_CONFIRM_REQUIRED',
61
+ 'Set confirmPaidCalls=true after the user confirms QCC paid data calls.',
62
+ );
63
+ }
64
+ }
65
+
66
+ function qccHttpStatus(code) {
67
+ if (code === 'QCC_RUN_NOT_FOUND') return 404;
68
+ if (code === 'QCC_AUTH_REQUIRED') return 401;
69
+ if (code === 'QCC_PERMISSION_DENIED') return 403;
70
+ if (code === 'QCC_QUOTA_EXHAUSTED') return 402;
71
+ if (code === 'QCC_RATE_LIMITED') return 429;
72
+ if (code === 'QCC_TIMEOUT') return 504;
73
+ if (
74
+ code === 'QCC_NOT_CONNECTED'
75
+ || code === 'QCC_TOOL_UNAVAILABLE'
76
+ || code === 'QCC_UPSTREAM_UNAVAILABLE'
77
+ || code === 'QCC_IDEMPOTENCY_CAPACITY'
78
+ ) return 503;
79
+ if (
80
+ code === 'QCC_CONFIRM_REQUIRED'
81
+ || code === 'QCC_ABORTED'
82
+ || code === 'QCC_IDEMPOTENCY_CONFLICT'
83
+ || code === 'QCC_REVIEW_NOT_PENDING'
84
+ || code === 'QCC_CANDIDATE_INVALID'
85
+ || code === 'QCC_OPERATION_IN_PROGRESS'
86
+ || code === 'QCC_RETRY_NOT_FAILED'
87
+ || code === 'QCC_RETRY_NOT_ALLOWED'
88
+ ) return 409;
89
+ return 400;
90
+ }
91
+
92
+ function writeQccError(res, error) {
93
+ const code = error?.code ?? 'QCC_BRIDGE';
94
+ const payload = error instanceof QccBridgeError
95
+ ? error.toJSON()
96
+ : { code, message: 'G5 Host Bridge request failed' };
97
+ writeJson(res, qccHttpStatus(code), { ok: false, ...payload });
98
+ }
99
+
55
100
  /** 从 JSON 协议解析上传:{ filename, content }。content 为字符串;xlsx 时为 base64。 */
56
101
  async function parseUpload(body) {
57
102
  let payload;
@@ -147,7 +192,12 @@ async function withRows() {
147
192
  if (!p.ok) throw new Error(JSON.stringify(p));
148
193
  return { rows: p.rows, headers: p.headers };
149
194
  }
150
- return { rows: JSON.parse($('src').value || '[]'), headers: null };
195
+ const content = $('src').value.trim();
196
+ if (!content) return { rows: [], headers: null };
197
+ if (content.startsWith('[')) return { rows: JSON.parse(content), headers: null };
198
+ const p = await call('/data-cleaning/api/mvp/parse', { filename: 'data.csv', content });
199
+ if (!p.ok) throw new Error(JSON.stringify(p));
200
+ return { rows: p.rows, headers: p.headers };
151
201
  }
152
202
 
153
203
  $('parse').onclick = async () => {
@@ -212,6 +262,9 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
212
262
  const tools = wctx.tools;
213
263
  const skills = wctx.skills;
214
264
  const disposers = [];
265
+ const qccBridge = new QccHostBridge({ tools, logger });
266
+ const g5Runs = new G5RunStore();
267
+ report.qccBridgeMounted = true;
215
268
  let state = null; // DataCleaningJobs,惰性初始化
216
269
  let stateReady = null;
217
270
 
@@ -248,10 +301,145 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
248
301
  skillListed: null,
249
302
  jobs: Boolean(wctx.jobs),
250
303
  storageDomain: Boolean(wctx.storageDomain),
304
+ qccBridge: qccBridge.capabilities(),
251
305
  },
252
306
  });
253
307
  });
254
308
 
309
+ register('/data-cleaning/api/g5/capabilities', (req, res) => {
310
+ if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
311
+ writeJson(res, 200, {
312
+ ok: true,
313
+ marker: 'g5-host-bridge',
314
+ capabilities: qccBridge.capabilities(),
315
+ limits: { maxRows: 100, maxConcurrency: 4 },
316
+ paidCallConfirmationRequired: true,
317
+ idempotencyRequired: true,
318
+ candidateResume: true,
319
+ manualRetry: true,
320
+ runPersistence: 'host-memory',
321
+ });
322
+ });
323
+
324
+ register('/data-cleaning/api/phase2/capabilities', (req, res) => {
325
+ if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
326
+ if (req.method !== 'GET') {
327
+ return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'GET required' });
328
+ }
329
+ writeJson(res, 200, {
330
+ ok: true,
331
+ marker: 'qcc-phase2-capabilities',
332
+ capabilities: qccBridge.phase2Capabilities(),
333
+ executesTools: false,
334
+ paidCalls: false,
335
+ historyAuthorizationRequiresRealCall: true,
336
+ });
337
+ });
338
+
339
+ const runPayload = (run, replayed = false) => {
340
+ const headers = [...new Set([
341
+ ...run.headers,
342
+ ...run.rows.flatMap((row) => Object.keys(row)),
343
+ ])];
344
+ return {
345
+ ok: true,
346
+ marker: 'g5-host-bridge',
347
+ ...run,
348
+ idempotencyReplayed: replayed,
349
+ rowCount: run.rows.length,
350
+ csv: toCsv(headers, run.rows),
351
+ downloadName: 'qcc-enriched.csv',
352
+ };
353
+ };
354
+
355
+ register('/data-cleaning/api/g5/enrich', async (req, res) => {
356
+ if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
357
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'POST required' });
358
+ try {
359
+ const body = await readBody(req);
360
+ const payload = JSON.parse(body.toString('utf8'));
361
+ requirePaidConfirmation(payload);
362
+ const rows = Array.isArray(payload?.rows) ? payload.rows : [];
363
+ const headers = Array.isArray(payload?.headers) ? payload.headers.map(String) : [];
364
+ const nameField = String(payload?.nameField ?? 'name');
365
+ const includeRisk = payload?.includeRisk === true;
366
+ const concurrency = Number(payload?.concurrency ?? 2);
367
+ const input = { rows, headers, nameField, includeRisk, concurrency };
368
+ const executed = await g5Runs.executeOnce({
369
+ key: payload?.idempotencyKey,
370
+ fingerprint: fingerprintRequest('enrich', input),
371
+ operation: async () => {
372
+ const audit = [];
373
+ const result = await qccBridge.enrichRows(rows, {
374
+ nameField,
375
+ includeRisk,
376
+ concurrency,
377
+ maxRows: 100,
378
+ onAudit: (event) => audit.push(event),
379
+ });
380
+ return g5Runs.createRun({ headers, nameField, includeRisk, concurrency, result, audit });
381
+ },
382
+ });
383
+ writeJson(res, 200, runPayload(executed.value, executed.replayed));
384
+ } catch (error) {
385
+ writeQccError(res, error);
386
+ }
387
+ });
388
+
389
+ register('/data-cleaning/api/g5/resolve', async (req, res) => {
390
+ if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
391
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'POST required' });
392
+ try {
393
+ const payload = JSON.parse((await readBody(req)).toString('utf8'));
394
+ requirePaidConfirmation(payload);
395
+ const input = {
396
+ runId: String(payload?.runId ?? ''),
397
+ companyName: String(payload?.companyName ?? ''),
398
+ selectedCreditNo: String(payload?.selectedCreditNo ?? ''),
399
+ };
400
+ const executed = await g5Runs.executeOnce({
401
+ key: payload?.idempotencyKey,
402
+ fingerprint: fingerprintRequest('resolve', input),
403
+ operation: () => g5Runs.resolveCandidate(input.runId, input, qccBridge),
404
+ });
405
+ writeJson(res, 200, runPayload(executed.value, executed.replayed));
406
+ } catch (error) {
407
+ writeQccError(res, error);
408
+ }
409
+ });
410
+
411
+ register('/data-cleaning/api/g5/retry', async (req, res) => {
412
+ if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
413
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'POST required' });
414
+ try {
415
+ const payload = JSON.parse((await readBody(req)).toString('utf8'));
416
+ requirePaidConfirmation(payload);
417
+ const input = {
418
+ runId: String(payload?.runId ?? ''),
419
+ companyNames: Array.isArray(payload?.companyNames) ? payload.companyNames.map(String) : [],
420
+ };
421
+ const executed = await g5Runs.executeOnce({
422
+ key: payload?.idempotencyKey,
423
+ fingerprint: fingerprintRequest('retry', input),
424
+ operation: () => g5Runs.retryCompanies(input.runId, input.companyNames, qccBridge),
425
+ });
426
+ writeJson(res, 200, runPayload(executed.value, executed.replayed));
427
+ } catch (error) {
428
+ writeQccError(res, error);
429
+ }
430
+ });
431
+
432
+ register('/data-cleaning/api/g5/run', (req, res) => {
433
+ if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
434
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'GET required' });
435
+ try {
436
+ const runId = String((req.url ?? '').split('/').filter(Boolean).pop() ?? '');
437
+ writeJson(res, 200, runPayload(g5Runs.get(runId)));
438
+ } catch (error) {
439
+ writeQccError(res, error);
440
+ }
441
+ });
442
+
255
443
  register('/data-cleaning/api/mvp/parse', async (req, res) => {
256
444
  if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
257
445
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-data-cleaning-agent",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Clean, complete, and profile enterprise name lists in DeepSeek Harness — a data cleaning & completion agent plugin with local CSV/XLSX/JSON engine and optional Qichacha (QCC) MCP enrichment. Maintained by Qichacha/QCC.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -23,14 +23,21 @@
23
23
  "docs/FIRST-CONTRIBUTION.md",
24
24
  "docs/COMPATIBILITY.md",
25
25
  "docs/QCC-ENRICHMENT-DESIGN.md",
26
- "docs/QCC-PHASES-ROADMAP.md"
26
+ "docs/QCC-PHASES-ROADMAP.md",
27
+ "docs/PHASE2-ACCEPTANCE.md",
28
+ "docs/RELEASE-0.4.0.md",
29
+ "docs/G5-HOST-BRIDGE.md",
30
+ "docs/G5-E2E-RUNBOOK.md"
27
31
  ],
28
32
  "scripts": {
29
33
  "test": "node --test",
30
- "lint": "node --check lib/index.js && node --check lib/engine.js && node --check lib/tools.js && node --check lib/skill.js && node --check lib/skill-enrich.js && node --check lib/jobs.js && node --check lib/web.js && node --check lib/client.js",
34
+ "lint": "node --check lib/index.js && node --check lib/engine.js && node --check lib/tools.js && node --check lib/skill.js && node --check lib/qcc-phase2.js && node --check lib/qcc-phase2-acceptance.js && node --check lib/skill-enrich.js && node --check lib/jobs.js && node --check lib/qcc-safety.js && node --check lib/qcc.js && node --check lib/qcc-runs.js && node --check lib/web.js && node --check lib/client.js && node --check scripts/check-market-registration.mjs && node --check scripts/g5-e2e.mjs && node --check scripts/phase2-acceptance.mjs",
31
35
  "docs:check": "node scripts/check-readme-version.mjs",
32
36
  "marketing:check": "node scripts/check-marketing.mjs",
33
37
  "verify-pack": "node scripts/verify-pack.mjs",
38
+ "market:check": "node scripts/check-market-registration.mjs",
39
+ "e2e:g5": "node scripts/g5-e2e.mjs",
40
+ "e2e:phase2": "node scripts/phase2-acceptance.mjs",
34
41
  "check": "npm run lint && npm run docs:check && npm run marketing:check && npm run verify-pack && npm test",
35
42
  "prepublishOnly": "npm run check"
36
43
  },