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/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
+ }