@tiangong-lca/cli 0.0.1
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 +368 -0
- package/bin/tiangong.d.ts +1 -0
- package/bin/tiangong.js +32 -0
- package/dist/scripts/assert-full-coverage.js +77 -0
- package/dist/scripts/assert-full-coverage.js.map +1 -0
- package/dist/src/cli.js +2740 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/lib/artifacts.js +86 -0
- package/dist/src/lib/artifacts.js.map +1 -0
- package/dist/src/lib/dotenv.js +36 -0
- package/dist/src/lib/dotenv.js.map +1 -0
- package/dist/src/lib/env.js +139 -0
- package/dist/src/lib/env.js.map +1 -0
- package/dist/src/lib/errors.js +38 -0
- package/dist/src/lib/errors.js.map +1 -0
- package/dist/src/lib/flow-build-alias-map.js +299 -0
- package/dist/src/lib/flow-build-alias-map.js.map +1 -0
- package/dist/src/lib/flow-get.js +81 -0
- package/dist/src/lib/flow-get.js.map +1 -0
- package/dist/src/lib/flow-governance.js +164 -0
- package/dist/src/lib/flow-governance.js.map +1 -0
- package/dist/src/lib/flow-list.js +187 -0
- package/dist/src/lib/flow-list.js.map +1 -0
- package/dist/src/lib/flow-publish-reviewed-data.js +808 -0
- package/dist/src/lib/flow-publish-reviewed-data.js.map +1 -0
- package/dist/src/lib/flow-publish-version.js +541 -0
- package/dist/src/lib/flow-publish-version.js.map +1 -0
- package/dist/src/lib/flow-read.js +310 -0
- package/dist/src/lib/flow-read.js.map +1 -0
- package/dist/src/lib/flow-regen-product.js +1396 -0
- package/dist/src/lib/flow-regen-product.js.map +1 -0
- package/dist/src/lib/flow-remediate.js +821 -0
- package/dist/src/lib/flow-remediate.js.map +1 -0
- package/dist/src/lib/http.js +45 -0
- package/dist/src/lib/http.js.map +1 -0
- package/dist/src/lib/io.js +30 -0
- package/dist/src/lib/io.js.map +1 -0
- package/dist/src/lib/kb-search.js +83 -0
- package/dist/src/lib/kb-search.js.map +1 -0
- package/dist/src/lib/lifecyclemodel-auto-build.js +1328 -0
- package/dist/src/lib/lifecyclemodel-auto-build.js.map +1 -0
- package/dist/src/lib/lifecyclemodel-orchestrate.js +1556 -0
- package/dist/src/lib/lifecyclemodel-orchestrate.js.map +1 -0
- package/dist/src/lib/lifecyclemodel-publish-build.js +296 -0
- package/dist/src/lib/lifecyclemodel-publish-build.js.map +1 -0
- package/dist/src/lib/lifecyclemodel-publish-resulting-process.js +101 -0
- package/dist/src/lib/lifecyclemodel-publish-resulting-process.js.map +1 -0
- package/dist/src/lib/lifecyclemodel-resulting-process.js +1152 -0
- package/dist/src/lib/lifecyclemodel-resulting-process.js.map +1 -0
- package/dist/src/lib/lifecyclemodel-validate-build.js +238 -0
- package/dist/src/lib/lifecyclemodel-validate-build.js.map +1 -0
- package/dist/src/lib/llm.js +383 -0
- package/dist/src/lib/llm.js.map +1 -0
- package/dist/src/lib/process-auto-build.js +717 -0
- package/dist/src/lib/process-auto-build.js.map +1 -0
- package/dist/src/lib/process-batch-build.js +409 -0
- package/dist/src/lib/process-batch-build.js.map +1 -0
- package/dist/src/lib/process-get.js +66 -0
- package/dist/src/lib/process-get.js.map +1 -0
- package/dist/src/lib/process-publish-build.js +451 -0
- package/dist/src/lib/process-publish-build.js.map +1 -0
- package/dist/src/lib/process-resume-build.js +364 -0
- package/dist/src/lib/process-resume-build.js.map +1 -0
- package/dist/src/lib/publish.js +562 -0
- package/dist/src/lib/publish.js.map +1 -0
- package/dist/src/lib/remote.js +88 -0
- package/dist/src/lib/remote.js.map +1 -0
- package/dist/src/lib/review-flow.js +1110 -0
- package/dist/src/lib/review-flow.js.map +1 -0
- package/dist/src/lib/review-lifecyclemodel.js +727 -0
- package/dist/src/lib/review-lifecyclemodel.js.map +1 -0
- package/dist/src/lib/review-process.js +702 -0
- package/dist/src/lib/review-process.js.map +1 -0
- package/dist/src/lib/run.js +106 -0
- package/dist/src/lib/run.js.map +1 -0
- package/dist/src/lib/state-lock.js +150 -0
- package/dist/src/lib/state-lock.js.map +1 -0
- package/dist/src/lib/supabase-client.js +269 -0
- package/dist/src/lib/supabase-client.js.map +1 -0
- package/dist/src/lib/supabase-json-ordered-write.js +186 -0
- package/dist/src/lib/supabase-json-ordered-write.js.map +1 -0
- package/dist/src/lib/supabase-rest.js +138 -0
- package/dist/src/lib/supabase-rest.js.map +1 -0
- package/dist/src/lib/supabase-session.js +424 -0
- package/dist/src/lib/supabase-session.js.map +1 -0
- package/dist/src/lib/tidas-sdk-package-validator.js +493 -0
- package/dist/src/lib/tidas-sdk-package-validator.js.map +1 -0
- package/dist/src/lib/unstructured.js +203 -0
- package/dist/src/lib/unstructured.js.map +1 -0
- package/dist/src/lib/user-api-key.js +75 -0
- package/dist/src/lib/user-api-key.js.map +1 -0
- package/dist/src/lib/validation.js +191 -0
- package/dist/src/lib/validation.js.map +1 -0
- package/dist/src/main.js +38 -0
- package/dist/src/main.js.map +1 -0
- package/package.json +81 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { writeJsonArtifact, writeTextArtifact } from './artifacts.js';
|
|
4
|
+
import { CliError } from './errors.js';
|
|
5
|
+
import { readJsonInput } from './io.js';
|
|
6
|
+
import { invokeLlm, readLlmRuntimeEnv } from './llm.js';
|
|
7
|
+
const KIND_RE = /\[tg_io_kind_tag=([^\]]+)\]/gu;
|
|
8
|
+
const UOM_RE = /\[tg_io_uom_tag=([^\]]+)\]/gu;
|
|
9
|
+
const ENERGY_WORDS = [
|
|
10
|
+
'electric',
|
|
11
|
+
'electricity',
|
|
12
|
+
'kwh',
|
|
13
|
+
'mj',
|
|
14
|
+
'heat',
|
|
15
|
+
'steam',
|
|
16
|
+
'fuel',
|
|
17
|
+
'diesel',
|
|
18
|
+
'gas',
|
|
19
|
+
'power',
|
|
20
|
+
'电',
|
|
21
|
+
'能',
|
|
22
|
+
];
|
|
23
|
+
const RAW_WORDS = [
|
|
24
|
+
'raw material',
|
|
25
|
+
'feedstock',
|
|
26
|
+
'fertilizer',
|
|
27
|
+
'water',
|
|
28
|
+
'seed',
|
|
29
|
+
'pesticide',
|
|
30
|
+
'原材料',
|
|
31
|
+
'投入',
|
|
32
|
+
'肥料',
|
|
33
|
+
'种子',
|
|
34
|
+
'农药',
|
|
35
|
+
'用水',
|
|
36
|
+
];
|
|
37
|
+
const BYP_WORDS = ['by-product', 'co-product', '副产品', '联产'];
|
|
38
|
+
const WASTE_WORDS = ['waste', '废', 'residue', 'sludge'];
|
|
39
|
+
function isRecord(value) {
|
|
40
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
41
|
+
}
|
|
42
|
+
function requiredNonEmpty(value, label, code) {
|
|
43
|
+
if (typeof value === 'string' && value.trim()) {
|
|
44
|
+
return value.trim();
|
|
45
|
+
}
|
|
46
|
+
throw new CliError(`Missing required ${label}.`, {
|
|
47
|
+
code,
|
|
48
|
+
exitCode: 2,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function textFromValue(value) {
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
return value
|
|
54
|
+
.filter(isRecord)
|
|
55
|
+
.map((item) => String(item['#text'] ?? ''))
|
|
56
|
+
.join(' ');
|
|
57
|
+
}
|
|
58
|
+
if (isRecord(value)) {
|
|
59
|
+
return String(value['#text'] ?? '');
|
|
60
|
+
}
|
|
61
|
+
return String(value ?? '');
|
|
62
|
+
}
|
|
63
|
+
function toNumber(value) {
|
|
64
|
+
if (typeof value === 'number') {
|
|
65
|
+
return Number.isFinite(value) ? value : 0;
|
|
66
|
+
}
|
|
67
|
+
if (typeof value === 'string' && value.trim()) {
|
|
68
|
+
const parsed = Number(value);
|
|
69
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
70
|
+
}
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
function deepGet(value, pathParts) {
|
|
74
|
+
let current = value;
|
|
75
|
+
for (const key of pathParts) {
|
|
76
|
+
if (!isRecord(current)) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
current = current[key];
|
|
80
|
+
if (current === undefined || current === null) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return current;
|
|
85
|
+
}
|
|
86
|
+
function hasNonEmpty(value) {
|
|
87
|
+
if (value === null || value === undefined) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (typeof value === 'string') {
|
|
91
|
+
return value.trim().length > 0;
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
return value.some((item) => hasNonEmpty(item));
|
|
95
|
+
}
|
|
96
|
+
if (isRecord(value)) {
|
|
97
|
+
if ('#text' in value) {
|
|
98
|
+
return hasNonEmpty(value['#text']);
|
|
99
|
+
}
|
|
100
|
+
return Object.values(value).some((item) => hasNonEmpty(item));
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
function extractBaseNames(processPayload) {
|
|
105
|
+
const base = deepGet(processPayload, [
|
|
106
|
+
'processDataSet',
|
|
107
|
+
'processInformation',
|
|
108
|
+
'dataSetInformation',
|
|
109
|
+
'name',
|
|
110
|
+
'baseName',
|
|
111
|
+
]);
|
|
112
|
+
const items = Array.isArray(base) ? base : base ? [base] : [];
|
|
113
|
+
let zh = false;
|
|
114
|
+
let en = false;
|
|
115
|
+
const values = [];
|
|
116
|
+
items.forEach((item) => {
|
|
117
|
+
if (!isRecord(item)) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const lang = String(item['@xml:lang'] ?? '').toLowerCase();
|
|
121
|
+
const text = String(item['#text'] ?? '').trim();
|
|
122
|
+
if (!text) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
values.push(text);
|
|
126
|
+
if (lang.startsWith('zh')) {
|
|
127
|
+
zh = true;
|
|
128
|
+
}
|
|
129
|
+
if (lang.startsWith('en')) {
|
|
130
|
+
en = true;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
if (!(zh && en) && values.length >= 2) {
|
|
134
|
+
zh = zh || true;
|
|
135
|
+
en = en || true;
|
|
136
|
+
}
|
|
137
|
+
return [zh, en, values];
|
|
138
|
+
}
|
|
139
|
+
function classifyExchange(exchange) {
|
|
140
|
+
const comments = `${textFromValue(exchange.commonComment)} ${textFromValue(exchange.generalComment)}`.toLowerCase();
|
|
141
|
+
const flowDescription = textFromValue(isRecord(exchange.referenceToFlowDataSet)
|
|
142
|
+
? exchange.referenceToFlowDataSet['common:shortDescription']
|
|
143
|
+
: undefined).toLowerCase();
|
|
144
|
+
const blob = `${comments} ${flowDescription}`.trim();
|
|
145
|
+
const kinds = Array.from(blob.matchAll(KIND_RE), (match) => match[1].toLowerCase());
|
|
146
|
+
const kindSet = new Set(kinds);
|
|
147
|
+
const uoms = Array.from(blob.matchAll(UOM_RE), (match) => match[1].toLowerCase());
|
|
148
|
+
const direction = String(exchange.exchangeDirection ?? '').toLowerCase();
|
|
149
|
+
const isEnergy = ENERGY_WORDS.some((word) => blob.includes(word)) ||
|
|
150
|
+
uoms.some((uom) => uom === 'kwh' || uom === 'mj' || uom === 'gj');
|
|
151
|
+
if (direction === 'input') {
|
|
152
|
+
if (kindSet.has('energy') || isEnergy) {
|
|
153
|
+
return { classification: 'energy_input', kinds, uoms, blob };
|
|
154
|
+
}
|
|
155
|
+
if (kindSet.has('waste')) {
|
|
156
|
+
return { classification: 'other_input', kinds, uoms, blob };
|
|
157
|
+
}
|
|
158
|
+
if (kindSet.has('raw_material') || kindSet.has('resource')) {
|
|
159
|
+
return { classification: 'raw_material_input', kinds, uoms, blob };
|
|
160
|
+
}
|
|
161
|
+
if (kindSet.has('product') || RAW_WORDS.some((word) => blob.includes(word))) {
|
|
162
|
+
return { classification: 'raw_material_input', kinds, uoms, blob };
|
|
163
|
+
}
|
|
164
|
+
return { classification: 'other_input', kinds, uoms, blob };
|
|
165
|
+
}
|
|
166
|
+
if (direction === 'output') {
|
|
167
|
+
if (kindSet.has('waste') || WASTE_WORDS.some((word) => blob.includes(word))) {
|
|
168
|
+
return { classification: 'waste_output', kinds, uoms, blob };
|
|
169
|
+
}
|
|
170
|
+
if (BYP_WORDS.some((word) => blob.includes(word))) {
|
|
171
|
+
return { classification: 'byproduct_output', kinds, uoms, blob };
|
|
172
|
+
}
|
|
173
|
+
if (kindSet.has('product')) {
|
|
174
|
+
return { classification: 'product_output', kinds, uoms, blob };
|
|
175
|
+
}
|
|
176
|
+
return { classification: 'other_output', kinds, uoms, blob };
|
|
177
|
+
}
|
|
178
|
+
return { classification: 'other', kinds, uoms, blob };
|
|
179
|
+
}
|
|
180
|
+
function unitIssueCheck(exchange, uoms, blob) {
|
|
181
|
+
const flowUuid = isRecord(exchange.referenceToFlowDataSet)
|
|
182
|
+
? String(exchange.referenceToFlowDataSet['@refObjectId'] ?? '')
|
|
183
|
+
: '';
|
|
184
|
+
if (!flowUuid) {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
const currentUnit = uoms[0] ?? '';
|
|
188
|
+
if (['electric', 'electricity', '交流电', '电力'].some((word) => blob.includes(word)) &&
|
|
189
|
+
currentUnit &&
|
|
190
|
+
!['kwh', 'mj', 'gj'].includes(currentUnit)) {
|
|
191
|
+
return [
|
|
192
|
+
{
|
|
193
|
+
flow_uuid: flowUuid,
|
|
194
|
+
current_unit: currentUnit,
|
|
195
|
+
suggested_unit: 'kWh',
|
|
196
|
+
basis: 'flow 描述为电力/电能,但 uom 标签非能量单位',
|
|
197
|
+
confidence: '高',
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
if (['water', '用水', '工艺用水'].some((word) => blob.includes(word)) &&
|
|
202
|
+
currentUnit &&
|
|
203
|
+
['kwh', 'mj', 'gj'].includes(currentUnit)) {
|
|
204
|
+
return [
|
|
205
|
+
{
|
|
206
|
+
flow_uuid: flowUuid,
|
|
207
|
+
current_unit: currentUnit,
|
|
208
|
+
suggested_unit: 'm3 或 kg',
|
|
209
|
+
basis: 'flow 描述为水,但 uom 标签为能量单位',
|
|
210
|
+
confidence: '高',
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
if (['co2', 'carbon dioxide', '二氧化碳'].some((word) => blob.includes(word)) &&
|
|
215
|
+
currentUnit &&
|
|
216
|
+
['kwh', 'mj', 'gj'].includes(currentUnit)) {
|
|
217
|
+
return [
|
|
218
|
+
{
|
|
219
|
+
flow_uuid: flowUuid,
|
|
220
|
+
current_unit: currentUnit,
|
|
221
|
+
suggested_unit: 'kg',
|
|
222
|
+
basis: '排放流通常质量单位计,当前为能量单位',
|
|
223
|
+
confidence: '中',
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
function baseInfoCheck(processPayload) {
|
|
230
|
+
const [zhOk, enOk, values] = extractBaseNames(processPayload);
|
|
231
|
+
const functionalUnit = deepGet(processPayload, [
|
|
232
|
+
'processDataSet',
|
|
233
|
+
'processInformation',
|
|
234
|
+
'quantitativeReference',
|
|
235
|
+
'functionalUnitOrOther',
|
|
236
|
+
]);
|
|
237
|
+
const mixAndLocation = deepGet(processPayload, [
|
|
238
|
+
'processDataSet',
|
|
239
|
+
'processInformation',
|
|
240
|
+
'geography',
|
|
241
|
+
'mixAndLocationTypes',
|
|
242
|
+
]);
|
|
243
|
+
const route = deepGet(processPayload, [
|
|
244
|
+
'processDataSet',
|
|
245
|
+
'modellingAndValidation',
|
|
246
|
+
'LCIMethodAndAllocation',
|
|
247
|
+
'typeOfDataSet',
|
|
248
|
+
]);
|
|
249
|
+
const time = deepGet(processPayload, ['processDataSet', 'processInformation', 'time']);
|
|
250
|
+
const geography = deepGet(processPayload, ['processDataSet', 'processInformation', 'geography']);
|
|
251
|
+
const technology = deepGet(processPayload, ['processDataSet', 'modellingAndValidation']);
|
|
252
|
+
const administrativeInformation = deepGet(processPayload, [
|
|
253
|
+
'processDataSet',
|
|
254
|
+
'administrativeInformation',
|
|
255
|
+
]);
|
|
256
|
+
const nameOk = zhOk && enOk;
|
|
257
|
+
const functionalUnitOk = hasNonEmpty(functionalUnit);
|
|
258
|
+
const systemBoundaryOk = hasNonEmpty(mixAndLocation) || hasNonEmpty(route);
|
|
259
|
+
const timeOk = hasNonEmpty(time);
|
|
260
|
+
const geographyOk = hasNonEmpty(geography);
|
|
261
|
+
const technologyOk = hasNonEmpty(technology);
|
|
262
|
+
const administrativeOk = hasNonEmpty(administrativeInformation);
|
|
263
|
+
const completenessScore = [
|
|
264
|
+
nameOk,
|
|
265
|
+
functionalUnitOk,
|
|
266
|
+
systemBoundaryOk,
|
|
267
|
+
timeOk,
|
|
268
|
+
geographyOk,
|
|
269
|
+
technologyOk,
|
|
270
|
+
administrativeOk,
|
|
271
|
+
].filter(Boolean).length;
|
|
272
|
+
return {
|
|
273
|
+
name_zh_en_ok: nameOk,
|
|
274
|
+
functional_unit_ok: functionalUnitOk,
|
|
275
|
+
system_boundary_ok: systemBoundaryOk,
|
|
276
|
+
time_ok: timeOk,
|
|
277
|
+
geo_ok: geographyOk,
|
|
278
|
+
tech_ok: technologyOk,
|
|
279
|
+
admin_ok: administrativeOk,
|
|
280
|
+
completeness_score: completenessScore,
|
|
281
|
+
base_names: values,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function unwrapProcessPayload(value, filePath) {
|
|
285
|
+
if (!isRecord(value)) {
|
|
286
|
+
throw new CliError(`Expected process review file to contain a JSON object: ${filePath}`, {
|
|
287
|
+
code: 'PROCESS_REVIEW_INPUT_INVALID',
|
|
288
|
+
exitCode: 2,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const candidate = (isRecord(value.json_ordered) && value.json_ordered) ||
|
|
292
|
+
(isRecord(value.jsonOrdered) && value.jsonOrdered) ||
|
|
293
|
+
(isRecord(value.json) && value.json) ||
|
|
294
|
+
value;
|
|
295
|
+
if (!isRecord(candidate.processDataSet)) {
|
|
296
|
+
throw new CliError(`Process review file is missing processDataSet: ${filePath}`, {
|
|
297
|
+
code: 'PROCESS_REVIEW_INPUT_INVALID',
|
|
298
|
+
exitCode: 2,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return candidate;
|
|
302
|
+
}
|
|
303
|
+
function readProcessFiles(runRoot) {
|
|
304
|
+
const processDir = path.join(runRoot, 'exports', 'processes');
|
|
305
|
+
if (!existsSync(processDir) || !statSync(processDir).isDirectory()) {
|
|
306
|
+
throw new CliError(`Process review exports directory not found: ${processDir}`, {
|
|
307
|
+
code: 'PROCESS_REVIEW_EXPORTS_NOT_FOUND',
|
|
308
|
+
exitCode: 2,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return readdirSync(processDir)
|
|
312
|
+
.filter((entry) => entry.endsWith('.json'))
|
|
313
|
+
.sort()
|
|
314
|
+
.map((entry) => path.join(processDir, entry));
|
|
315
|
+
}
|
|
316
|
+
function buildPrompt(processSummaries) {
|
|
317
|
+
return [
|
|
318
|
+
'请基于以下 process 摘要做语义一致性审核(中英名称一致性、边界表达、修订建议)。',
|
|
319
|
+
'要求:',
|
|
320
|
+
'1) 只根据给定摘要;',
|
|
321
|
+
'2) 证据不足必须明确标注;',
|
|
322
|
+
'3) 输出 JSON,格式:{"findings":[{"process_file","severity","fixability","evidence","action"}]}。',
|
|
323
|
+
'',
|
|
324
|
+
`输入摘要:\n${JSON.stringify(processSummaries, null, 2)}`,
|
|
325
|
+
].join('\n');
|
|
326
|
+
}
|
|
327
|
+
function parseLlmJsonOutput(output) {
|
|
328
|
+
const start = output.indexOf('{');
|
|
329
|
+
const end = output.lastIndexOf('}');
|
|
330
|
+
const candidate = start >= 0 && end > start ? output.slice(start, end + 1) : output;
|
|
331
|
+
try {
|
|
332
|
+
const parsed = JSON.parse(candidate);
|
|
333
|
+
return isRecord(parsed) ? parsed : null;
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function runOptionalLlmReview(processSummaries, options) {
|
|
340
|
+
if (!options.enableLlm) {
|
|
341
|
+
return {
|
|
342
|
+
enabled: false,
|
|
343
|
+
reason: 'disabled',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
let env = readLlmRuntimeEnv(options.env);
|
|
347
|
+
if (options.llmModel) {
|
|
348
|
+
env = {
|
|
349
|
+
...env,
|
|
350
|
+
model: options.llmModel,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
const response = await invokeLlm({
|
|
355
|
+
env,
|
|
356
|
+
input: {
|
|
357
|
+
prompt: '你是严谨的LCA审核助手。只给基于输入证据的判断,不得臆造。输出必须是JSON对象。',
|
|
358
|
+
context: buildPrompt(processSummaries),
|
|
359
|
+
},
|
|
360
|
+
fetchImpl: options.fetchImpl,
|
|
361
|
+
timeoutMs: 45_000,
|
|
362
|
+
cacheDir: path.join(options.outDir, '.llm-cache'),
|
|
363
|
+
tracePath: path.join(options.outDir, 'llm-trace.jsonl'),
|
|
364
|
+
module: 'review-process',
|
|
365
|
+
stage: 'semantic-review',
|
|
366
|
+
runId: 'review-process',
|
|
367
|
+
});
|
|
368
|
+
const parsed = parseLlmJsonOutput(response.output);
|
|
369
|
+
if (!parsed) {
|
|
370
|
+
return {
|
|
371
|
+
enabled: true,
|
|
372
|
+
ok: false,
|
|
373
|
+
reason: 'llm_non_json_output',
|
|
374
|
+
raw: response.output.slice(0, 8_000),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
enabled: true,
|
|
379
|
+
ok: true,
|
|
380
|
+
result: parsed,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
return {
|
|
385
|
+
enabled: true,
|
|
386
|
+
ok: false,
|
|
387
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function formatPercent(value) {
|
|
392
|
+
return value === null ? '' : `${(value * 100).toFixed(2)}%`;
|
|
393
|
+
}
|
|
394
|
+
function renderZhReview(options) {
|
|
395
|
+
const lines = [
|
|
396
|
+
'# one_flow_rerun_review_v2_1_zh\n',
|
|
397
|
+
`- run_id: \`${options.runId}\`\n`,
|
|
398
|
+
`- logic_version: \`${options.logicVersion}\`\n`,
|
|
399
|
+
'\n## 2.1 基础信息核查\n',
|
|
400
|
+
'|process file|中英名称|功能单位|系统边界|时间|地理|技术|管理元数据|完整性得分(0-7)|\n',
|
|
401
|
+
'|---|---|---|---|---|---|---|---|---:|\n',
|
|
402
|
+
];
|
|
403
|
+
options.baseRows.forEach(([fileName, base]) => {
|
|
404
|
+
lines.push(`|${fileName}|${base.name_zh_en_ok ? '✅' : '❌'}|${base.functional_unit_ok ? '✅' : '❌'}|${base.system_boundary_ok ? '✅' : '❌'}|${base.time_ok ? '✅' : '❌'}|${base.geo_ok ? '✅' : '❌'}|${base.tech_ok ? '✅' : '❌'}|${base.admin_ok ? '✅' : '❌'}|${base.completeness_score}|\n`);
|
|
405
|
+
});
|
|
406
|
+
lines.push('\n## 物料平衡口径\n- 物料平衡:仅核查 `原材料投入 = 产品+副产品+废物`\n- 能量投入:单列记录,不计入平衡\n');
|
|
407
|
+
lines.push('\n## 分过程结果\n|process file|原材料投入|产品|副产品|废物|能量投入(不计平衡)|差值(输出-投入)|相对偏差|\n|---|---:|---:|---:|---:|---:|---:|---:|\n');
|
|
408
|
+
options.rows.forEach((row) => {
|
|
409
|
+
lines.push(`|${row.process_file}|${row.raw_input.toPrecision(6)}|${row.product.toPrecision(6)}|${row.byproduct.toPrecision(6)}|${row.waste.toPrecision(6)}|${row.energy_excluded.toPrecision(6)}|${row.delta.toPrecision(6)}|${formatPercent(row.relative_deviation)}|\n`);
|
|
410
|
+
});
|
|
411
|
+
lines.push(`\n## 汇总\n- 原材料投入合计: **${options.totals.raw_input.toPrecision(6)}**\n- 产品+副产品+废物合计: **${options.totals.product_plus_byproduct_plus_waste.toPrecision(6)}**\n- 差值(输出-投入): **${options.totals.delta.toPrecision(6)}**\n- 相对偏差: **${formatPercent(options.totals.relative_deviation)}**\n- 能量投入(不计平衡)合计: **${options.totals.energy_excluded.toPrecision(6)}**\n`);
|
|
412
|
+
lines.push('\n## LLM 语义审核层(可选)\n');
|
|
413
|
+
if (options.llmResult.enabled && options.llmResult.ok) {
|
|
414
|
+
const findings = Array.isArray(options.llmResult.result.findings)
|
|
415
|
+
? options.llmResult.result.findings
|
|
416
|
+
: [];
|
|
417
|
+
if (findings.length > 0) {
|
|
418
|
+
lines.push('\n|process file|severity|fixability|evidence|action|\n|---|---|---|---|---|\n');
|
|
419
|
+
findings.slice(0, 50).forEach((finding) => {
|
|
420
|
+
const evidenceValue = isRecord(finding) ? finding.evidence : '';
|
|
421
|
+
const evidence = typeof evidenceValue === 'string'
|
|
422
|
+
? evidenceValue
|
|
423
|
+
: JSON.stringify(evidenceValue ?? '', null, 0);
|
|
424
|
+
const action = isRecord(finding) ? String(finding.action ?? finding.suggestion ?? '') : '';
|
|
425
|
+
lines.push(`|${String(isRecord(finding) ? (finding.process_file ?? '') : '').replaceAll('|', '/')}|${String(isRecord(finding) ? (finding.severity ?? '') : '').replaceAll('|', '/')}|${String(isRecord(finding) ? (finding.fixability ?? 'review-needed') : 'review-needed').replaceAll('|', '/')}|${evidence.replaceAll('|', '/')}|${action.replaceAll('|', '/')}|\n`);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
lines.push(`- 未启用或调用失败:\`${options.llmResult.reason}\`\n`);
|
|
431
|
+
}
|
|
432
|
+
lines.push(`\n## 证据充足的结论\n${options.evidenceStrong.map((item) => `- ${item}`).join('\n')}\n`);
|
|
433
|
+
lines.push(`\n## 证据不足的结论/限制\n${options.evidenceWeak.map((item) => `- ${item}`).join('\n')}\n`);
|
|
434
|
+
return lines.join('');
|
|
435
|
+
}
|
|
436
|
+
function renderEnReview(options) {
|
|
437
|
+
const lines = [
|
|
438
|
+
'# one_flow_rerun_review_v2_1_en\n',
|
|
439
|
+
`- run_id: \`${options.runId}\`\n`,
|
|
440
|
+
`- logic_version: \`${options.logicVersion}\`\n`,
|
|
441
|
+
'\n## 2.1 Basic info checks\n',
|
|
442
|
+
'|process file|zh+en names|functional unit|system boundary|time|geo|tech|admin metadata|completeness(0-7)|\n',
|
|
443
|
+
'|---|---|---|---|---|---|---|---|---:|\n',
|
|
444
|
+
];
|
|
445
|
+
options.baseRows.forEach(([fileName, base]) => {
|
|
446
|
+
lines.push(`|${fileName}|${base.name_zh_en_ok ? '✅' : '❌'}|${base.functional_unit_ok ? '✅' : '❌'}|${base.system_boundary_ok ? '✅' : '❌'}|${base.time_ok ? '✅' : '❌'}|${base.geo_ok ? '✅' : '❌'}|${base.tech_ok ? '✅' : '❌'}|${base.admin_ok ? '✅' : '❌'}|${base.completeness_score}|\n`);
|
|
447
|
+
});
|
|
448
|
+
lines.push('\n## Material balance scope\n- Check only `raw material input = product + by-product + waste`\n- Energy inputs are listed but excluded from balance\n');
|
|
449
|
+
lines.push('\n## Per-process results\n|process file|raw material in|product|by-product|waste|energy in (excluded)|delta(out-in)|relative deviation|\n|---|---:|---:|---:|---:|---:|---:|---:|\n');
|
|
450
|
+
options.rows.forEach((row) => {
|
|
451
|
+
lines.push(`|${row.process_file}|${row.raw_input.toPrecision(6)}|${row.product.toPrecision(6)}|${row.byproduct.toPrecision(6)}|${row.waste.toPrecision(6)}|${row.energy_excluded.toPrecision(6)}|${row.delta.toPrecision(6)}|${formatPercent(row.relative_deviation)}|\n`);
|
|
452
|
+
});
|
|
453
|
+
lines.push(`\n## Summary\n- Raw material input total: **${options.totals.raw_input.toPrecision(6)}**\n- Product+by-product+waste total: **${options.totals.product_plus_byproduct_plus_waste.toPrecision(6)}**\n- Delta (out-in): **${options.totals.delta.toPrecision(6)}**\n- Relative deviation: **${formatPercent(options.totals.relative_deviation)}**\n- Energy input total (excluded from balance): **${options.totals.energy_excluded.toPrecision(6)}**\n`);
|
|
454
|
+
lines.push(`\n## Evidence-sufficient conclusions\n${options.evidenceStrong.map((item) => `- ${item}`).join('\n')}\n`);
|
|
455
|
+
lines.push(`\n## Evidence-insufficient conclusions / limitations\n${options.evidenceWeak.map((item) => `- ${item}`).join('\n')}\n`);
|
|
456
|
+
return lines.join('');
|
|
457
|
+
}
|
|
458
|
+
function renderTiming(options) {
|
|
459
|
+
const lines = ['# one_flow_rerun_timing\n', `- run_id: \`${options.runId}\`\n`];
|
|
460
|
+
if (options.startTs && options.endTs) {
|
|
461
|
+
const start = new Date(options.startTs);
|
|
462
|
+
const end = new Date(options.endTs);
|
|
463
|
+
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
|
464
|
+
throw new CliError('Expected --start-ts and --end-ts to be valid ISO timestamps.', {
|
|
465
|
+
code: 'PROCESS_REVIEW_INVALID_TIMESTAMP',
|
|
466
|
+
exitCode: 2,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
lines.push(`- start: \`${options.startTs}\`\n`);
|
|
470
|
+
lines.push(`- end: \`${options.endTs}\`\n`);
|
|
471
|
+
lines.push(`- total elapsed: **${((end.getTime() - start.getTime()) / 60_000).toFixed(2)} min**\n`);
|
|
472
|
+
}
|
|
473
|
+
lines.push(`- process files reviewed: \`${options.processCount}\`\n`);
|
|
474
|
+
lines.push('- major time consumers: references retrieval, flow matching/search, flow metadata lookups.\n');
|
|
475
|
+
return lines.join('');
|
|
476
|
+
}
|
|
477
|
+
function renderUnitIssues(runId, unitIssues) {
|
|
478
|
+
const lines = [
|
|
479
|
+
'# flow_unit_issue_log\n',
|
|
480
|
+
`- run_id: \`${runId}\`\n`,
|
|
481
|
+
'\n|flow UUID|current unit|suggested unit|basis|confidence|\n|---|---|---|---|---|\n',
|
|
482
|
+
];
|
|
483
|
+
if (unitIssues.length === 0) {
|
|
484
|
+
lines.push('|无|无|无|未发现基于直接证据的单位矛盾|—|\n');
|
|
485
|
+
return lines.join('');
|
|
486
|
+
}
|
|
487
|
+
const seen = new Set();
|
|
488
|
+
unitIssues.forEach((issue) => {
|
|
489
|
+
const key = JSON.stringify(issue);
|
|
490
|
+
if (seen.has(key)) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
seen.add(key);
|
|
494
|
+
lines.push(`|${issue.flow_uuid}|${issue.current_unit}|${issue.suggested_unit}|${issue.basis}|${issue.confidence}|\n`);
|
|
495
|
+
});
|
|
496
|
+
return lines.join('');
|
|
497
|
+
}
|
|
498
|
+
export async function runProcessReview(options) {
|
|
499
|
+
const runRoot = path.resolve(requiredNonEmpty(options.runRoot, '--run-root', 'PROCESS_REVIEW_RUN_ROOT_REQUIRED'));
|
|
500
|
+
const runId = requiredNonEmpty(options.runId, '--run-id', 'PROCESS_REVIEW_RUN_ID_REQUIRED');
|
|
501
|
+
const outDir = path.resolve(requiredNonEmpty(options.outDir, '--out-dir', 'PROCESS_REVIEW_OUT_DIR_REQUIRED'));
|
|
502
|
+
const logicVersion = options.logicVersion?.trim() || 'v2.1';
|
|
503
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
504
|
+
const env = options.env ?? process.env;
|
|
505
|
+
const llmMaxProcesses = typeof options.llmMaxProcesses === 'number' &&
|
|
506
|
+
Number.isInteger(options.llmMaxProcesses) &&
|
|
507
|
+
options.llmMaxProcesses > 0
|
|
508
|
+
? options.llmMaxProcesses
|
|
509
|
+
: 8;
|
|
510
|
+
const processFiles = readProcessFiles(runRoot);
|
|
511
|
+
const baseRows = [];
|
|
512
|
+
const rows = [];
|
|
513
|
+
const unitIssues = [];
|
|
514
|
+
const processSummariesForLlm = [];
|
|
515
|
+
let totalRaw = 0;
|
|
516
|
+
let totalProduct = 0;
|
|
517
|
+
let totalByproduct = 0;
|
|
518
|
+
let totalWaste = 0;
|
|
519
|
+
let totalEnergy = 0;
|
|
520
|
+
processFiles.forEach((filePath) => {
|
|
521
|
+
const processPayload = unwrapProcessPayload(readJsonInput(filePath), filePath);
|
|
522
|
+
const exchangesValue = deepGet(processPayload, ['processDataSet', 'exchanges', 'exchange']);
|
|
523
|
+
const exchanges = Array.isArray(exchangesValue)
|
|
524
|
+
? exchangesValue.filter(isRecord)
|
|
525
|
+
: isRecord(exchangesValue)
|
|
526
|
+
? [exchangesValue]
|
|
527
|
+
: [];
|
|
528
|
+
const base = baseInfoCheck(processPayload);
|
|
529
|
+
const fileName = path.basename(filePath);
|
|
530
|
+
baseRows.push([fileName, base]);
|
|
531
|
+
let rawInput = 0;
|
|
532
|
+
let product = 0;
|
|
533
|
+
let byproduct = 0;
|
|
534
|
+
let waste = 0;
|
|
535
|
+
let energyExcluded = 0;
|
|
536
|
+
exchanges.forEach((exchange) => {
|
|
537
|
+
const classified = classifyExchange(exchange);
|
|
538
|
+
const amount = toNumber(exchange.meanAmount ?? exchange.resultingAmount);
|
|
539
|
+
if (classified.classification === 'raw_material_input') {
|
|
540
|
+
rawInput += amount;
|
|
541
|
+
}
|
|
542
|
+
else if (classified.classification === 'product_output') {
|
|
543
|
+
product += amount;
|
|
544
|
+
}
|
|
545
|
+
else if (classified.classification === 'byproduct_output') {
|
|
546
|
+
byproduct += amount;
|
|
547
|
+
}
|
|
548
|
+
else if (classified.classification === 'waste_output') {
|
|
549
|
+
waste += amount;
|
|
550
|
+
}
|
|
551
|
+
else if (classified.classification === 'energy_input') {
|
|
552
|
+
energyExcluded += amount;
|
|
553
|
+
}
|
|
554
|
+
unitIssues.push(...unitIssueCheck(exchange, classified.uoms, classified.blob));
|
|
555
|
+
});
|
|
556
|
+
const balanceOut = product + byproduct + waste;
|
|
557
|
+
const delta = balanceOut - rawInput;
|
|
558
|
+
const relativeDeviation = rawInput > 0 ? Math.abs(delta) / rawInput : null;
|
|
559
|
+
rows.push({
|
|
560
|
+
process_file: fileName,
|
|
561
|
+
raw_input: rawInput,
|
|
562
|
+
product,
|
|
563
|
+
byproduct,
|
|
564
|
+
waste,
|
|
565
|
+
energy_excluded: energyExcluded,
|
|
566
|
+
delta,
|
|
567
|
+
relative_deviation: relativeDeviation,
|
|
568
|
+
});
|
|
569
|
+
totalRaw += rawInput;
|
|
570
|
+
totalProduct += product;
|
|
571
|
+
totalByproduct += byproduct;
|
|
572
|
+
totalWaste += waste;
|
|
573
|
+
totalEnergy += energyExcluded;
|
|
574
|
+
if (processSummariesForLlm.length < Math.max(1, llmMaxProcesses)) {
|
|
575
|
+
processSummariesForLlm.push({
|
|
576
|
+
process_file: fileName,
|
|
577
|
+
base_names: base.base_names.slice(0, 4),
|
|
578
|
+
base_checks: {
|
|
579
|
+
name_zh_en_ok: base.name_zh_en_ok,
|
|
580
|
+
functional_unit_ok: base.functional_unit_ok,
|
|
581
|
+
system_boundary_ok: base.system_boundary_ok,
|
|
582
|
+
time_ok: base.time_ok,
|
|
583
|
+
geo_ok: base.geo_ok,
|
|
584
|
+
tech_ok: base.tech_ok,
|
|
585
|
+
admin_ok: base.admin_ok,
|
|
586
|
+
},
|
|
587
|
+
balance: {
|
|
588
|
+
raw_in: rawInput,
|
|
589
|
+
product,
|
|
590
|
+
byproduct,
|
|
591
|
+
waste,
|
|
592
|
+
energy_excluded: energyExcluded,
|
|
593
|
+
relative_deviation: relativeDeviation,
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
const totals = {
|
|
599
|
+
raw_input: totalRaw,
|
|
600
|
+
product_plus_byproduct_plus_waste: totalProduct + totalByproduct + totalWaste,
|
|
601
|
+
delta: totalProduct + totalByproduct + totalWaste - totalRaw,
|
|
602
|
+
relative_deviation: totalRaw > 0
|
|
603
|
+
? Math.abs(totalProduct + totalByproduct + totalWaste - totalRaw) / totalRaw
|
|
604
|
+
: null,
|
|
605
|
+
energy_excluded: totalEnergy,
|
|
606
|
+
};
|
|
607
|
+
const evidenceStrong = [
|
|
608
|
+
'已基于 exchange 的 comment 标签/描述做口径过滤,仅核算 原材料投入 vs 产品+副产品+废物,能量单列不计入平衡。',
|
|
609
|
+
...(unitIssues.length > 0
|
|
610
|
+
? ['发现单位疑似错误时均附带 flow 描述与单位标签的直接矛盾证据。']
|
|
611
|
+
: []),
|
|
612
|
+
];
|
|
613
|
+
const evidenceWeak = [
|
|
614
|
+
'部分 exchange 缺少结构化 type 标签,仅能依赖文本关键词分类,存在误判风险。',
|
|
615
|
+
'未逐条拉取 flow 数据集参考单位做机器核对,单位结论以评论标签与流名称语义一致性为主。',
|
|
616
|
+
];
|
|
617
|
+
const llmResult = await runOptionalLlmReview(processSummariesForLlm, {
|
|
618
|
+
enableLlm: Boolean(options.enableLlm),
|
|
619
|
+
llmModel: options.llmModel,
|
|
620
|
+
env,
|
|
621
|
+
fetchImpl,
|
|
622
|
+
outDir,
|
|
623
|
+
});
|
|
624
|
+
const summary = {
|
|
625
|
+
run_id: runId,
|
|
626
|
+
logic_version: logicVersion,
|
|
627
|
+
process_count: processFiles.length,
|
|
628
|
+
totals,
|
|
629
|
+
llm: llmResult,
|
|
630
|
+
};
|
|
631
|
+
const reviewZhPath = writeTextArtifact(path.join(outDir, 'one_flow_rerun_review_v2_1_zh.md'), renderZhReview({
|
|
632
|
+
runId,
|
|
633
|
+
logicVersion,
|
|
634
|
+
baseRows,
|
|
635
|
+
rows,
|
|
636
|
+
totals,
|
|
637
|
+
llmResult,
|
|
638
|
+
evidenceStrong,
|
|
639
|
+
evidenceWeak,
|
|
640
|
+
}));
|
|
641
|
+
const reviewEnPath = writeTextArtifact(path.join(outDir, 'one_flow_rerun_review_v2_1_en.md'), renderEnReview({
|
|
642
|
+
runId,
|
|
643
|
+
logicVersion,
|
|
644
|
+
baseRows,
|
|
645
|
+
rows,
|
|
646
|
+
totals,
|
|
647
|
+
evidenceStrong,
|
|
648
|
+
evidenceWeak,
|
|
649
|
+
}));
|
|
650
|
+
const timingPath = writeTextArtifact(path.join(outDir, 'one_flow_rerun_timing.md'), renderTiming({
|
|
651
|
+
runId,
|
|
652
|
+
startTs: options.startTs,
|
|
653
|
+
endTs: options.endTs,
|
|
654
|
+
processCount: processFiles.length,
|
|
655
|
+
}));
|
|
656
|
+
const unitIssuePath = writeTextArtifact(path.join(outDir, 'flow_unit_issue_log.md'), renderUnitIssues(runId, unitIssues));
|
|
657
|
+
const summaryPath = writeJsonArtifact(path.join(outDir, 'review_summary_v2_1.json'), summary);
|
|
658
|
+
const report = {
|
|
659
|
+
schema_version: 1,
|
|
660
|
+
generated_at_utc: (options.now ?? (() => new Date()))().toISOString(),
|
|
661
|
+
status: 'completed_local_process_review',
|
|
662
|
+
run_id: runId,
|
|
663
|
+
run_root: runRoot,
|
|
664
|
+
out_dir: outDir,
|
|
665
|
+
logic_version: logicVersion,
|
|
666
|
+
process_count: processFiles.length,
|
|
667
|
+
totals,
|
|
668
|
+
files: {
|
|
669
|
+
review_zh: reviewZhPath,
|
|
670
|
+
review_en: reviewEnPath,
|
|
671
|
+
timing: timingPath,
|
|
672
|
+
unit_issue_log: unitIssuePath,
|
|
673
|
+
summary: summaryPath,
|
|
674
|
+
report: '',
|
|
675
|
+
},
|
|
676
|
+
llm: llmResult,
|
|
677
|
+
};
|
|
678
|
+
const reportPath = writeJsonArtifact(path.join(outDir, 'process-review-report.json'), report);
|
|
679
|
+
report.files.report = reportPath;
|
|
680
|
+
writeJsonArtifact(reportPath, report);
|
|
681
|
+
return report;
|
|
682
|
+
}
|
|
683
|
+
export const __testInternals = {
|
|
684
|
+
requiredNonEmpty,
|
|
685
|
+
textFromValue,
|
|
686
|
+
toNumber,
|
|
687
|
+
deepGet,
|
|
688
|
+
hasNonEmpty,
|
|
689
|
+
extractBaseNames,
|
|
690
|
+
classifyExchange,
|
|
691
|
+
unitIssueCheck,
|
|
692
|
+
baseInfoCheck,
|
|
693
|
+
unwrapProcessPayload,
|
|
694
|
+
buildPrompt,
|
|
695
|
+
parseLlmJsonOutput,
|
|
696
|
+
runOptionalLlmReview,
|
|
697
|
+
renderZhReview,
|
|
698
|
+
renderEnReview,
|
|
699
|
+
renderTiming,
|
|
700
|
+
renderUnitIssues,
|
|
701
|
+
};
|
|
702
|
+
//# sourceMappingURL=review-process.js.map
|