@chenchaolong/plugin-trade-compliance-workbench 1.0.64 → 1.0.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +22 -3
  2. package/dist/index.d.ts +65 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/lib/adapters/translation.provider.d.ts +24 -0
  5. package/dist/lib/adapters/translation.provider.d.ts.map +1 -1
  6. package/dist/lib/adapters/translation.provider.js +279 -0
  7. package/dist/lib/adapters/translation.provider.js.map +1 -1
  8. package/dist/lib/catalog-import/catalog-import.pipeline.d.ts +1 -1
  9. package/dist/lib/catalog-import/catalog-import.pipeline.d.ts.map +1 -1
  10. package/dist/lib/catalog-import/catalog-import.pipeline.js +8 -5
  11. package/dist/lib/catalog-import/catalog-import.pipeline.js.map +1 -1
  12. package/dist/lib/catalog-import/confidence.js +8 -1
  13. package/dist/lib/catalog-import/confidence.js.map +1 -1
  14. package/dist/lib/catalog-import/docx.parser.d.ts.map +1 -1
  15. package/dist/lib/catalog-import/docx.parser.js +15 -1
  16. package/dist/lib/catalog-import/docx.parser.js.map +1 -1
  17. package/dist/lib/constants.d.ts +1 -0
  18. package/dist/lib/constants.d.ts.map +1 -1
  19. package/dist/lib/constants.js +1 -0
  20. package/dist/lib/constants.js.map +1 -1
  21. package/dist/lib/domain/catalog-localization.d.ts +1 -1
  22. package/dist/lib/domain/catalog-localization.d.ts.map +1 -1
  23. package/dist/lib/domain/catalog-localization.js +3 -1
  24. package/dist/lib/domain/catalog-localization.js.map +1 -1
  25. package/dist/lib/import-jobs/agent-handoff.dispatcher.d.ts.map +1 -1
  26. package/dist/lib/import-jobs/agent-handoff.dispatcher.js +6 -2
  27. package/dist/lib/import-jobs/agent-handoff.dispatcher.js.map +1 -1
  28. package/dist/lib/import-jobs/import-command.builder.js +1 -1
  29. package/dist/lib/import-jobs/import-command.builder.js.map +1 -1
  30. package/dist/lib/trade-compliance.config.d.ts +65 -0
  31. package/dist/lib/trade-compliance.config.d.ts.map +1 -1
  32. package/dist/lib/trade-compliance.config.js +14 -3
  33. package/dist/lib/trade-compliance.config.js.map +1 -1
  34. package/dist/lib/workbench.middleware.d.ts +34 -1
  35. package/dist/lib/workbench.middleware.d.ts.map +1 -1
  36. package/dist/lib/workbench.middleware.js +29 -2
  37. package/dist/lib/workbench.middleware.js.map +1 -1
  38. package/dist/lib/workbench.service.d.ts +8 -0
  39. package/dist/lib/workbench.service.d.ts.map +1 -1
  40. package/dist/lib/workbench.service.js +138 -21
  41. package/dist/lib/workbench.service.js.map +1 -1
  42. package/package.json +6 -1
  43. package/scripts/verify-catalog-e2e.mjs +250 -0
  44. package/scripts/verify-catalog-samples.mjs +61 -0
  45. package/scripts/verify-qwen-translation.mjs +46 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenchaolong/plugin-trade-compliance-workbench",
3
- "version": "1.0.64",
3
+ "version": "1.0.66",
4
4
  "description": "外贸合规工作台新版组织级插件",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
@@ -17,6 +17,9 @@
17
17
  "files": [
18
18
  "dist",
19
19
  "scripts/seed-demo-data.mjs",
20
+ "scripts/verify-catalog-e2e.mjs",
21
+ "scripts/verify-catalog-samples.mjs",
22
+ "scripts/verify-qwen-translation.mjs",
20
23
  "!**/*.tsbuildinfo"
21
24
  ],
