dsh-data-cleaning-agent 0.5.2 → 0.6.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 +67 -0
- package/README.en.md +20 -10
- package/README.md +17 -8
- package/docs/COMPATIBILITY.md +33 -2
- package/docs/RELEASE-0.5.2.md +6 -2
- package/docs/RELEASE-0.5.3.md +66 -0
- package/docs/RELEASE-0.6.0.md +46 -0
- package/docs/UI-WORKFLOW-V2-ACCEPTANCE.md +84 -0
- package/docs/UI-WORKFLOW-V2-MIGRATION.md +62 -0
- package/docs/UI-WORKFLOW-V2.md +174 -0
- package/docs/USER-GUIDE.md +25 -10
- package/lib/artifacts.js +239 -0
- package/lib/client.js +1620 -192
- package/lib/index.js +1 -1
- package/lib/web.js +214 -0
- package/lib/workflow-contract.js +263 -0
- package/lib/workflow.js +452 -0
- package/package.json +7 -2
package/lib/index.js
CHANGED
|
@@ -59,7 +59,7 @@ export function apply(ctx, config) {
|
|
|
59
59
|
|
|
60
60
|
// 3. web 半区(仅 web 组合存在;headless 组合无 webServer/webRuntime,inject 会失败)
|
|
61
61
|
try {
|
|
62
|
-
ctx.inject(['webServer', 'webRuntime', 'tools', 'skills', 'jobs', 'storageDomain'], (wctx) => {
|
|
62
|
+
ctx.inject(['webServer', 'webRuntime', 'tools', 'skills', 'jobs', 'storageDomain', 'fs'], (wctx) => {
|
|
63
63
|
const dispose = mountWebRoutes(wctx, { logger: ctx.logger, report, TOOL_NAME: TOOL_CLEAN, SKILL_NAME });
|
|
64
64
|
if (typeof dispose === 'function' && typeof ctx.effect === 'function') {
|
|
65
65
|
ctx.effect(() => () => dispose(), 'data-cleaning-agent: web routes');
|
package/lib/web.js
CHANGED
|
@@ -12,6 +12,9 @@ import { runSync, DataCleaningJobs } from './jobs.js';
|
|
|
12
12
|
import { QccBridgeError, QccHostBridge } from './qcc.js';
|
|
13
13
|
import { fingerprintRequest, G5RunStore } from './qcc-runs.js';
|
|
14
14
|
import { PHASE3_BATCH_LIMITS, Phase3BatchService, Phase3RunStore } from './qcc-phase3-batch.js';
|
|
15
|
+
import { publicWorkflowContract } from './workflow-contract.js';
|
|
16
|
+
import { DataCleaningWorkflowStore, WorkflowError } from './workflow.js';
|
|
17
|
+
import { ArtifactError, WorkflowArtifactStore } from './artifacts.js';
|
|
15
18
|
|
|
16
19
|
const MAX_BODY = 16 * 1024 * 1024; // 16 MiB 上传上限(MVP)
|
|
17
20
|
|
|
@@ -98,6 +101,20 @@ function writeQccError(res, error) {
|
|
|
98
101
|
writeJson(res, qccHttpStatus(code), { ok: false, ...payload });
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
function writeWorkflowError(res, error) {
|
|
105
|
+
if (error instanceof ArtifactError) {
|
|
106
|
+
return writeJson(res, error.status, { ok: false, code: error.code, message: error.message });
|
|
107
|
+
}
|
|
108
|
+
if (error instanceof WorkflowError) {
|
|
109
|
+
return writeJson(res, error.status, { ok: false, code: error.code, message: error.message });
|
|
110
|
+
}
|
|
111
|
+
return writeJson(res, 500, {
|
|
112
|
+
ok: false,
|
|
113
|
+
code: 'DC_WORKFLOW_INTERNAL',
|
|
114
|
+
message: 'Workflow request failed.',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
101
118
|
/** 从 JSON 协议解析上传:{ filename, content }。content 为字符串;xlsx 时为 base64。 */
|
|
102
119
|
async function parseUpload(body) {
|
|
103
120
|
let payload;
|
|
@@ -270,6 +287,9 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
270
287
|
report.qccBridgeMounted = true;
|
|
271
288
|
let state = null; // DataCleaningJobs,惰性初始化
|
|
272
289
|
let stateReady = null;
|
|
290
|
+
let workflow = null; // v2 工作流元数据,惰性初始化
|
|
291
|
+
let workflowReady = null;
|
|
292
|
+
const artifactStore = wctx.fs ? new WorkflowArtifactStore({ fs: wctx.fs }) : null;
|
|
273
293
|
|
|
274
294
|
const register = (path, handler) => {
|
|
275
295
|
disposers.push(server.register({ kind: 'prefix', path, handler }));
|
|
@@ -286,6 +306,15 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
286
306
|
return null;
|
|
287
307
|
};
|
|
288
308
|
|
|
309
|
+
const getWorkflow = () => {
|
|
310
|
+
if (!wctx.storageDomain) return null;
|
|
311
|
+
if (!workflowReady) {
|
|
312
|
+
workflow = new DataCleaningWorkflowStore({ storageDomain: wctx.storageDomain, logger });
|
|
313
|
+
workflowReady = workflow.init();
|
|
314
|
+
}
|
|
315
|
+
return workflowReady;
|
|
316
|
+
};
|
|
317
|
+
|
|
289
318
|
register('/data-cleaning/', (req, res) => {
|
|
290
319
|
if (!isTrusted(req)) { res.writeHead(403); return res.end('untrusted origin'); }
|
|
291
320
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
@@ -304,11 +333,193 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
304
333
|
skillListed: null,
|
|
305
334
|
jobs: Boolean(wctx.jobs),
|
|
306
335
|
storageDomain: Boolean(wctx.storageDomain),
|
|
336
|
+
workflowV2: Boolean(wctx.storageDomain),
|
|
337
|
+
durableArtifacts: Boolean(artifactStore),
|
|
338
|
+
artifactBinaryStrategy: artifactStore ? 'xlsx-base64-over-writeText' : 'unavailable',
|
|
307
339
|
qccBridge: qccBridge.capabilities(),
|
|
308
340
|
},
|
|
309
341
|
});
|
|
310
342
|
});
|
|
311
343
|
|
|
344
|
+
register('/data-cleaning/api/workflow/contract', (req, res) => {
|
|
345
|
+
if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
|
|
346
|
+
if (req.method !== 'GET') {
|
|
347
|
+
return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'GET required' });
|
|
348
|
+
}
|
|
349
|
+
writeJson(res, 200, {
|
|
350
|
+
ok: true,
|
|
351
|
+
marker: 'data-cleaning-workflow-v2',
|
|
352
|
+
contract: publicWorkflowContract(),
|
|
353
|
+
executesTools: false,
|
|
354
|
+
paidCalls: false,
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
register('/data-cleaning/api/workflow/tasks', async (req, res) => {
|
|
359
|
+
if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
|
|
360
|
+
try {
|
|
361
|
+
const ready = getWorkflow();
|
|
362
|
+
if (!ready) {
|
|
363
|
+
return writeJson(res, 503, {
|
|
364
|
+
ok: false,
|
|
365
|
+
code: 'DC_WORKFLOW_UNAVAILABLE',
|
|
366
|
+
message: 'storageDomain unavailable in this composition',
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
await ready;
|
|
370
|
+
const pathname = new URL(req.url ?? '/data-cleaning/api/workflow/tasks', 'http://127.0.0.1').pathname;
|
|
371
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
372
|
+
const tasksIndex = segments.indexOf('tasks');
|
|
373
|
+
const rest = tasksIndex >= 0 ? segments.slice(tasksIndex + 1) : [];
|
|
374
|
+
|
|
375
|
+
if (rest.length === 0) {
|
|
376
|
+
if (req.method === 'GET') {
|
|
377
|
+
return writeJson(res, 200, {
|
|
378
|
+
ok: true,
|
|
379
|
+
marker: 'data-cleaning-workflow-v2',
|
|
380
|
+
tasks: await workflow.list(),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
if (req.method === 'POST') {
|
|
384
|
+
const body = await readBody(req);
|
|
385
|
+
const payload = body.length ? JSON.parse(body.toString('utf8')) : {};
|
|
386
|
+
return writeJson(res, 201, {
|
|
387
|
+
ok: true,
|
|
388
|
+
marker: 'data-cleaning-workflow-v2',
|
|
389
|
+
task: await workflow.create(payload),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'GET or POST required' });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const taskId = decodeURIComponent(rest[0]);
|
|
396
|
+
if (rest.length === 1) {
|
|
397
|
+
if (req.method === 'GET') {
|
|
398
|
+
return writeJson(res, 200, {
|
|
399
|
+
ok: true,
|
|
400
|
+
marker: 'data-cleaning-workflow-v2',
|
|
401
|
+
task: await workflow.require(taskId),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
if (req.method === 'PATCH') {
|
|
405
|
+
const body = await readBody(req);
|
|
406
|
+
const payload = body.length ? JSON.parse(body.toString('utf8')) : {};
|
|
407
|
+
return writeJson(res, 200, {
|
|
408
|
+
ok: true,
|
|
409
|
+
marker: 'data-cleaning-workflow-v2',
|
|
410
|
+
task: await workflow.updateDraft(taskId, payload),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'GET or PATCH required' });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (rest[1] === 'artifacts') {
|
|
417
|
+
if (!artifactStore) {
|
|
418
|
+
return writeJson(res, 503, {
|
|
419
|
+
ok: false,
|
|
420
|
+
code: 'DC_ARTIFACT_UNAVAILABLE',
|
|
421
|
+
message: 'DSH fs service unavailable in this composition.',
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
const current = await workflow.require(taskId);
|
|
425
|
+
if (rest.length === 2 && req.method === 'GET') {
|
|
426
|
+
return writeJson(res, 200, {
|
|
427
|
+
ok: true,
|
|
428
|
+
marker: 'data-cleaning-artifacts-v1',
|
|
429
|
+
task: current,
|
|
430
|
+
artifacts: current.artifacts,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
if (rest.length === 2 && req.method === 'POST') {
|
|
434
|
+
const body = await readBody(req);
|
|
435
|
+
const payload = body.length ? JSON.parse(body.toString('utf8')) : {};
|
|
436
|
+
if (Number(payload.expectedRevision) !== current.revision) {
|
|
437
|
+
throw new WorkflowError('DC_WORKFLOW_REVISION_CONFLICT', 'Workflow task was updated by another session.', 409);
|
|
438
|
+
}
|
|
439
|
+
let readyTask = current;
|
|
440
|
+
if (['rules_confirmed', 'diagnosed'].includes(readyTask.state)) {
|
|
441
|
+
readyTask = await workflow.prepareLocalExport(taskId, {
|
|
442
|
+
expectedRevision: readyTask.revision,
|
|
443
|
+
summary: payload.summary,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
if (!['export_ready', 'partial'].includes(readyTask.state)) {
|
|
447
|
+
throw new WorkflowError('DC_WORKFLOW_EXPORT_STATE', 'Output is not ready for durable export.', 409);
|
|
448
|
+
}
|
|
449
|
+
const artifacts = await artifactStore.createBundle(taskId, {
|
|
450
|
+
rows: payload.rows,
|
|
451
|
+
headers: payload.headers,
|
|
452
|
+
exceptionRows: payload.exceptionRows,
|
|
453
|
+
baseName: payload.baseName || readyTask.title,
|
|
454
|
+
});
|
|
455
|
+
const completed = await workflow.recordExport(taskId, {
|
|
456
|
+
expectedRevision: readyTask.revision,
|
|
457
|
+
artifacts,
|
|
458
|
+
});
|
|
459
|
+
return writeJson(res, 201, {
|
|
460
|
+
ok: true,
|
|
461
|
+
marker: 'data-cleaning-artifacts-v1',
|
|
462
|
+
task: completed,
|
|
463
|
+
artifacts: completed.artifacts,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
if (rest.length === 3 && req.method === 'GET') {
|
|
467
|
+
const artifactId = decodeURIComponent(rest[2]);
|
|
468
|
+
const artifact = current.artifacts.find((item) => item.id === artifactId);
|
|
469
|
+
if (!artifact) throw new ArtifactError('DC_ARTIFACT_NOT_FOUND', 'Artifact not found for this task.', 404);
|
|
470
|
+
const bytes = await artifactStore.read(taskId, artifact);
|
|
471
|
+
const fallback = artifact.format === 'xlsx' ? 'export.xlsx' : 'export.csv';
|
|
472
|
+
const asciiName = String(artifact.fileName || fallback).replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
473
|
+
res.writeHead(200, {
|
|
474
|
+
'content-type': artifact.mediaType || 'application/octet-stream',
|
|
475
|
+
'content-length': String(bytes.length),
|
|
476
|
+
'content-disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(artifact.fileName || fallback)}`,
|
|
477
|
+
'cache-control': 'no-store',
|
|
478
|
+
'x-content-type-options': 'nosniff',
|
|
479
|
+
'referrer-policy': 'no-referrer',
|
|
480
|
+
});
|
|
481
|
+
return res.end(bytes);
|
|
482
|
+
}
|
|
483
|
+
return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'Artifact route requires GET or POST.' });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (rest.length !== 3 || rest[1] !== 'actions' || req.method !== 'POST') {
|
|
487
|
+
return writeJson(res, 404, { ok: false, code: 'DC_WORKFLOW_ROUTE', message: 'Workflow route not found.' });
|
|
488
|
+
}
|
|
489
|
+
const body = await readBody(req);
|
|
490
|
+
const payload = body.length ? JSON.parse(body.toString('utf8')) : {};
|
|
491
|
+
const action = rest[2];
|
|
492
|
+
const actions = {
|
|
493
|
+
upload: () => workflow.recordUpload(taskId, payload),
|
|
494
|
+
rules: () => workflow.confirmRules(taskId, payload),
|
|
495
|
+
quality: () => workflow.recordQuality(taskId, payload),
|
|
496
|
+
'match-start': () => workflow.startMatch(taskId, payload),
|
|
497
|
+
match: () => workflow.recordMatch(taskId, payload),
|
|
498
|
+
'enrich-start': () => workflow.startEnrichment(taskId, payload),
|
|
499
|
+
enrichment: () => workflow.recordEnrichment(taskId, payload),
|
|
500
|
+
'local-export-ready': () => workflow.prepareLocalExport(taskId, payload),
|
|
501
|
+
export: () => workflow.recordExport(taskId, payload),
|
|
502
|
+
'parse-failed': () => workflow.recordParseFailure(taskId, payload),
|
|
503
|
+
'authorization-required': () => workflow.requireAuthorization(taskId, payload),
|
|
504
|
+
fail: () => workflow.recordFailure(taskId, payload),
|
|
505
|
+
cancel: () => workflow.cancel(taskId, payload),
|
|
506
|
+
};
|
|
507
|
+
if (!actions[action]) {
|
|
508
|
+
return writeJson(res, 404, { ok: false, code: 'DC_WORKFLOW_ACTION', message: `Unknown workflow action: ${action}` });
|
|
509
|
+
}
|
|
510
|
+
return writeJson(res, 200, {
|
|
511
|
+
ok: true,
|
|
512
|
+
marker: 'data-cleaning-workflow-v2',
|
|
513
|
+
task: await actions[action](),
|
|
514
|
+
});
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (error instanceof SyntaxError) {
|
|
517
|
+
return writeJson(res, 400, { ok: false, code: 'DC_BAD_JSON', message: 'Request body must be valid JSON.' });
|
|
518
|
+
}
|
|
519
|
+
return writeWorkflowError(res, error);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
312
523
|
register('/data-cleaning/api/g5/capabilities', (req, res) => {
|
|
313
524
|
if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
|
|
314
525
|
writeJson(res, 200, {
|
|
@@ -633,6 +844,8 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
633
844
|
kind,
|
|
634
845
|
summary: result.summary,
|
|
635
846
|
rowCount: result.rows.length,
|
|
847
|
+
headers: headers.length ? headers : result.rows[0] ? Object.keys(result.rows[0]) : [],
|
|
848
|
+
rows: result.rows,
|
|
636
849
|
csv,
|
|
637
850
|
downloadName: kind === 'clean' ? 'cleaned.csv' : kind === 'complete' ? 'completed.csv' : null,
|
|
638
851
|
});
|
|
@@ -687,6 +900,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
687
900
|
|
|
688
901
|
return () => {
|
|
689
902
|
if (state) { state.dispose().catch(() => {}); }
|
|
903
|
+
if (workflow) { workflow.dispose().catch(() => {}); }
|
|
690
904
|
for (const dispose of disposers) dispose();
|
|
691
905
|
};
|
|
692
906
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数据清洗补全智能体 v2 工作流契约。
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:
|
|
5
|
+
* - 业务主流程固定为五步;质量体检与任务历史是横向能力。
|
|
6
|
+
* - 匹配结果只表达状态与可审计依据,不生成无法验证的“置信度”。
|
|
7
|
+
* - 历史、人员、招投标三域不属于当前版本字段目录。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const WORKFLOW_SCHEMA_VERSION = 2;
|
|
11
|
+
|
|
12
|
+
export const WORKFLOW_STAGES = Object.freeze([
|
|
13
|
+
Object.freeze({ id: 'upload', label: '上传数据', order: 1 }),
|
|
14
|
+
Object.freeze({ id: 'rules', label: '规则确认', order: 2 }),
|
|
15
|
+
Object.freeze({ id: 'match', label: '数据匹配', order: 3 }),
|
|
16
|
+
Object.freeze({ id: 'enrich', label: '清洗补全', order: 4 }),
|
|
17
|
+
Object.freeze({ id: 'download', label: '下载数据', order: 5 }),
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export const WORKFLOW_STATES = Object.freeze([
|
|
21
|
+
'draft',
|
|
22
|
+
'uploaded',
|
|
23
|
+
'rules_confirmed',
|
|
24
|
+
'diagnosed',
|
|
25
|
+
'matching',
|
|
26
|
+
'review_required',
|
|
27
|
+
'matched',
|
|
28
|
+
'enriching',
|
|
29
|
+
'export_ready',
|
|
30
|
+
'completed',
|
|
31
|
+
'parse_failed',
|
|
32
|
+
'authorization_required',
|
|
33
|
+
'partial',
|
|
34
|
+
'failed',
|
|
35
|
+
'cancelled',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
export const TERMINAL_WORKFLOW_STATES = Object.freeze(['completed', 'cancelled']);
|
|
39
|
+
|
|
40
|
+
export const SOURCE_TYPES = Object.freeze(['text', 'csv', 'xlsx', 'json', 'image']);
|
|
41
|
+
|
|
42
|
+
export const MATCH_STATUSES = Object.freeze([
|
|
43
|
+
'exact',
|
|
44
|
+
'candidate',
|
|
45
|
+
'confirmed',
|
|
46
|
+
'unresolved',
|
|
47
|
+
'failed',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export const MATCH_ANCHORS = Object.freeze(['company_name', 'credit_no', 'reg_no']);
|
|
51
|
+
|
|
52
|
+
export const FIELD_CATALOG = Object.freeze([
|
|
53
|
+
Object.freeze({
|
|
54
|
+
id: 'identity',
|
|
55
|
+
label: '基础工商信息',
|
|
56
|
+
fields: Object.freeze([
|
|
57
|
+
Object.freeze({ id: 'company_name', label: '企业名称', inputAnchor: true, defaultSelected: true }),
|
|
58
|
+
Object.freeze({ id: 'credit_no', label: '统一社会信用代码', inputAnchor: true, defaultSelected: true }),
|
|
59
|
+
Object.freeze({ id: 'reg_no', label: '注册号', inputAnchor: true }),
|
|
60
|
+
Object.freeze({ id: 'org_no', label: '组织机构代码' }),
|
|
61
|
+
Object.freeze({ id: 'reg_status', label: '登记状态', defaultSelected: true }),
|
|
62
|
+
Object.freeze({ id: 'legal_rep', label: '法定代表人', defaultSelected: true }),
|
|
63
|
+
Object.freeze({ id: 'reg_capital', label: '注册资本', defaultSelected: true }),
|
|
64
|
+
Object.freeze({ id: 'paid_capital', label: '实缴资本' }),
|
|
65
|
+
Object.freeze({ id: 'establish_date', label: '成立日期', defaultSelected: true }),
|
|
66
|
+
Object.freeze({ id: 'company_type', label: '企业类型' }),
|
|
67
|
+
Object.freeze({ id: 'registration_authority', label: '登记机关' }),
|
|
68
|
+
Object.freeze({ id: 'former_name', label: '曾用名' }),
|
|
69
|
+
Object.freeze({ id: 'english_name', label: '英文名' }),
|
|
70
|
+
]),
|
|
71
|
+
}),
|
|
72
|
+
Object.freeze({
|
|
73
|
+
id: 'contact',
|
|
74
|
+
label: '地址与联系方式',
|
|
75
|
+
fields: Object.freeze([
|
|
76
|
+
Object.freeze({ id: 'registered_address', label: '注册地址', defaultSelected: true }),
|
|
77
|
+
Object.freeze({ id: 'province', label: '省份地区', matchAuxiliary: true }),
|
|
78
|
+
Object.freeze({ id: 'city', label: '城市', matchAuxiliary: true }),
|
|
79
|
+
Object.freeze({ id: 'district', label: '区县' }),
|
|
80
|
+
Object.freeze({ id: 'phone', label: '电话', matchAuxiliary: true }),
|
|
81
|
+
Object.freeze({ id: 'email', label: '邮箱' }),
|
|
82
|
+
Object.freeze({ id: 'website', label: '官网' }),
|
|
83
|
+
]),
|
|
84
|
+
}),
|
|
85
|
+
Object.freeze({
|
|
86
|
+
id: 'operation',
|
|
87
|
+
label: '经营信息',
|
|
88
|
+
fields: Object.freeze([
|
|
89
|
+
Object.freeze({ id: 'business_scope', label: '经营范围' }),
|
|
90
|
+
Object.freeze({ id: 'industry_category', label: '国标行业' }),
|
|
91
|
+
Object.freeze({ id: 'industry_large', label: '一级行业' }),
|
|
92
|
+
Object.freeze({ id: 'industry_middle', label: '二级行业' }),
|
|
93
|
+
Object.freeze({ id: 'operating_period', label: '营业期限' }),
|
|
94
|
+
Object.freeze({ id: 'company_size', label: '企业规模' }),
|
|
95
|
+
Object.freeze({ id: 'company_profile', label: '企业简介' }),
|
|
96
|
+
]),
|
|
97
|
+
}),
|
|
98
|
+
Object.freeze({
|
|
99
|
+
id: 'risk',
|
|
100
|
+
label: '风险摘要',
|
|
101
|
+
capability: 'qcc.risk',
|
|
102
|
+
fields: Object.freeze([
|
|
103
|
+
Object.freeze({ id: 'risk_summary', label: '风险摘要', capability: 'qcc.risk' }),
|
|
104
|
+
Object.freeze({ id: 'operating_exception', label: '经营异常摘要', capability: 'qcc.risk' }),
|
|
105
|
+
Object.freeze({ id: 'administrative_penalty', label: '行政处罚摘要', capability: 'qcc.risk' }),
|
|
106
|
+
]),
|
|
107
|
+
}),
|
|
108
|
+
Object.freeze({
|
|
109
|
+
id: 'ipr',
|
|
110
|
+
label: '知识产权摘要',
|
|
111
|
+
capability: 'qcc.ipr',
|
|
112
|
+
fields: Object.freeze([
|
|
113
|
+
Object.freeze({ id: 'trademark_summary', label: '商标摘要', capability: 'qcc.ipr' }),
|
|
114
|
+
Object.freeze({ id: 'patent_summary', label: '专利摘要', capability: 'qcc.ipr' }),
|
|
115
|
+
Object.freeze({ id: 'software_copyright_summary', label: '软件著作权摘要', capability: 'qcc.ipr' }),
|
|
116
|
+
]),
|
|
117
|
+
}),
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const FIELD_IDS = new Set(FIELD_CATALOG.flatMap((group) => group.fields.map((field) => field.id)));
|
|
121
|
+
const STAGE_IDS = new Set(WORKFLOW_STAGES.map((stage) => stage.id));
|
|
122
|
+
const STATE_IDS = new Set(WORKFLOW_STATES);
|
|
123
|
+
|
|
124
|
+
const TRANSITIONS = Object.freeze({
|
|
125
|
+
draft: ['uploaded', 'parse_failed', 'cancelled'],
|
|
126
|
+
uploaded: ['draft', 'rules_confirmed', 'parse_failed', 'cancelled'],
|
|
127
|
+
rules_confirmed: ['diagnosed', 'matching', 'matched', 'review_required', 'export_ready', 'authorization_required', 'failed', 'cancelled'],
|
|
128
|
+
diagnosed: ['matching', 'matched', 'review_required', 'export_ready', 'authorization_required', 'failed', 'cancelled'],
|
|
129
|
+
matching: ['matched', 'review_required', 'authorization_required', 'partial', 'failed', 'cancelled'],
|
|
130
|
+
review_required: ['matching', 'matched', 'authorization_required', 'partial', 'failed', 'cancelled'],
|
|
131
|
+
matched: ['enriching', 'export_ready', 'authorization_required', 'partial', 'failed', 'cancelled'],
|
|
132
|
+
enriching: ['export_ready', 'review_required', 'authorization_required', 'partial', 'failed', 'cancelled'],
|
|
133
|
+
export_ready: ['enriching', 'completed', 'failed', 'cancelled'],
|
|
134
|
+
parse_failed: ['draft', 'uploaded', 'cancelled'],
|
|
135
|
+
authorization_required: ['matching', 'enriching', 'failed', 'cancelled'],
|
|
136
|
+
partial: ['review_required', 'enriching', 'export_ready', 'authorization_required', 'completed', 'failed', 'cancelled'],
|
|
137
|
+
failed: ['draft', 'uploaded', 'rules_confirmed', 'diagnosed', 'matching', 'enriching', 'cancelled'],
|
|
138
|
+
completed: [],
|
|
139
|
+
cancelled: [],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export class WorkflowContractError extends Error {
|
|
143
|
+
constructor(code, message) {
|
|
144
|
+
super(message);
|
|
145
|
+
this.name = 'WorkflowContractError';
|
|
146
|
+
this.code = code;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function text(value, max = 160) {
|
|
151
|
+
return String(value ?? '').trim().slice(0, max);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function uniqueStrings(values, max = 64) {
|
|
155
|
+
if (!Array.isArray(values)) return [];
|
|
156
|
+
return [...new Set(values.map((value) => text(value)).filter(Boolean))].slice(0, max);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function normalizeMappings(value) {
|
|
160
|
+
if (!Array.isArray(value)) return [];
|
|
161
|
+
return value.slice(0, 128).map((mapping) => ({
|
|
162
|
+
sourceField: text(mapping?.sourceField),
|
|
163
|
+
targetField: text(mapping?.targetField),
|
|
164
|
+
})).filter((mapping) => mapping.sourceField && FIELD_IDS.has(mapping.targetField));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function validateMappings(value) {
|
|
168
|
+
if (!Array.isArray(value) || value.length > 128) {
|
|
169
|
+
throw new WorkflowContractError('DC_WORKFLOW_MAPPING_INVALID', 'Field mappings must be an array with at most 128 entries.');
|
|
170
|
+
}
|
|
171
|
+
const mappings = normalizeMappings(value);
|
|
172
|
+
if (mappings.length !== value.length) {
|
|
173
|
+
throw new WorkflowContractError('DC_WORKFLOW_MAPPING_INVALID', 'Every mapping needs a source field and a supported target field.');
|
|
174
|
+
}
|
|
175
|
+
if (mappings.length === 0) {
|
|
176
|
+
throw new WorkflowContractError('DC_WORKFLOW_MAPPING_REQUIRED', 'At least one valid field mapping is required.');
|
|
177
|
+
}
|
|
178
|
+
if (!mappings.some((mapping) => MATCH_ANCHORS.includes(mapping.targetField))) {
|
|
179
|
+
throw new WorkflowContractError(
|
|
180
|
+
'DC_WORKFLOW_ANCHOR_REQUIRED',
|
|
181
|
+
'Map at least one enterprise identity anchor: company name, unified social credit code, or registration number.',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const targets = new Set();
|
|
185
|
+
const sources = new Set();
|
|
186
|
+
for (const mapping of mappings) {
|
|
187
|
+
if (targets.has(mapping.targetField)) {
|
|
188
|
+
throw new WorkflowContractError('DC_WORKFLOW_DUPLICATE_MAPPING', `Duplicate target mapping: ${mapping.targetField}`);
|
|
189
|
+
}
|
|
190
|
+
if (sources.has(mapping.sourceField)) {
|
|
191
|
+
throw new WorkflowContractError('DC_WORKFLOW_DUPLICATE_MAPPING', `Duplicate source mapping: ${mapping.sourceField}`);
|
|
192
|
+
}
|
|
193
|
+
targets.add(mapping.targetField);
|
|
194
|
+
sources.add(mapping.sourceField);
|
|
195
|
+
}
|
|
196
|
+
return mappings;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function normalizeFieldSelection(value) {
|
|
200
|
+
return uniqueStrings(value).filter((field) => FIELD_IDS.has(field));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function normalizeWorkflowDraft(value = {}) {
|
|
204
|
+
const objectives = uniqueStrings(value.objectives, 16).filter((item) => (
|
|
205
|
+
['clean_name', 'deduplicate', 'validate_identity', 'complete_fields'].includes(item)
|
|
206
|
+
));
|
|
207
|
+
return {
|
|
208
|
+
title: text(value.title, 120) || '未命名数据清洗补全任务',
|
|
209
|
+
objectives,
|
|
210
|
+
fieldSelection: normalizeFieldSelection(value.fieldSelection),
|
|
211
|
+
mappings: normalizeMappings(value.mappings),
|
|
212
|
+
matchRules: {
|
|
213
|
+
normalizeNames: value.matchRules?.normalizeNames !== false,
|
|
214
|
+
preferCreditNo: value.matchRules?.preferCreditNo !== false,
|
|
215
|
+
deduplicate: value.matchRules?.deduplicate !== false,
|
|
216
|
+
manualReviewAmbiguous: value.matchRules?.manualReviewAmbiguous !== false,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function canTransition(from, to) {
|
|
222
|
+
if (!STATE_IDS.has(from) || !STATE_IDS.has(to)) return false;
|
|
223
|
+
return TRANSITIONS[from].includes(to);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function assertWorkflowTransition(from, to) {
|
|
227
|
+
if (!canTransition(from, to)) {
|
|
228
|
+
throw new WorkflowContractError('DC_WORKFLOW_TRANSITION', `Invalid workflow transition: ${from} -> ${to}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function publicWorkflowContract() {
|
|
233
|
+
return {
|
|
234
|
+
schemaVersion: WORKFLOW_SCHEMA_VERSION,
|
|
235
|
+
stages: WORKFLOW_STAGES,
|
|
236
|
+
states: WORKFLOW_STATES,
|
|
237
|
+
terminalStates: TERMINAL_WORKFLOW_STATES,
|
|
238
|
+
sourceTypes: SOURCE_TYPES,
|
|
239
|
+
matchStatuses: MATCH_STATUSES,
|
|
240
|
+
matchAnchors: MATCH_ANCHORS,
|
|
241
|
+
fieldCatalog: FIELD_CATALOG,
|
|
242
|
+
crossCuttingCapabilities: [
|
|
243
|
+
{ id: 'prompt', label: '任务设置' },
|
|
244
|
+
{ id: 'profile', label: '质量体检' },
|
|
245
|
+
{ id: 'history', label: '任务历史' },
|
|
246
|
+
],
|
|
247
|
+
privacy: {
|
|
248
|
+
persisted: ['task metadata', 'numeric summaries', 'artifact references'],
|
|
249
|
+
notPersisted: ['raw rows and enterprise lists', 'QCC response payloads', 'candidate details'],
|
|
250
|
+
},
|
|
251
|
+
deferredDomains: ['history', 'person', 'tender'],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function assertWorkflowRecordShape(record) {
|
|
256
|
+
if (!record || record.schemaVersion !== WORKFLOW_SCHEMA_VERSION) {
|
|
257
|
+
throw new WorkflowContractError('DC_WORKFLOW_SCHEMA', 'Unsupported workflow record schema.');
|
|
258
|
+
}
|
|
259
|
+
if (!STAGE_IDS.has(record.stage) || !STATE_IDS.has(record.state)) {
|
|
260
|
+
throw new WorkflowContractError('DC_WORKFLOW_SCHEMA', 'Workflow record contains an invalid stage or state.');
|
|
261
|
+
}
|
|
262
|
+
return record;
|
|
263
|
+
}
|