@tiangong-lca/cli 0.0.7 → 0.0.8
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/README.md +83 -2
- package/dist/src/cli.js +1327 -40
- package/dist/src/cli.js.map +1 -1
- package/dist/src/lib/dataset-author.js +100 -0
- package/dist/src/lib/dataset-author.js.map +1 -0
- package/dist/src/lib/dataset-bilingual.js +545 -0
- package/dist/src/lib/dataset-bilingual.js.map +1 -0
- package/dist/src/lib/dataset-contract.js +350 -0
- package/dist/src/lib/dataset-contract.js.map +1 -0
- package/dist/src/lib/dataset-evidence-search.js +636 -0
- package/dist/src/lib/dataset-evidence-search.js.map +1 -0
- package/dist/src/lib/dataset-import-lca.js +171 -0
- package/dist/src/lib/dataset-import-lca.js.map +1 -0
- package/dist/src/lib/dataset-remote-refresh.js +166 -0
- package/dist/src/lib/dataset-remote-refresh.js.map +1 -0
- package/dist/src/lib/dataset-remote-verify.js +543 -0
- package/dist/src/lib/dataset-remote-verify.js.map +1 -0
- package/dist/src/lib/dataset-validate.js +63 -7
- package/dist/src/lib/dataset-validate.js.map +1 -1
- package/dist/src/lib/flow-payload-validation.js +51 -0
- package/dist/src/lib/flow-payload-validation.js.map +1 -0
- package/dist/src/lib/flow-publish-reviewed-data.js +16 -0
- package/dist/src/lib/flow-publish-reviewed-data.js.map +1 -1
- package/dist/src/lib/flow-publish-version.js +182 -12
- package/dist/src/lib/flow-publish-version.js.map +1 -1
- package/dist/src/lib/identity-preflight.js +1021 -0
- package/dist/src/lib/identity-preflight.js.map +1 -0
- package/dist/src/lib/process-auto-build.js +147 -0
- package/dist/src/lib/process-auto-build.js.map +1 -1
- package/dist/src/lib/process-dedup-review.js +51 -0
- package/dist/src/lib/process-dedup-review.js.map +1 -1
- package/dist/src/lib/process-flow-build-plan.js +1071 -0
- package/dist/src/lib/process-flow-build-plan.js.map +1 -0
- package/dist/src/lib/process-payload-validation.js +14 -7
- package/dist/src/lib/process-payload-validation.js.map +1 -1
- package/dist/src/lib/process-publish-build.js +122 -4
- package/dist/src/lib/process-publish-build.js.map +1 -1
- package/dist/src/lib/process-refresh-references.js +19 -10
- package/dist/src/lib/process-refresh-references.js.map +1 -1
- package/dist/src/lib/process-required-fields.js +810 -0
- package/dist/src/lib/process-required-fields.js.map +1 -0
- package/dist/src/lib/process-save-draft-run.js +4 -1
- package/dist/src/lib/process-save-draft-run.js.map +1 -1
- package/dist/src/lib/publish.js +100 -0
- package/dist/src/lib/publish.js.map +1 -1
- package/dist/src/lib/review-flow.js +58 -0
- package/dist/src/lib/review-flow.js.map +1 -1
- package/dist/src/lib/review-process.js +150 -2
- package/dist/src/lib/review-process.js.map +1 -1
- package/dist/src/lib/runtime-rulesets.js +283 -0
- package/dist/src/lib/runtime-rulesets.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import * as tidasSdk from '@tiangong-lca/tidas-sdk';
|
|
4
|
+
import { writeJsonArtifact, writeJsonLinesArtifact } from './artifacts.js';
|
|
5
|
+
import { readRuntimeEnv } from './env.js';
|
|
6
|
+
import { CliError } from './errors.js';
|
|
7
|
+
import { postJson } from './http.js';
|
|
8
|
+
import { datasetIdentity, detectDatasetKind, isRecord, readDatasetRowsInput, unwrapDatasetPayload, } from './dataset-local.js';
|
|
9
|
+
import { readJsonInput } from './io.js';
|
|
10
|
+
import { deriveSupabaseFunctionsBaseUrl, requireSupabaseRestRuntime } from './supabase-client.js';
|
|
11
|
+
import { resolveSupabaseUserSession } from './supabase-session.js';
|
|
12
|
+
import { normalizeIssuePath, validateSchemaWithDeepFallback, } from './tidas-sdk-validation.js';
|
|
13
|
+
const SCHEMA_EXPORTS = {
|
|
14
|
+
flow: 'FlowSchema',
|
|
15
|
+
process: 'ProcessSchema',
|
|
16
|
+
};
|
|
17
|
+
const ENTITY_FACTORY_EXPORTS = {
|
|
18
|
+
flow: 'createFlow',
|
|
19
|
+
process: 'createProcess',
|
|
20
|
+
};
|
|
21
|
+
function requiredInputPath(inputPath) {
|
|
22
|
+
const normalized = inputPath.trim();
|
|
23
|
+
if (!normalized) {
|
|
24
|
+
throw new CliError('Missing required --input value.', {
|
|
25
|
+
code: 'IDENTITY_PREFLIGHT_INPUT_REQUIRED',
|
|
26
|
+
exitCode: 2,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
31
|
+
function normalizeToRecord(value, label) {
|
|
32
|
+
if (!isRecord(value)) {
|
|
33
|
+
throw new CliError(`${label} must be a JSON object.`, {
|
|
34
|
+
code: 'IDENTITY_PREFLIGHT_INVALID_INPUT',
|
|
35
|
+
exitCode: 2,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
function normalizeRows(value, label) {
|
|
41
|
+
if (value === undefined || value === null) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const rows = isRecord(value) && Array.isArray(value.rows) ? value.rows : value;
|
|
45
|
+
const normalizedRows = Array.isArray(rows) ? rows : [rows];
|
|
46
|
+
return normalizedRows.map((row, index) => normalizeToRecord(row, `${label}[${index}]`));
|
|
47
|
+
}
|
|
48
|
+
function normalizePathList(value, label) {
|
|
49
|
+
if (value === undefined || value === null) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const values = Array.isArray(value) ? value : [value];
|
|
53
|
+
return values.flatMap((entry, index) => {
|
|
54
|
+
if (typeof entry !== 'string') {
|
|
55
|
+
throw new CliError(`${label}[${index}] must be a string path.`, {
|
|
56
|
+
code: 'IDENTITY_PREFLIGHT_INVALID_CANDIDATE_INPUT',
|
|
57
|
+
exitCode: 2,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return entry
|
|
61
|
+
.split(',')
|
|
62
|
+
.map((pathValue) => pathValue.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function normalizePositiveInteger(value, label) {
|
|
67
|
+
if (value === undefined || value === null || value === '') {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const parsed = typeof value === 'number' ? value : Number.parseInt(String(value), 10);
|
|
71
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
72
|
+
throw new CliError(`Expected ${label} to be a positive integer.`, {
|
|
73
|
+
code: 'IDENTITY_PREFLIGHT_INVALID_REMOTE_LIMIT',
|
|
74
|
+
exitCode: 2,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
function normalizeRemoteCandidateSearch(value) {
|
|
80
|
+
if (value === undefined || value === null) {
|
|
81
|
+
return { enabled: false, query: null, filter: null, limit: null };
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === 'boolean') {
|
|
84
|
+
return { enabled: value, query: null, filter: null, limit: null };
|
|
85
|
+
}
|
|
86
|
+
if (!isRecord(value)) {
|
|
87
|
+
throw new CliError('remote_candidate_search must be a boolean or object.', {
|
|
88
|
+
code: 'IDENTITY_PREFLIGHT_INVALID_REMOTE_SEARCH',
|
|
89
|
+
exitCode: 2,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const enabled = value.enabled === undefined ? true : Boolean(value.enabled);
|
|
93
|
+
const query = textValue(value.query) ?? textValue(value.search_query);
|
|
94
|
+
const filter = value.filter === undefined || value.filter === null
|
|
95
|
+
? null
|
|
96
|
+
: normalizeToRecord(value.filter, 'remote_candidate_search.filter');
|
|
97
|
+
return {
|
|
98
|
+
enabled,
|
|
99
|
+
query,
|
|
100
|
+
filter,
|
|
101
|
+
limit: normalizePositiveInteger(value.limit, 'remote_candidate_search.limit'),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function pickKindTarget(input, kind) {
|
|
105
|
+
if (input.target !== undefined) {
|
|
106
|
+
return input.target;
|
|
107
|
+
}
|
|
108
|
+
if (input.candidate !== undefined) {
|
|
109
|
+
return input.candidate;
|
|
110
|
+
}
|
|
111
|
+
if (kind === 'process' && input.process !== undefined) {
|
|
112
|
+
return input.process;
|
|
113
|
+
}
|
|
114
|
+
if (kind === 'flow' && input.flow !== undefined) {
|
|
115
|
+
return input.flow;
|
|
116
|
+
}
|
|
117
|
+
return input;
|
|
118
|
+
}
|
|
119
|
+
function normalizePreflightInput(rawInput, kind) {
|
|
120
|
+
const input = normalizeToRecord(rawInput, 'identity preflight input');
|
|
121
|
+
const target = normalizeToRecord(pickKindTarget(input, kind), 'identity preflight target');
|
|
122
|
+
const candidateGroups = [
|
|
123
|
+
...normalizeRows(input.candidates, 'candidates'),
|
|
124
|
+
...normalizeRows(input.existing, 'existing'),
|
|
125
|
+
...normalizeRows(input.existing_rows, 'existing_rows'),
|
|
126
|
+
...normalizeRows(input.rows, 'rows'),
|
|
127
|
+
];
|
|
128
|
+
return {
|
|
129
|
+
target,
|
|
130
|
+
candidates: candidateGroups,
|
|
131
|
+
candidateInputPaths: [
|
|
132
|
+
...normalizePathList(input.candidate_input, 'candidate_input'),
|
|
133
|
+
...normalizePathList(input.candidate_inputs, 'candidate_inputs'),
|
|
134
|
+
...normalizePathList(input.candidateInputPaths, 'candidateInputPaths'),
|
|
135
|
+
...normalizePathList(input.candidate_files, 'candidate_files'),
|
|
136
|
+
...normalizePathList(input.candidateFiles, 'candidateFiles'),
|
|
137
|
+
],
|
|
138
|
+
remoteCandidateSearch: normalizeRemoteCandidateSearch(input.remote_candidate_search ?? input.remoteCandidateSearch ?? input.remote_candidates),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function isCandidateDatasetFile(filePath) {
|
|
142
|
+
return /\.(?:json|jsonl)$/iu.test(path.basename(filePath));
|
|
143
|
+
}
|
|
144
|
+
function throwUnsupportedCandidateInput(resolved) {
|
|
145
|
+
throw new CliError(`Candidate input must be a JSON/JSONL file or directory: ${resolved}`, {
|
|
146
|
+
code: 'IDENTITY_PREFLIGHT_CANDIDATE_INPUT_UNSUPPORTED',
|
|
147
|
+
exitCode: 2,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
function collectCandidateFilesFromStats(resolved, stats) {
|
|
151
|
+
if (stats.isFile()) {
|
|
152
|
+
if (!isCandidateDatasetFile(resolved)) {
|
|
153
|
+
throwUnsupportedCandidateInput(resolved);
|
|
154
|
+
}
|
|
155
|
+
return [resolved];
|
|
156
|
+
}
|
|
157
|
+
if (!stats.isDirectory()) {
|
|
158
|
+
throwUnsupportedCandidateInput(resolved);
|
|
159
|
+
}
|
|
160
|
+
const files = [];
|
|
161
|
+
const visit = (directory) => {
|
|
162
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
163
|
+
if (entry.name.startsWith('.')) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const entryPath = path.join(directory, entry.name);
|
|
167
|
+
if (entry.isDirectory()) {
|
|
168
|
+
visit(entryPath);
|
|
169
|
+
}
|
|
170
|
+
else if (entry.isFile() && isCandidateDatasetFile(entry.name)) {
|
|
171
|
+
files.push(entryPath);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
visit(resolved);
|
|
176
|
+
return files.sort((left, right) => left.localeCompare(right));
|
|
177
|
+
}
|
|
178
|
+
function collectCandidateFiles(candidatePath) {
|
|
179
|
+
const resolved = path.resolve(candidatePath);
|
|
180
|
+
if (!existsSync(resolved)) {
|
|
181
|
+
throw new CliError(`Candidate input not found: ${resolved}`, {
|
|
182
|
+
code: 'IDENTITY_PREFLIGHT_CANDIDATE_INPUT_NOT_FOUND',
|
|
183
|
+
exitCode: 2,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const stats = statSync(resolved);
|
|
187
|
+
return collectCandidateFilesFromStats(resolved, stats);
|
|
188
|
+
}
|
|
189
|
+
function readCandidateSource(candidatePath) {
|
|
190
|
+
const resolved = path.resolve(candidatePath);
|
|
191
|
+
const files = collectCandidateFiles(resolved);
|
|
192
|
+
const rows = files.flatMap((file) => readDatasetRowsInput(file));
|
|
193
|
+
const kind = statSync(resolved).isDirectory() ? 'directory' : 'file';
|
|
194
|
+
return {
|
|
195
|
+
rows,
|
|
196
|
+
source: {
|
|
197
|
+
path: resolved,
|
|
198
|
+
kind,
|
|
199
|
+
row_count: rows.length,
|
|
200
|
+
scanned_files: files,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function mergeRemoteCandidateSearchConfig(inputConfig, options) {
|
|
205
|
+
return {
|
|
206
|
+
enabled: options.remoteCandidateSearch ?? inputConfig.enabled,
|
|
207
|
+
query: options.remoteQuery?.trim() || inputConfig.query,
|
|
208
|
+
filter: options.remoteFilter ?? inputConfig.filter,
|
|
209
|
+
limit: options.remoteLimit ?? inputConfig.limit,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function remoteSearchEndpoint(kind) {
|
|
213
|
+
return kind === 'process' ? 'process_hybrid_search' : 'flow_hybrid_search';
|
|
214
|
+
}
|
|
215
|
+
function defaultRemoteQuery(profile) {
|
|
216
|
+
return (profile.names[0] ??
|
|
217
|
+
Object.values(profile.fields)
|
|
218
|
+
.flat()
|
|
219
|
+
.find((value) => typeof value === 'string' && value.trim().length > 0) ??
|
|
220
|
+
(profile.identity_key ? profile.identity_key : null));
|
|
221
|
+
}
|
|
222
|
+
function remoteSearchFilter(kind, profile, explicitFilter) {
|
|
223
|
+
const filter = explicitFilter ? { ...explicitFilter } : {};
|
|
224
|
+
if (kind === 'flow' && filter.flowType === undefined) {
|
|
225
|
+
const flowType = Array.isArray(profile.fields.type_of_dataset)
|
|
226
|
+
? profile.fields.type_of_dataset[0]
|
|
227
|
+
: profile.fields.type_of_dataset;
|
|
228
|
+
if (flowType) {
|
|
229
|
+
filter.flowType = flowType;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return Object.keys(filter).length > 0 ? filter : null;
|
|
233
|
+
}
|
|
234
|
+
function rowsFromRemoteSearchResponse(value) {
|
|
235
|
+
const rows = isRecord(value)
|
|
236
|
+
? (value.data ?? value.rows ?? value.results ?? value.candidates ?? [])
|
|
237
|
+
: value;
|
|
238
|
+
if (rows === undefined || rows === null) {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
return normalizeRows(rows, 'remote search candidates');
|
|
242
|
+
}
|
|
243
|
+
async function readRemoteCandidateSource(kind, targetProfile, config, options) {
|
|
244
|
+
if (!config.enabled) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const query = config.query ?? defaultRemoteQuery(targetProfile);
|
|
248
|
+
if (!query) {
|
|
249
|
+
throw new CliError('Remote identity candidate search requires a query.', {
|
|
250
|
+
code: 'IDENTITY_PREFLIGHT_REMOTE_QUERY_REQUIRED',
|
|
251
|
+
exitCode: 2,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const runtimeEnv = readRuntimeEnv(options.env ?? process.env);
|
|
255
|
+
const runtime = requireSupabaseRestRuntime(options.env ?? process.env);
|
|
256
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
257
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
258
|
+
const endpoint = remoteSearchEndpoint(kind);
|
|
259
|
+
const url = `${deriveSupabaseFunctionsBaseUrl(runtime.apiBaseUrl)}/${endpoint}`;
|
|
260
|
+
const session = await resolveSupabaseUserSession({
|
|
261
|
+
runtime,
|
|
262
|
+
fetchImpl,
|
|
263
|
+
timeoutMs,
|
|
264
|
+
now: options.now,
|
|
265
|
+
});
|
|
266
|
+
const filter = remoteSearchFilter(kind, targetProfile, config.filter);
|
|
267
|
+
const body = {
|
|
268
|
+
query,
|
|
269
|
+
...(filter ? { filter } : {}),
|
|
270
|
+
...(config.limit ? { limit: config.limit } : {}),
|
|
271
|
+
};
|
|
272
|
+
const headers = {
|
|
273
|
+
Authorization: `Bearer ${session.accessToken}`,
|
|
274
|
+
'Content-Type': 'application/json',
|
|
275
|
+
};
|
|
276
|
+
if (runtimeEnv.region) {
|
|
277
|
+
headers['x-region'] = runtimeEnv.region;
|
|
278
|
+
}
|
|
279
|
+
const response = await postJson({
|
|
280
|
+
url,
|
|
281
|
+
headers,
|
|
282
|
+
body,
|
|
283
|
+
timeoutMs,
|
|
284
|
+
fetchImpl,
|
|
285
|
+
});
|
|
286
|
+
const rows = rowsFromRemoteSearchResponse(response);
|
|
287
|
+
const limitedRows = config.limit ? rows.slice(0, config.limit) : rows;
|
|
288
|
+
return {
|
|
289
|
+
rows: limitedRows,
|
|
290
|
+
source: {
|
|
291
|
+
path: url,
|
|
292
|
+
kind: 'remote_search',
|
|
293
|
+
row_count: limitedRows.length,
|
|
294
|
+
scanned_files: [],
|
|
295
|
+
endpoint,
|
|
296
|
+
query,
|
|
297
|
+
filter,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function schemaForKind(kind, schemas) {
|
|
302
|
+
if (schemas?.[kind]) {
|
|
303
|
+
return {
|
|
304
|
+
validator: 'injected',
|
|
305
|
+
schema: schemas[kind],
|
|
306
|
+
createEntity: null,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const exportName = SCHEMA_EXPORTS[kind];
|
|
310
|
+
const candidate = tidasSdk[exportName];
|
|
311
|
+
if (!candidate ||
|
|
312
|
+
typeof candidate !== 'object' ||
|
|
313
|
+
typeof candidate.safeParse !== 'function') {
|
|
314
|
+
throw new CliError(`${String(exportName)} is unavailable in @tiangong-lca/tidas-sdk.`, {
|
|
315
|
+
code: 'IDENTITY_PREFLIGHT_SCHEMA_UNAVAILABLE',
|
|
316
|
+
exitCode: 2,
|
|
317
|
+
details: { kind },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
const factoryName = ENTITY_FACTORY_EXPORTS[kind];
|
|
321
|
+
const createEntity = tidasSdk[factoryName];
|
|
322
|
+
return {
|
|
323
|
+
validator: `@tiangong-lca/tidas-sdk/${String(exportName)}`,
|
|
324
|
+
schema: candidate,
|
|
325
|
+
createEntity: typeof createEntity === 'function' ? createEntity : null,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function validateTargetSchema(target, kind, schemas) {
|
|
329
|
+
const detectedKind = detectDatasetKind(target);
|
|
330
|
+
if (detectedKind && detectedKind !== kind) {
|
|
331
|
+
return {
|
|
332
|
+
status: 'failed',
|
|
333
|
+
validator: null,
|
|
334
|
+
issue_count: 1,
|
|
335
|
+
issues: [
|
|
336
|
+
{
|
|
337
|
+
path: '<root>',
|
|
338
|
+
message: `Expected ${kind} target but detected ${detectedKind}.`,
|
|
339
|
+
code: 'dataset_kind_mismatch',
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (!detectedKind) {
|
|
345
|
+
return {
|
|
346
|
+
status: 'not_applicable',
|
|
347
|
+
validator: null,
|
|
348
|
+
issue_count: 0,
|
|
349
|
+
issues: [],
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const { validator, schema, createEntity } = schemaForKind(kind, schemas);
|
|
353
|
+
const payload = unwrapDatasetPayload(target);
|
|
354
|
+
const outcome = validateSchemaWithDeepFallback(schema, payload, createEntity);
|
|
355
|
+
if (outcome.success) {
|
|
356
|
+
return {
|
|
357
|
+
status: 'passed',
|
|
358
|
+
validator,
|
|
359
|
+
issue_count: 0,
|
|
360
|
+
issues: [],
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
status: 'failed',
|
|
365
|
+
validator,
|
|
366
|
+
issue_count: outcome.issues.length,
|
|
367
|
+
issues: outcome.issues.map((issue) => ({
|
|
368
|
+
path: normalizeIssuePath(issue.path),
|
|
369
|
+
message: issue.message ?? 'Validation failed',
|
|
370
|
+
code: issue.code ?? 'custom',
|
|
371
|
+
})),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function normalizeText(value) {
|
|
375
|
+
return value
|
|
376
|
+
.normalize('NFKC')
|
|
377
|
+
.trim()
|
|
378
|
+
.toLowerCase()
|
|
379
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, ' ')
|
|
380
|
+
.replace(/\s+/gu, ' ')
|
|
381
|
+
.trim();
|
|
382
|
+
}
|
|
383
|
+
function normalizeKey(key) {
|
|
384
|
+
return key
|
|
385
|
+
.split(':')
|
|
386
|
+
.pop()
|
|
387
|
+
.replace(/[^a-zA-Z0-9]+/gu, '')
|
|
388
|
+
.toLowerCase();
|
|
389
|
+
}
|
|
390
|
+
function textValue(value) {
|
|
391
|
+
if (typeof value === 'string') {
|
|
392
|
+
const trimmed = value.trim();
|
|
393
|
+
return trimmed || null;
|
|
394
|
+
}
|
|
395
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
396
|
+
return String(value);
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
function collectText(value, output = []) {
|
|
401
|
+
const direct = textValue(value);
|
|
402
|
+
if (direct) {
|
|
403
|
+
output.push(direct);
|
|
404
|
+
return output;
|
|
405
|
+
}
|
|
406
|
+
if (Array.isArray(value)) {
|
|
407
|
+
for (const entry of value) {
|
|
408
|
+
collectText(entry, output);
|
|
409
|
+
}
|
|
410
|
+
return output;
|
|
411
|
+
}
|
|
412
|
+
if (isRecord(value)) {
|
|
413
|
+
if (textValue(value['#text'])) {
|
|
414
|
+
output.push(textValue(value['#text']));
|
|
415
|
+
}
|
|
416
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
417
|
+
if (key === '#text') {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
collectText(entry, output);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return output;
|
|
424
|
+
}
|
|
425
|
+
function collectValuesByKey(value, wantedKeys, output = []) {
|
|
426
|
+
if (Array.isArray(value)) {
|
|
427
|
+
for (const entry of value) {
|
|
428
|
+
collectValuesByKey(entry, wantedKeys, output);
|
|
429
|
+
}
|
|
430
|
+
return output;
|
|
431
|
+
}
|
|
432
|
+
if (!isRecord(value)) {
|
|
433
|
+
return output;
|
|
434
|
+
}
|
|
435
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
436
|
+
if (wantedKeys.has(normalizeKey(key))) {
|
|
437
|
+
output.push(entry);
|
|
438
|
+
}
|
|
439
|
+
collectValuesByKey(entry, wantedKeys, output);
|
|
440
|
+
}
|
|
441
|
+
return output;
|
|
442
|
+
}
|
|
443
|
+
function uniqueTexts(values) {
|
|
444
|
+
const normalized = new Map();
|
|
445
|
+
for (const value of values) {
|
|
446
|
+
for (const text of collectText(value)) {
|
|
447
|
+
const key = normalizeText(text);
|
|
448
|
+
if (key && !normalized.has(key)) {
|
|
449
|
+
normalized.set(key, text.trim());
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return [...normalized.values()].sort((a, b) => normalizeText(a).localeCompare(normalizeText(b)));
|
|
454
|
+
}
|
|
455
|
+
function firstUniqueText(...values) {
|
|
456
|
+
return uniqueTexts(values)[0] ?? null;
|
|
457
|
+
}
|
|
458
|
+
function fieldFromKeys(row, payload, keys) {
|
|
459
|
+
const wanted = new Set(keys.map(normalizeKey));
|
|
460
|
+
return firstUniqueText(...collectValuesByKey(row, wanted), ...collectValuesByKey(payload, wanted));
|
|
461
|
+
}
|
|
462
|
+
function textListFromKeys(row, payload, keys) {
|
|
463
|
+
const wanted = new Set(keys.map(normalizeKey));
|
|
464
|
+
return uniqueTexts([...collectValuesByKey(row, wanted), ...collectValuesByKey(payload, wanted)]);
|
|
465
|
+
}
|
|
466
|
+
function normalizedList(values) {
|
|
467
|
+
return [...new Set(values.map(normalizeText).filter(Boolean))].sort();
|
|
468
|
+
}
|
|
469
|
+
function numberOrNull(value) {
|
|
470
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
471
|
+
return value;
|
|
472
|
+
}
|
|
473
|
+
if (typeof value === 'string' && value.trim()) {
|
|
474
|
+
const parsed = Number.parseInt(value.trim(), 10);
|
|
475
|
+
return Number.isInteger(parsed) ? parsed : null;
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
function collectExchangeLikeRecords(value, output = []) {
|
|
480
|
+
if (Array.isArray(value)) {
|
|
481
|
+
for (const entry of value) {
|
|
482
|
+
collectExchangeLikeRecords(entry, output);
|
|
483
|
+
}
|
|
484
|
+
return output;
|
|
485
|
+
}
|
|
486
|
+
if (!isRecord(value)) {
|
|
487
|
+
return output;
|
|
488
|
+
}
|
|
489
|
+
const keys = new Set(Object.keys(value).map(normalizeKey));
|
|
490
|
+
if (keys.has('referencetoflowdataset') ||
|
|
491
|
+
keys.has('flowid') ||
|
|
492
|
+
keys.has('flowuuid') ||
|
|
493
|
+
keys.has('flow') ||
|
|
494
|
+
keys.has('exchangedirection')) {
|
|
495
|
+
output.push(value);
|
|
496
|
+
}
|
|
497
|
+
for (const entry of Object.values(value)) {
|
|
498
|
+
collectExchangeLikeRecords(entry, output);
|
|
499
|
+
}
|
|
500
|
+
return output;
|
|
501
|
+
}
|
|
502
|
+
function exchangeRecordSignature(record) {
|
|
503
|
+
const flowId = fieldFromKeys(record, record, [
|
|
504
|
+
'@refObjectId',
|
|
505
|
+
'refObjectId',
|
|
506
|
+
'flow_id',
|
|
507
|
+
'flowId',
|
|
508
|
+
'flow_uuid',
|
|
509
|
+
'flowUuid',
|
|
510
|
+
'referenceToFlowDataSet',
|
|
511
|
+
]);
|
|
512
|
+
const direction = fieldFromKeys(record, record, [
|
|
513
|
+
'exchangeDirection',
|
|
514
|
+
'direction',
|
|
515
|
+
'inputGroup',
|
|
516
|
+
'outputGroup',
|
|
517
|
+
]);
|
|
518
|
+
const amount = fieldFromKeys(record, record, [
|
|
519
|
+
'meanAmount',
|
|
520
|
+
'mean_amount',
|
|
521
|
+
'resultingAmount',
|
|
522
|
+
'resulting_amount',
|
|
523
|
+
'amount',
|
|
524
|
+
'meanValue',
|
|
525
|
+
]);
|
|
526
|
+
const normalizedFlowId = flowId ? normalizeText(flowId) : '';
|
|
527
|
+
if (!normalizedFlowId) {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
return [normalizedFlowId, normalizeText(direction ?? ''), normalizeText(amount ?? '')].join(':');
|
|
531
|
+
}
|
|
532
|
+
function processExchangeSignature(row, payload) {
|
|
533
|
+
return [
|
|
534
|
+
...new Set([...collectExchangeLikeRecords(row), ...collectExchangeLikeRecords(payload)]
|
|
535
|
+
.map(exchangeRecordSignature)
|
|
536
|
+
.filter((entry) => Boolean(entry))),
|
|
537
|
+
].sort();
|
|
538
|
+
}
|
|
539
|
+
function profileDatasetIdentity(row, payload, kind) {
|
|
540
|
+
const detectedKind = detectDatasetKind(row);
|
|
541
|
+
const identity = datasetIdentity(row, payload, detectedKind === kind ? kind : null);
|
|
542
|
+
return {
|
|
543
|
+
id: firstUniqueText(row.id, row.process_id, row.flow_id, row.uuid, identity.id) ?? identity.id,
|
|
544
|
+
version: firstUniqueText(row.version, row.dataset_version, identity.version) ?? identity.version,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function processProfile(row) {
|
|
548
|
+
const payload = unwrapDatasetPayload(row);
|
|
549
|
+
const identity = profileDatasetIdentity(row, payload, 'process');
|
|
550
|
+
const names = textListFromKeys(row, payload, [
|
|
551
|
+
'name',
|
|
552
|
+
'baseName',
|
|
553
|
+
'shortDescription',
|
|
554
|
+
'name_en',
|
|
555
|
+
'name_zh',
|
|
556
|
+
]);
|
|
557
|
+
const referenceFlowIds = textListFromKeys(row, payload, [
|
|
558
|
+
'reference_flow_id',
|
|
559
|
+
'referenceFlowId',
|
|
560
|
+
'reference_product_flow',
|
|
561
|
+
'referenceProductFlow',
|
|
562
|
+
'referenceToReferenceFlow',
|
|
563
|
+
'referenceToFlowDataSet',
|
|
564
|
+
'@refObjectId',
|
|
565
|
+
'refObjectId',
|
|
566
|
+
]);
|
|
567
|
+
const operation = fieldFromKeys(row, payload, ['operation', 'process_operation']);
|
|
568
|
+
const quantitativeReference = fieldFromKeys(row, payload, [
|
|
569
|
+
'quantitative_reference',
|
|
570
|
+
'quantitativeReference',
|
|
571
|
+
'qref',
|
|
572
|
+
'referenceToReferenceFlow',
|
|
573
|
+
]);
|
|
574
|
+
const geography = fieldFromKeys(row, payload, [
|
|
575
|
+
'geography',
|
|
576
|
+
'location',
|
|
577
|
+
'locationOfOperationSupplyOrProduction',
|
|
578
|
+
]);
|
|
579
|
+
const time = fieldFromKeys(row, payload, [
|
|
580
|
+
'time',
|
|
581
|
+
'reference_year',
|
|
582
|
+
'referenceYear',
|
|
583
|
+
'timePeriod',
|
|
584
|
+
]);
|
|
585
|
+
const technologyRoute = fieldFromKeys(row, payload, [
|
|
586
|
+
'technology_route',
|
|
587
|
+
'technologyRoute',
|
|
588
|
+
'technology',
|
|
589
|
+
'treatmentStandardsRoutes',
|
|
590
|
+
]);
|
|
591
|
+
const systemBoundary = fieldFromKeys(row, payload, [
|
|
592
|
+
'system_boundary',
|
|
593
|
+
'systemBoundary',
|
|
594
|
+
'boundary',
|
|
595
|
+
]);
|
|
596
|
+
const providerRole = fieldFromKeys(row, payload, ['provider_role', 'providerRole']);
|
|
597
|
+
const exchangeSignature = processExchangeSignature(row, payload);
|
|
598
|
+
const keyParts = [
|
|
599
|
+
...normalizedList(names).slice(0, 4),
|
|
600
|
+
...normalizedList(referenceFlowIds).slice(0, 4),
|
|
601
|
+
normalizeText(operation ?? ''),
|
|
602
|
+
normalizeText(quantitativeReference ?? ''),
|
|
603
|
+
normalizeText(geography ?? ''),
|
|
604
|
+
normalizeText(time ?? ''),
|
|
605
|
+
normalizeText(technologyRoute ?? ''),
|
|
606
|
+
normalizeText(systemBoundary ?? ''),
|
|
607
|
+
normalizeText(providerRole ?? ''),
|
|
608
|
+
exchangeSignature.join(','),
|
|
609
|
+
].filter(Boolean);
|
|
610
|
+
return {
|
|
611
|
+
id: identity.id,
|
|
612
|
+
version: identity.version,
|
|
613
|
+
state_code: numberOrNull(row.state_code ?? row.stateCode),
|
|
614
|
+
names,
|
|
615
|
+
normalized_names: normalizedList(names),
|
|
616
|
+
identity_key: keyParts.join('|'),
|
|
617
|
+
exchange_signature: exchangeSignature,
|
|
618
|
+
fields: {
|
|
619
|
+
reference_flow_ids: referenceFlowIds,
|
|
620
|
+
operation,
|
|
621
|
+
quantitative_reference: quantitativeReference,
|
|
622
|
+
geography,
|
|
623
|
+
time,
|
|
624
|
+
technology_route: technologyRoute,
|
|
625
|
+
system_boundary: systemBoundary,
|
|
626
|
+
provider_role: providerRole,
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
function flowProfile(row) {
|
|
631
|
+
const payload = unwrapDatasetPayload(row);
|
|
632
|
+
const identity = profileDatasetIdentity(row, payload, 'flow');
|
|
633
|
+
const names = textListFromKeys(row, payload, [
|
|
634
|
+
'name',
|
|
635
|
+
'baseName',
|
|
636
|
+
'shortDescription',
|
|
637
|
+
'name_en',
|
|
638
|
+
'name_zh',
|
|
639
|
+
'synonyms',
|
|
640
|
+
]);
|
|
641
|
+
const typeOfDataset = fieldFromKeys(row, payload, [
|
|
642
|
+
'type_of_dataset',
|
|
643
|
+
'typeOfDataSet',
|
|
644
|
+
'flow_type',
|
|
645
|
+
'flowType',
|
|
646
|
+
]);
|
|
647
|
+
const cas = fieldFromKeys(row, payload, ['CASNumber', 'cas_number', 'cas']);
|
|
648
|
+
const flowProperty = fieldFromKeys(row, payload, [
|
|
649
|
+
'flow_property',
|
|
650
|
+
'flowProperty',
|
|
651
|
+
'referenceToFlowPropertyDataSet',
|
|
652
|
+
'reference_property',
|
|
653
|
+
'referenceProperty',
|
|
654
|
+
]);
|
|
655
|
+
const referenceUnit = fieldFromKeys(row, payload, ['reference_unit', 'referenceUnit', 'unit']);
|
|
656
|
+
const categories = textListFromKeys(row, payload, ['category', 'compartment']);
|
|
657
|
+
const geography = fieldFromKeys(row, payload, [
|
|
658
|
+
'geography',
|
|
659
|
+
'location',
|
|
660
|
+
'market',
|
|
661
|
+
'mixAndLocationTypes',
|
|
662
|
+
]);
|
|
663
|
+
const keyParts = [
|
|
664
|
+
normalizeText(typeOfDataset ?? ''),
|
|
665
|
+
...normalizedList(names).slice(0, 4),
|
|
666
|
+
normalizeText(cas ?? ''),
|
|
667
|
+
normalizeText(flowProperty ?? ''),
|
|
668
|
+
normalizeText(referenceUnit ?? ''),
|
|
669
|
+
...normalizedList(categories).slice(0, 4),
|
|
670
|
+
normalizeText(geography ?? ''),
|
|
671
|
+
].filter(Boolean);
|
|
672
|
+
return {
|
|
673
|
+
id: identity.id,
|
|
674
|
+
version: identity.version,
|
|
675
|
+
state_code: numberOrNull(row.state_code ?? row.stateCode),
|
|
676
|
+
names,
|
|
677
|
+
normalized_names: normalizedList(names),
|
|
678
|
+
identity_key: keyParts.join('|'),
|
|
679
|
+
exchange_signature: [],
|
|
680
|
+
fields: {
|
|
681
|
+
type_of_dataset: typeOfDataset,
|
|
682
|
+
cas,
|
|
683
|
+
flow_property: flowProperty,
|
|
684
|
+
reference_unit: referenceUnit,
|
|
685
|
+
categories,
|
|
686
|
+
geography,
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
function profileForKind(row, kind) {
|
|
691
|
+
return kind === 'process' ? processProfile(row) : flowProfile(row);
|
|
692
|
+
}
|
|
693
|
+
function intersects(left, right) {
|
|
694
|
+
const rightSet = new Set(right);
|
|
695
|
+
return left.some((entry) => rightSet.has(entry));
|
|
696
|
+
}
|
|
697
|
+
function normalizedFieldValues(value) {
|
|
698
|
+
return (Array.isArray(value) ? value : [value])
|
|
699
|
+
.filter((entry) => typeof entry === 'string')
|
|
700
|
+
.map(normalizeText)
|
|
701
|
+
.filter(Boolean);
|
|
702
|
+
}
|
|
703
|
+
function sameNonEmptyField(left, right) {
|
|
704
|
+
const leftValues = normalizedFieldValues(left);
|
|
705
|
+
const rightValues = normalizedFieldValues(right);
|
|
706
|
+
return leftValues.length > 0 && rightValues.length > 0 && intersects(leftValues, rightValues);
|
|
707
|
+
}
|
|
708
|
+
function sameExchangeSignature(left, right) {
|
|
709
|
+
return left.length > 0 && right.length > 0 && left.join('|') === right.join('|');
|
|
710
|
+
}
|
|
711
|
+
function hasEquivalentFlowCore(target, candidate) {
|
|
712
|
+
const hasSameType = sameNonEmptyField(target.fields.type_of_dataset, candidate.fields.type_of_dataset);
|
|
713
|
+
const hasSameProperty = sameNonEmptyField(target.fields.flow_property, candidate.fields.flow_property);
|
|
714
|
+
const hasSameUnit = sameNonEmptyField(target.fields.reference_unit, candidate.fields.reference_unit);
|
|
715
|
+
const hasSameCas = sameNonEmptyField(target.fields.cas, candidate.fields.cas);
|
|
716
|
+
const hasSameCategory = sameNonEmptyField(target.fields.categories, candidate.fields.categories);
|
|
717
|
+
return (hasSameType &&
|
|
718
|
+
hasSameProperty &&
|
|
719
|
+
hasSameUnit &&
|
|
720
|
+
intersects(target.normalized_names, candidate.normalized_names) &&
|
|
721
|
+
(hasSameCas || hasSameCategory));
|
|
722
|
+
}
|
|
723
|
+
function candidateEvaluation(target, candidate, kind, index) {
|
|
724
|
+
const matchReasons = [];
|
|
725
|
+
let matchScore = 0;
|
|
726
|
+
let decisionHint = null;
|
|
727
|
+
if (target.id && candidate.id && target.id === candidate.id) {
|
|
728
|
+
matchScore += 100;
|
|
729
|
+
matchReasons.push('same_dataset_id');
|
|
730
|
+
if (candidate.state_code === 0) {
|
|
731
|
+
decisionHint = 'update_same_row';
|
|
732
|
+
}
|
|
733
|
+
else if (target.version && candidate.version && target.version !== candidate.version) {
|
|
734
|
+
decisionHint = 'version_bump';
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
decisionHint = 'reuse';
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (target.identity_key &&
|
|
741
|
+
candidate.identity_key &&
|
|
742
|
+
target.identity_key === candidate.identity_key) {
|
|
743
|
+
matchScore += 90;
|
|
744
|
+
matchReasons.push('same_identity_key');
|
|
745
|
+
if (!decisionHint) {
|
|
746
|
+
decisionHint = 'block_duplicate';
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (kind === 'process' &&
|
|
750
|
+
sameExchangeSignature(target.exchange_signature, candidate.exchange_signature)) {
|
|
751
|
+
matchScore += 40;
|
|
752
|
+
matchReasons.push('same_exchange_signature');
|
|
753
|
+
if (!decisionHint) {
|
|
754
|
+
decisionHint = 'manual_review';
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
const hasOverlappingName = intersects(target.normalized_names, candidate.normalized_names);
|
|
758
|
+
if (hasOverlappingName) {
|
|
759
|
+
matchScore += 20;
|
|
760
|
+
matchReasons.push('overlapping_name');
|
|
761
|
+
if (!decisionHint) {
|
|
762
|
+
decisionHint = 'manual_review';
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const targetReferenceFields = Object.values(target.fields)
|
|
766
|
+
.flat()
|
|
767
|
+
.filter((value) => typeof value === 'string')
|
|
768
|
+
.map(normalizeText)
|
|
769
|
+
.filter(Boolean);
|
|
770
|
+
const candidateReferenceFields = Object.values(candidate.fields)
|
|
771
|
+
.flat()
|
|
772
|
+
.filter((value) => typeof value === 'string')
|
|
773
|
+
.map(normalizeText)
|
|
774
|
+
.filter(Boolean);
|
|
775
|
+
const hasOverlappingIdentityField = intersects(targetReferenceFields, candidateReferenceFields);
|
|
776
|
+
if (hasOverlappingIdentityField) {
|
|
777
|
+
matchScore += 10;
|
|
778
|
+
matchReasons.push('overlapping_identity_field');
|
|
779
|
+
}
|
|
780
|
+
if (kind === 'process' &&
|
|
781
|
+
decisionHint === 'manual_review' &&
|
|
782
|
+
matchReasons.includes('same_exchange_signature') &&
|
|
783
|
+
hasOverlappingIdentityField) {
|
|
784
|
+
matchScore += 20;
|
|
785
|
+
matchReasons.push('same_exchange_fingerprint');
|
|
786
|
+
decisionHint = 'block_duplicate';
|
|
787
|
+
}
|
|
788
|
+
if (kind === 'flow' &&
|
|
789
|
+
(decisionHint === null || decisionHint === 'manual_review') &&
|
|
790
|
+
hasEquivalentFlowCore(target, candidate)) {
|
|
791
|
+
matchScore += 70;
|
|
792
|
+
matchReasons.push('equivalent_flow_core_fields');
|
|
793
|
+
decisionHint = 'block_duplicate';
|
|
794
|
+
}
|
|
795
|
+
const findings = [];
|
|
796
|
+
if (decisionHint === 'block_duplicate') {
|
|
797
|
+
findings.push({
|
|
798
|
+
code: `${kind}_duplicate_candidate`,
|
|
799
|
+
severity: 'blocker',
|
|
800
|
+
message: `Candidate ${index} matches the target identity and should block new ${kind} creation.`,
|
|
801
|
+
candidate_index: index,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
else if (decisionHint === 'manual_review') {
|
|
805
|
+
findings.push({
|
|
806
|
+
code: `${kind}_manual_review_candidate`,
|
|
807
|
+
severity: 'warning',
|
|
808
|
+
message: `Candidate ${index} is similar enough to require manual review before new ${kind} creation.`,
|
|
809
|
+
candidate_index: index,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
else if (decisionHint) {
|
|
813
|
+
findings.push({
|
|
814
|
+
code: `${kind}_${decisionHint}`,
|
|
815
|
+
severity: 'info',
|
|
816
|
+
message: `Candidate ${index} supports decision ${decisionHint}.`,
|
|
817
|
+
candidate_index: index,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
report: {
|
|
822
|
+
index,
|
|
823
|
+
id: candidate.id,
|
|
824
|
+
version: candidate.version,
|
|
825
|
+
state_code: candidate.state_code,
|
|
826
|
+
identity_key: candidate.identity_key,
|
|
827
|
+
match_score: matchScore,
|
|
828
|
+
match_reasons: matchReasons,
|
|
829
|
+
decision_hint: decisionHint,
|
|
830
|
+
},
|
|
831
|
+
findings,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
function chooseDecision(evaluations, validation, kind) {
|
|
835
|
+
const findings = evaluations.flatMap((evaluation) => evaluation.findings);
|
|
836
|
+
if (validation.status === 'failed') {
|
|
837
|
+
return {
|
|
838
|
+
decision: 'manual_review',
|
|
839
|
+
confidence: 'high',
|
|
840
|
+
findings: [
|
|
841
|
+
{
|
|
842
|
+
code: `${kind}_schema_invalid`,
|
|
843
|
+
severity: 'blocker',
|
|
844
|
+
message: `Target ${kind} payload failed schema validation.`,
|
|
845
|
+
},
|
|
846
|
+
...findings,
|
|
847
|
+
],
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
const sorted = [...evaluations].sort((left, right) => right.report.match_score - left.report.match_score);
|
|
851
|
+
const top = sorted[0]?.report;
|
|
852
|
+
if (!top || top.match_score === 0) {
|
|
853
|
+
return {
|
|
854
|
+
decision: 'create_new',
|
|
855
|
+
confidence: 'medium',
|
|
856
|
+
findings: [
|
|
857
|
+
{
|
|
858
|
+
code: `${kind}_no_duplicate_candidate`,
|
|
859
|
+
severity: 'info',
|
|
860
|
+
message: `No duplicate ${kind} candidate matched the target identity.`,
|
|
861
|
+
},
|
|
862
|
+
...findings,
|
|
863
|
+
],
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
if (top.decision_hint === 'block_duplicate') {
|
|
867
|
+
return {
|
|
868
|
+
decision: 'block_duplicate',
|
|
869
|
+
confidence: top.match_score >= 90 ? 'high' : 'medium',
|
|
870
|
+
findings,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
if (top.decision_hint === 'reuse' ||
|
|
874
|
+
top.decision_hint === 'update_same_row' ||
|
|
875
|
+
top.decision_hint === 'version_bump') {
|
|
876
|
+
return {
|
|
877
|
+
decision: top.decision_hint,
|
|
878
|
+
confidence: top.match_score >= 90 ? 'high' : 'medium',
|
|
879
|
+
findings,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
return {
|
|
883
|
+
decision: 'manual_review',
|
|
884
|
+
confidence: top.match_score >= 60 ? 'medium' : 'low',
|
|
885
|
+
findings,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
function statusForDecision(decision, blockers) {
|
|
889
|
+
if (blockers.length > 0 || decision === 'block_duplicate') {
|
|
890
|
+
return 'blocked';
|
|
891
|
+
}
|
|
892
|
+
if (decision === 'manual_review') {
|
|
893
|
+
return 'needs_review';
|
|
894
|
+
}
|
|
895
|
+
return 'passed';
|
|
896
|
+
}
|
|
897
|
+
function nextActionForDecision(decision) {
|
|
898
|
+
if (decision === 'reuse') {
|
|
899
|
+
return 'reuse_existing';
|
|
900
|
+
}
|
|
901
|
+
if (decision === 'update_same_row') {
|
|
902
|
+
return 'repair_existing_draft';
|
|
903
|
+
}
|
|
904
|
+
if (decision === 'version_bump') {
|
|
905
|
+
return 'prepare_version_update';
|
|
906
|
+
}
|
|
907
|
+
if (decision === 'block_duplicate') {
|
|
908
|
+
return 'stop_duplicate';
|
|
909
|
+
}
|
|
910
|
+
if (decision === 'manual_review') {
|
|
911
|
+
return 'queue_manual_review';
|
|
912
|
+
}
|
|
913
|
+
return 'materialize_new_payload';
|
|
914
|
+
}
|
|
915
|
+
function writeArtifacts(report, outDir) {
|
|
916
|
+
if (!outDir) {
|
|
917
|
+
return {
|
|
918
|
+
identity_decision: null,
|
|
919
|
+
candidates: null,
|
|
920
|
+
candidate_sources: null,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
const resolved = path.resolve(outDir);
|
|
924
|
+
const files = {
|
|
925
|
+
identity_decision: path.join(resolved, 'outputs', 'identity-decision.json'),
|
|
926
|
+
candidates: path.join(resolved, 'outputs', 'identity-candidates.jsonl'),
|
|
927
|
+
candidate_sources: path.join(resolved, 'outputs', 'identity-candidate-sources.json'),
|
|
928
|
+
};
|
|
929
|
+
writeJsonArtifact(files.identity_decision, { ...report, files });
|
|
930
|
+
writeJsonLinesArtifact(files.candidates, report.candidates);
|
|
931
|
+
writeJsonArtifact(files.candidate_sources, report.candidate_sources);
|
|
932
|
+
return files;
|
|
933
|
+
}
|
|
934
|
+
export async function runIdentityPreflight(kind, options) {
|
|
935
|
+
const inputPath = requiredInputPath(options.inputPath);
|
|
936
|
+
const normalizedInput = normalizePreflightInput(options.rawInput ?? readJsonInput(inputPath), kind);
|
|
937
|
+
const targetProfile = profileForKind(normalizedInput.target, kind);
|
|
938
|
+
const remoteCandidateSearch = mergeRemoteCandidateSearchConfig(normalizedInput.remoteCandidateSearch, options);
|
|
939
|
+
const candidateSourceReads = [
|
|
940
|
+
...normalizedInput.candidateInputPaths,
|
|
941
|
+
...(options.candidateInputPaths ?? []),
|
|
942
|
+
].map(readCandidateSource);
|
|
943
|
+
const remoteCandidateRead = await readRemoteCandidateSource(kind, targetProfile, remoteCandidateSearch, options);
|
|
944
|
+
const candidateSources = [
|
|
945
|
+
...(normalizedInput.candidates.length > 0
|
|
946
|
+
? [
|
|
947
|
+
{
|
|
948
|
+
path: path.resolve(inputPath),
|
|
949
|
+
kind: 'embedded_request',
|
|
950
|
+
row_count: normalizedInput.candidates.length,
|
|
951
|
+
scanned_files: [],
|
|
952
|
+
},
|
|
953
|
+
]
|
|
954
|
+
: []),
|
|
955
|
+
...candidateSourceReads.map((entry) => entry.source),
|
|
956
|
+
...(remoteCandidateRead ? [remoteCandidateRead.source] : []),
|
|
957
|
+
];
|
|
958
|
+
const candidates = [
|
|
959
|
+
...normalizedInput.candidates,
|
|
960
|
+
...candidateSourceReads.flatMap((entry) => entry.rows),
|
|
961
|
+
...(remoteCandidateRead?.rows ?? []),
|
|
962
|
+
];
|
|
963
|
+
const validation = validateTargetSchema(normalizedInput.target, kind, options.schemas);
|
|
964
|
+
const evaluations = candidates.map((candidate, index) => candidateEvaluation(targetProfile, profileForKind(candidate, kind), kind, index));
|
|
965
|
+
const decision = chooseDecision(evaluations, validation, kind);
|
|
966
|
+
const blockers = decision.findings.filter((finding) => finding.severity === 'blocker');
|
|
967
|
+
const baseReport = {
|
|
968
|
+
schema_version: 1,
|
|
969
|
+
generated_at_utc: (options.now ?? new Date()).toISOString(),
|
|
970
|
+
kind,
|
|
971
|
+
status: statusForDecision(decision.decision, blockers),
|
|
972
|
+
decision: decision.decision,
|
|
973
|
+
confidence: decision.confidence,
|
|
974
|
+
input_path: path.resolve(inputPath),
|
|
975
|
+
out_dir: options.outDir ? path.resolve(options.outDir) : null,
|
|
976
|
+
target: {
|
|
977
|
+
id: targetProfile.id,
|
|
978
|
+
version: targetProfile.version,
|
|
979
|
+
identity_key: targetProfile.identity_key,
|
|
980
|
+
exchange_signature: targetProfile.exchange_signature,
|
|
981
|
+
schema_validation: validation,
|
|
982
|
+
},
|
|
983
|
+
candidates: evaluations.map((evaluation) => evaluation.report),
|
|
984
|
+
candidate_sources: candidateSources,
|
|
985
|
+
findings: decision.findings,
|
|
986
|
+
blockers,
|
|
987
|
+
next_action: nextActionForDecision(decision.decision),
|
|
988
|
+
files: {
|
|
989
|
+
identity_decision: null,
|
|
990
|
+
candidates: null,
|
|
991
|
+
candidate_sources: null,
|
|
992
|
+
},
|
|
993
|
+
};
|
|
994
|
+
const files = writeArtifacts(baseReport, options.outDir);
|
|
995
|
+
return {
|
|
996
|
+
...baseReport,
|
|
997
|
+
files,
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
export async function runProcessIdentityPreflight(options) {
|
|
1001
|
+
return (await runIdentityPreflight('process', options));
|
|
1002
|
+
}
|
|
1003
|
+
export async function runFlowIdentityPreflight(options) {
|
|
1004
|
+
return (await runIdentityPreflight('flow', options));
|
|
1005
|
+
}
|
|
1006
|
+
export const __testInternals = {
|
|
1007
|
+
normalizePreflightInput,
|
|
1008
|
+
schemaForKind,
|
|
1009
|
+
entityFactoryExports: ENTITY_FACTORY_EXPORTS,
|
|
1010
|
+
processProfile,
|
|
1011
|
+
flowProfile,
|
|
1012
|
+
candidateEvaluation,
|
|
1013
|
+
chooseDecision,
|
|
1014
|
+
collectCandidateFilesFromStats,
|
|
1015
|
+
readCandidateSource,
|
|
1016
|
+
defaultRemoteQuery,
|
|
1017
|
+
normalizeRemoteCandidateSearch,
|
|
1018
|
+
remoteSearchFilter,
|
|
1019
|
+
rowsFromRemoteSearchResponse,
|
|
1020
|
+
};
|
|
1021
|
+
//# sourceMappingURL=identity-preflight.js.map
|