22
25
  "scripts": {
@@ -33,6 +36,8 @@
33
36
  "test": "vitest run",
34
37
  "test:watch": "vitest",
35
38
  "verify:catalog-samples": "pnpm build && node scripts/verify-catalog-samples.mjs",
39
+ "verify:catalog-e2e": "pnpm build && node scripts/verify-catalog-e2e.mjs",
40
+ "verify:qwen-translation": "pnpm build && node scripts/verify-qwen-translation.mjs",
36
41
  "verify:flow-local": "node scripts/verify-flow-local.mjs",
37
42
  "verify:home-layout": "node scripts/visual-check.mjs --home-layout-only",
38
43
  "verify:runtime-local": "node scripts/verify-runtime-local.mjs",
@@ -0,0 +1,250 @@
1
+ import 'reflect-metadata'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { basename } from 'node:path'
4
+ import { readFile } from 'node:fs/promises'
5
+ import { DataSource, IsNull } from 'typeorm'
6
+ import {
7
+ TRADE_COMPLIANCE_ENTITIES,
8
+ CatalogReviewBlock,
9
+ ControlledCatalogBatch,
10
+ ControlledGoodsRecord,
11
+ ImportTask,
12
+ SanctionCatalogBatch,
13
+ SanctionedCompanyAlias,
14
+ SanctionedCompanyRecord
15
+ } from '../dist/lib/entities/index.js'
16
+ import { TranslationProvider } from '../dist/lib/adapters/translation.provider.js'
17
+ import { CatalogImportPipeline } from '../dist/lib/catalog-import/catalog-import.pipeline.js'
18
+ import { parseCatalogDocument } from '../dist/lib/catalog-import/catalog-document.parser.js'
19
+ import { TradeComplianceWorkbenchService } from '../dist/lib/workbench.service.js'
20
+
21
+ const postgresUrl = process.env.TRADE_COMPLIANCE_TEST_POSTGRES_URL?.trim()
22
+ const apiKey = process.env.TRADE_COMPLIANCE_TRANSLATION_API_KEY?.trim()
23
+ const paths = process.argv.slice(2).filter(value => value !== '--')
24
+ if (!postgresUrl || !apiKey || paths.length !== 2) {
25
+ console.error('Usage: TRADE_COMPLIANCE_TEST_POSTGRES_URL=... TRADE_COMPLIANCE_TRANSLATION_API_KEY=... pnpm verify:catalog-e2e -- <controlled.pdf> <sanctions.docx>')
26
+ process.exit(2)
27
+ }
28
+
29
+ const [controlledPath, sanctionPath] = paths
30
+ const schema = `tcw_e2e_${process.pid}_${randomUUID().replaceAll('-', '')}`
31
+ const scope = {
32
+ tenantId: '10000000-0000-4000-8000-000000000001',
33
+ organizationId: '20000000-0000-4000-8000-000000000001',
34
+ userId: '30000000-0000-4000-8000-000000000001'
35
+ }
36
+ const dbScope = { tenantId: scope.tenantId, organizationId: scope.organizationId }
37
+ const targetAssistantId = '40000000-0000-4000-8000-000000000001'
38
+ const admin = new DataSource({ type: 'postgres', url: postgresUrl })
39
+ let dataSource
40
+ const startedAt = Date.now()
41
+
42
+ try {
43
+ await admin.initialize()
44
+ await admin.query(`CREATE SCHEMA "${schema}"`)
45
+ dataSource = new DataSource({
46
+ type: 'postgres', url: postgresUrl, schema, entities: [...TRADE_COMPLIANCE_ENTITIES],
47
+ synchronize: true, extra: { application_name: `tcw-catalog-e2e-${process.pid}` }
48
+ })
49
+ await dataSource.initialize()
50
+
51
+ const config = { translation: {
52
+ endpoint: process.env.TRADE_COMPLIANCE_TRANSLATION_ENDPOINT || 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
53
+ apiKey,
54
+ model: process.env.TRADE_COMPLIANCE_TRANSLATION_MODEL || 'qwen3.6-flash',
55
+ targetLanguage: 'zh-CN', batchSize: 10, concurrency: 5, timeoutMs: 120_000
56
+ } }
57
+ const qwen = new TranslationProvider({ resolve: () => config })
58
+ const observedTranslationStages = []
59
+ const translator = {
60
+ toChinese: (...args) => qwen.toChinese(...args),
61
+ toEnglish: (...args) => qwen.toEnglish(...args),
62
+ translateBatch: async (...args) => {
63
+ const tasks = await dataSource.getRepository(ImportTask).find({ where: { ...dbScope, deletedAt: IsNull() } })
64
+ observedTranslationStages.push(...tasks.filter(task => task.progress?.stage === 'TRANSLATING').map(task => task.id))
65
+ const translationStartedAt = Date.now()
66
+ const result = await qwen.translateBatch(...args)
67
+ console.error(JSON.stringify({ phase: 'translation', requested: args[0].length, returned: result.length, elapsedMs: Date.now() - translationStartedAt }))
68
+ return result
69
+ }
70
+ }
71
+ const service = new TradeComplianceWorkbenchService(dataSource, undefined, translator)
72
+ const submitAutomaticReview = service.submitCatalogReviewBatchInternal.bind(service)
73
+ service.submitCatalogReviewBatchInternal = async (...args) => {
74
+ const result = await submitAutomaticReview(...args)
75
+ if (args[4] === true) {
76
+ const reasons = result.errors.reduce((counts, error) => {
77
+ for (const reason of error.reasons) counts[reason] = (counts[reason] ?? 0) + 1
78
+ return counts
79
+ }, {})
80
+ console.error(JSON.stringify({ phase: 'automatic-review', submitted: args[3].length, resolved: result.resolved, remaining: result.remaining, reasons }))
81
+ }
82
+ return result
83
+ }
84
+
85
+ const controlled = await runDocument({
86
+ path: controlledPath, format: 'PDF', kind: 'CONTROLLED', documentType: 'CONTROLLED_CATALOG',
87
+ executionInput: { actionKey: 'upload_controlled_catalog', catalogType: 'DUAL_USE' }, service, dataSource
88
+ })
89
+ const sanction = await runDocument({
90
+ path: sanctionPath, format: 'DOCX', kind: 'SANCTION', documentType: 'SANCTION_CATALOG',
91
+ executionInput: { actionKey: 'upload_sanction_catalog' }, service, dataSource
92
+ })
93
+
94
+ assert(controlled.task.status === 'AWAITING_REVIEW', `controlled status before review: ${controlled.task.status}`)
95
+ assert(controlled.task.progress?.reviewPendingCount === controlled.parsed.reviewBlocks.length, 'controlled pending review count mismatch')
96
+ assert(sanction.task.status === 'AWAITING_REVIEW', `sanction status before review: ${sanction.task.status}`)
97
+ assert(sanction.task.progress?.reviewPendingCount === 1, `sanction pending review count: ${sanction.task.progress?.reviewPendingCount}`)
98
+ assert(observedTranslationStages.includes(sanction.task.id), 'sanction translation stage was not persisted before the Qwen request')
99
+ assert(sanction.observerSummaries.at(-1)?.reviewCount === 1, 'sanction observer did not receive the remaining review count')
100
+
101
+ const controlledBefore = await dataSource.getRepository(ControlledCatalogBatch).findOneByOrFail({ id: controlled.task.resultEntityId })
102
+ const sanctionBefore = await dataSource.getRepository(SanctionCatalogBatch).findOneByOrFail({ id: sanction.task.resultEntityId })
103
+ assert(controlledBefore.successCount > 0, 'controlled catalog persisted no valid rows')
104
+ assert(sanctionBefore.successCount === 167, `sanction translated success count: ${sanctionBefore.successCount}`)
105
+
106
+ const sanctionRows = await dataSource.getRepository(SanctionedCompanyRecord).find({ where: { batchId: sanctionBefore.id, deletedAt: IsNull() } })
107
+ const sanctionByName = new Map(sanctionRows.map(row => [row.companyName, row]))
108
+ const translatedBlocks = sanction.parsed.reviewBlocks.filter(block => block.reasons.length === 1 && block.reasons[0] === 'TRANSLATION_REQUIRED')
109
+ assert(translatedBlocks.length === 167, `sanction translation block count: ${translatedBlocks.length}`)
110
+ for (const block of translatedBlocks) {
111
+ const companyName = String(block.payload.companyName ?? '')
112
+ const row = sanctionByName.get(companyName)
113
+ assert(row, `translated sanction company missing: ${companyName}`)
114
+ assert(row.sourceLocation && row.sourceContent, `sanction source evidence missing: ${companyName}`)
115
+ for (const field of ['sanctionReason', 'remarks']) {
116
+ const source = block.payload[field]
117
+ const translated = row[field]
118
+ if (typeof source === 'string' && /[A-Za-zА-Яа-яЁё]/u.test(source)) {
119
+ assert(typeof translated === 'string' && /\p{Script=Han}/u.test(translated), `${field} was not translated for ${companyName}`)
120
+ }
121
+ }
122
+ }
123
+
124
+ const expectedAliases = translatedBlocks.reduce((total, block) => total + (Array.isArray(block.payload.aliases) ? block.payload.aliases.length : 0), 0)
125
+ const aliasCount = await dataSource.getRepository(SanctionedCompanyAlias).count({ where: { deletedAt: IsNull() } })
126
+ assert(aliasCount === expectedAliases, `sanction alias count: expected ${expectedAliases}, received ${aliasCount}`)
127
+
128
+ const controlledRows = await dataSource.getRepository(ControlledGoodsRecord).find({ where: { batchId: controlledBefore.id, deletedAt: IsNull() } })
129
+ assert(controlledRows.length === controlledBefore.successCount, 'controlled batch and record counts differ')
130
+ assert(controlledRows.every(row => row.sourceLocation && row.sourceContent), 'controlled source evidence is incomplete')
131
+ assert(controlledRows.every(row => row.translationSource === 'SOURCE_CHINESE'), 'controlled Chinese source rows have an incorrect translation source')
132
+
133
+ const controlledRejected = await rejectRemainingReviews(service, controlled.task.id)
134
+ const sanctionRejected = await rejectRemainingReviews(service, sanction.task.id)
135
+ assert(controlledRejected === controlled.parsed.reviewBlocks.length, 'controlled review rejection count mismatch')
136
+ assert(sanctionRejected === 1, 'sanction review rejection count mismatch')
137
+
138
+ const controlledFinalTask = await dataSource.getRepository(ImportTask).findOneByOrFail({ id: controlled.task.id })
139
+ const sanctionFinalTask = await dataSource.getRepository(ImportTask).findOneByOrFail({ id: sanction.task.id })
140
+ const controlledFinalBatch = await dataSource.getRepository(ControlledCatalogBatch).findOneByOrFail({ id: controlledBefore.id })
141
+ const sanctionFinalBatch = await dataSource.getRepository(SanctionCatalogBatch).findOneByOrFail({ id: sanctionBefore.id })
142
+ assert(controlledFinalTask.status === 'SUCCEEDED' && controlledFinalBatch.lifecycleStatus === 'ACTIVE', 'controlled catalog was not published')
143
+ assert(sanctionFinalTask.status === 'SUCCEEDED' && sanctionFinalBatch.lifecycleStatus === 'ACTIVE', 'sanction catalog was not published')
144
+ const unresolved = await dataSource.getRepository(CatalogReviewBlock).count({ where: { ...dbScope, status: 'PENDING' } }) +
145
+ await dataSource.getRepository(CatalogReviewBlock).count({ where: { ...dbScope, status: 'LEASED' } })
146
+ assert(unresolved === 0, `unresolved review blocks remain: ${unresolved}`)
147
+
148
+ console.log(JSON.stringify({
149
+ success: true,
150
+ elapsedMs: Date.now() - startedAt,
151
+ model: config.translation.model,
152
+ controlled: {
153
+ parsedCandidates: controlled.parsed.accepted.length,
154
+ reviewBlocks: controlled.parsed.reviewBlocks.length,
155
+ successCount: controlledFinalBatch.successCount,
156
+ duplicateCount: controlledFinalBatch.duplicateCount,
157
+ invalidCount: controlledFinalBatch.invalidCount,
158
+ lifecycleStatus: controlledFinalBatch.lifecycleStatus
159
+ },
160
+ sanction: {
161
+ translatedRecords: translatedBlocks.length,
162
+ successCount: sanctionFinalBatch.successCount,
163
+ aliases: aliasCount,
164
+ rejectedReviews: sanctionRejected,
165
+ lifecycleStatus: sanctionFinalBatch.lifecycleStatus
166
+ }
167
+ }, null, 2))
168
+ } finally {
169
+ if (dataSource?.isInitialized) await dataSource.destroy()
170
+ if (admin.isInitialized) {
171
+ await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`)
172
+ await admin.destroy()
173
+ }
174
+ }
175
+
176
+ async function runDocument(input) {
177
+ const buffer = await readFile(input.path)
178
+ const parsed = await parseCatalogDocument({ format: input.format, kind: input.kind, buffer })
179
+ assert(parsed.mode === 'FAST_PIPELINE', `${input.kind} parser mode: ${parsed.mode}`)
180
+ const taskRepo = input.dataSource.getRepository(ImportTask)
181
+ const task = await taskRepo.save(taskRepo.create({
182
+ ...scope,
183
+ documentType: input.documentType,
184
+ sourceFileId: randomUUID(),
185
+ sourceFileName: basename(input.path),
186
+ originalSourceFileName: null,
187
+ fileNameEncodingStatus: 'UNCHANGED',
188
+ sourceFileFormat: input.format,
189
+ sourcePlatformFileId: null,
190
+ sourceWorkspacePath: null,
191
+ status: 'AI_PARSING',
192
+ targetAssistantId,
193
+ queueJobId: null,
194
+ executionInput: input.executionInput,
195
+ executionCommand: null,
196
+ commandDispatchedAt: null,
197
+ progress: null,
198
+ observerConversationId: null,
199
+ handoffCallbackSequence: 0,
200
+ claimAttemptToken: randomUUID(),
201
+ resultPayloadVersion: null,
202
+ resultPayload: null,
203
+ resultEntityId: null,
204
+ resultProcessingClaimId: null,
205
+ resultProcessingClaimExpiresAt: null,
206
+ currentReanalysisRunId: null,
207
+ reviewSavedAt: null,
208
+ retryOfTaskId: null,
209
+ errorCode: null,
210
+ errorMessage: null,
211
+ queuedAt: new Date(),
212
+ startedAt: new Date(),
213
+ completedAt: null,
214
+ canceledAt: null,
215
+ canceledById: null,
216
+ deletedAt: null,
217
+ deletedById: null,
218
+ deleteOperation: null,
219
+ machineElapsedMs: null,
220
+ createdById: scope.userId
221
+ }))
222
+ const observerSummaries = []
223
+ const pipeline = new CatalogImportPipeline(
224
+ input.service,
225
+ { read: async fileId => {
226
+ assert(fileId === task.sourceFileId, `unexpected source file id: ${fileId}`)
227
+ return buffer
228
+ } },
229
+ async () => parsed,
230
+ async summary => { observerSummaries.push(summary) }
231
+ )
232
+ const outcome = await pipeline.process(scope, task)
233
+ assert(outcome.kind === 'COMPLETED', `${input.kind} pipeline outcome: ${outcome.kind}`)
234
+ return { parsed, task: await taskRepo.findOneByOrFail({ id: task.id }), observerSummaries }
235
+ }
236
+
237
+ async function rejectRemainingReviews(service, taskId) {
238
+ let rejected = 0
239
+ while (true) {
240
+ const leased = await service.nextCatalogReviewBatch(scope, taskId, 100)
241
+ if (!leased.leaseToken || leased.items.length === 0) return rejected
242
+ const result = await service.submitCatalogReviewBatch(scope, taskId, leased.leaseToken,
243
+ leased.items.map(item => ({ blockId: item.blockId, decision: 'REJECT' })))
244
+ rejected += result.rejected
245
+ }
246
+ }
247
+
248
+ function assert(condition, message) {
249
+ if (!condition) throw new Error(message)
250
+ }
@@ -0,0 +1,61 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { parseDocxCatalog } from '../dist/lib/catalog-import/docx.parser.js'
3
+ import { parsePdfCatalog } from '../dist/lib/catalog-import/pdf-layout.parser.js'
4
+
5
+ const [pdfPath, docxPath] = process.argv.slice(2).filter(argument => argument !== '--')
6
+ if (!pdfPath || !docxPath) {
7
+ console.error('Usage: pnpm verify:catalog-samples -- <controlled-catalog.pdf> <sanction-catalog.docx>')
8
+ process.exit(2)
9
+ }
10
+
11
+ const startedAt = Date.now()
12
+ const [pdf, docx] = await Promise.all([
13
+ parsePdfCatalog(await readFile(pdfPath)),
14
+ parseDocxCatalog({ buffer: await readFile(docxPath) })
15
+ ])
16
+
17
+ const docxCandidates = [
18
+ ...docx.accepted.map(candidate => candidate.values),
19
+ ...docx.reviewBlocks.map(block => block.payload)
20
+ ]
21
+ const reviewReasons = (blocks) => blocks.reduce((counts, block) => {
22
+ for (const reason of block.reasons) counts[reason] = (counts[reason] ?? 0) + 1
23
+ return counts
24
+ }, {})
25
+ const aliasStats = {
26
+ localLanguageRecords: docxCandidates.filter(values => values.aliases?.some(alias => alias.aliasType === 'LOCAL_LANGUAGE')).length,
27
+ akaRecords: docxCandidates.filter(values => values.aliases?.some(alias => alias.aliasType === 'AKA')).length
28
+ }
29
+
30
+ const report = {
31
+ elapsedMs: Date.now() - startedAt,
32
+ pdf: {
33
+ schemas: pdf.schemas,
34
+ accepted: pdf.accepted.length,
35
+ reviewBlocks: pdf.reviewBlocks.length,
36
+ reviewReasons: reviewReasons(pdf.reviewBlocks)
37
+ },
38
+ docx: {
39
+ entityCandidates: docxCandidates.length,
40
+ accepted: docx.accepted.length,
41
+ reviewBlocks: docx.reviewBlocks.length,
42
+ skippedPersons: docx.skipped.filter(item => item.reason === 'SANCTION_PERSON_OUT_OF_SCOPE').length,
43
+ reviewReasons: reviewReasons(docx.reviewBlocks),
44
+ aliases: aliasStats
45
+ }
46
+ }
47
+ console.log(JSON.stringify(report, null, 2))
48
+
49
+ const failures = []
50
+ if (!pdf.schemas.includes('IMPORT_FIVE_COLUMN')) failures.push('PDF import five-column schema was not detected')
51
+ if (!pdf.schemas.includes('EXPORT_SIX_COLUMN')) failures.push('PDF export six-column schema was not detected')
52
+ if (docxCandidates.length !== 168) failures.push(`DOCX entity candidate count is ${docxCandidates.length}, expected 168`)
53
+ if (report.docx.skippedPersons !== 48) failures.push(`DOCX skipped person count is ${report.docx.skippedPersons}, expected 48`)
54
+ if (report.docx.reviewReasons.EFFECTIVE_DATE_INVALID !== 1) failures.push('DOCX incomplete listing date was not isolated exactly once')
55
+ if (aliasStats.localLanguageRecords !== 157) failures.push(`DOCX local-language alias record count is ${aliasStats.localLanguageRecords}, expected 157`)
56
+ if (aliasStats.akaRecords !== 78) failures.push(`DOCX AKA record count is ${aliasStats.akaRecords}, expected 78`)
57
+
58
+ if (failures.length) {
59
+ for (const failure of failures) console.error(`Verification failed: ${failure}`)
60
+ process.exit(1)
61
+ }
@@ -0,0 +1,46 @@
1
+ import { TranslationProvider } from '../dist/lib/adapters/translation.provider.js'
2
+
3
+ const apiKey = process.env.TRADE_COMPLIANCE_TRANSLATION_API_KEY?.trim()
4
+ if (!apiKey) {
5
+ console.error('TRADE_COMPLIANCE_TRANSLATION_API_KEY is required')
6
+ process.exit(2)
7
+ }
8
+
9
+ const config = {
10
+ translation: {
11
+ endpoint: process.env.TRADE_COMPLIANCE_TRANSLATION_ENDPOINT || 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
12
+ apiKey,
13
+ model: process.env.TRADE_COMPLIANCE_TRANSLATION_MODEL || 'qwen3.6-flash',
14
+ targetLanguage: process.env.TRADE_COMPLIANCE_TRANSLATION_TARGET_LANGUAGE || 'zh-CN',
15
+ batchSize: 10,
16
+ concurrency: 5,
17
+ timeoutMs: 120_000
18
+ }
19
+ }
20
+ const provider = new TranslationProvider({ resolve: () => config })
21
+ const startedAt = Date.now()
22
+ const records = await provider.translateBatch([
23
+ { recordId: 'controlled-1', fields: { productName: 'Electric motor', controlDescription: 'Subject to export controls' } },
24
+ { recordId: 'sanction-1', fields: { sanctionReason: 'Supporting restricted exports and concealing end-user information' } }
25
+ ])
26
+ const singleTranslation = await provider.toChinese('Commercial aircraft engine')
27
+
28
+ const byId = new Map(records.map(record => [record.recordId, record.fields]))
29
+ const failures = []
30
+ for (const [recordId, fields] of [
31
+ ['controlled-1', ['productName', 'controlDescription']],
32
+ ['sanction-1', ['sanctionReason']]
33
+ ]) {
34
+ const translated = byId.get(recordId)
35
+ if (!translated) failures.push(`${recordId} is missing`)
36
+ for (const field of fields) {
37
+ const value = translated?.[field]
38
+ if (typeof value !== 'string' || !/\p{Script=Han}/u.test(value)) failures.push(`${recordId}.${field} is not Chinese`)
39
+ }
40
+ }
41
+ if (typeof singleTranslation !== 'string' || !/\p{Script=Han}/u.test(singleTranslation)) {
42
+ failures.push('single text translation is not Chinese')
43
+ }
44
+
45
+ console.log(JSON.stringify({ model: config.translation.model, elapsedMs: Date.now() - startedAt, batchRecordCount: records.length, singleTranslation: Boolean(singleTranslation), success: failures.length === 0, failures }, null, 2))
46
+ if (failures.length) process.exit(1)