@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.
Files changed (96) hide show
  1. package/README.md +368 -0
  2. package/bin/tiangong.d.ts +1 -0
  3. package/bin/tiangong.js +32 -0
  4. package/dist/scripts/assert-full-coverage.js +77 -0
  5. package/dist/scripts/assert-full-coverage.js.map +1 -0
  6. package/dist/src/cli.js +2740 -0
  7. package/dist/src/cli.js.map +1 -0
  8. package/dist/src/lib/artifacts.js +86 -0
  9. package/dist/src/lib/artifacts.js.map +1 -0
  10. package/dist/src/lib/dotenv.js +36 -0
  11. package/dist/src/lib/dotenv.js.map +1 -0
  12. package/dist/src/lib/env.js +139 -0
  13. package/dist/src/lib/env.js.map +1 -0
  14. package/dist/src/lib/errors.js +38 -0
  15. package/dist/src/lib/errors.js.map +1 -0
  16. package/dist/src/lib/flow-build-alias-map.js +299 -0
  17. package/dist/src/lib/flow-build-alias-map.js.map +1 -0
  18. package/dist/src/lib/flow-get.js +81 -0
  19. package/dist/src/lib/flow-get.js.map +1 -0
  20. package/dist/src/lib/flow-governance.js +164 -0
  21. package/dist/src/lib/flow-governance.js.map +1 -0
  22. package/dist/src/lib/flow-list.js +187 -0
  23. package/dist/src/lib/flow-list.js.map +1 -0
  24. package/dist/src/lib/flow-publish-reviewed-data.js +808 -0
  25. package/dist/src/lib/flow-publish-reviewed-data.js.map +1 -0
  26. package/dist/src/lib/flow-publish-version.js +541 -0
  27. package/dist/src/lib/flow-publish-version.js.map +1 -0
  28. package/dist/src/lib/flow-read.js +310 -0
  29. package/dist/src/lib/flow-read.js.map +1 -0
  30. package/dist/src/lib/flow-regen-product.js +1396 -0
  31. package/dist/src/lib/flow-regen-product.js.map +1 -0
  32. package/dist/src/lib/flow-remediate.js +821 -0
  33. package/dist/src/lib/flow-remediate.js.map +1 -0
  34. package/dist/src/lib/http.js +45 -0
  35. package/dist/src/lib/http.js.map +1 -0
  36. package/dist/src/lib/io.js +30 -0
  37. package/dist/src/lib/io.js.map +1 -0
  38. package/dist/src/lib/kb-search.js +83 -0
  39. package/dist/src/lib/kb-search.js.map +1 -0
  40. package/dist/src/lib/lifecyclemodel-auto-build.js +1328 -0
  41. package/dist/src/lib/lifecyclemodel-auto-build.js.map +1 -0
  42. package/dist/src/lib/lifecyclemodel-orchestrate.js +1556 -0
  43. package/dist/src/lib/lifecyclemodel-orchestrate.js.map +1 -0
  44. package/dist/src/lib/lifecyclemodel-publish-build.js +296 -0
  45. package/dist/src/lib/lifecyclemodel-publish-build.js.map +1 -0
  46. package/dist/src/lib/lifecyclemodel-publish-resulting-process.js +101 -0
  47. package/dist/src/lib/lifecyclemodel-publish-resulting-process.js.map +1 -0
  48. package/dist/src/lib/lifecyclemodel-resulting-process.js +1152 -0
  49. package/dist/src/lib/lifecyclemodel-resulting-process.js.map +1 -0
  50. package/dist/src/lib/lifecyclemodel-validate-build.js +238 -0
  51. package/dist/src/lib/lifecyclemodel-validate-build.js.map +1 -0
  52. package/dist/src/lib/llm.js +383 -0
  53. package/dist/src/lib/llm.js.map +1 -0
  54. package/dist/src/lib/process-auto-build.js +717 -0
  55. package/dist/src/lib/process-auto-build.js.map +1 -0
  56. package/dist/src/lib/process-batch-build.js +409 -0
  57. package/dist/src/lib/process-batch-build.js.map +1 -0
  58. package/dist/src/lib/process-get.js +66 -0
  59. package/dist/src/lib/process-get.js.map +1 -0
  60. package/dist/src/lib/process-publish-build.js +451 -0
  61. package/dist/src/lib/process-publish-build.js.map +1 -0
  62. package/dist/src/lib/process-resume-build.js +364 -0
  63. package/dist/src/lib/process-resume-build.js.map +1 -0
  64. package/dist/src/lib/publish.js +562 -0
  65. package/dist/src/lib/publish.js.map +1 -0
  66. package/dist/src/lib/remote.js +88 -0
  67. package/dist/src/lib/remote.js.map +1 -0
  68. package/dist/src/lib/review-flow.js +1110 -0
  69. package/dist/src/lib/review-flow.js.map +1 -0
  70. package/dist/src/lib/review-lifecyclemodel.js +727 -0
  71. package/dist/src/lib/review-lifecyclemodel.js.map +1 -0
  72. package/dist/src/lib/review-process.js +702 -0
  73. package/dist/src/lib/review-process.js.map +1 -0
  74. package/dist/src/lib/run.js +106 -0
  75. package/dist/src/lib/run.js.map +1 -0
  76. package/dist/src/lib/state-lock.js +150 -0
  77. package/dist/src/lib/state-lock.js.map +1 -0
  78. package/dist/src/lib/supabase-client.js +269 -0
  79. package/dist/src/lib/supabase-client.js.map +1 -0
  80. package/dist/src/lib/supabase-json-ordered-write.js +186 -0
  81. package/dist/src/lib/supabase-json-ordered-write.js.map +1 -0
  82. package/dist/src/lib/supabase-rest.js +138 -0
  83. package/dist/src/lib/supabase-rest.js.map +1 -0
  84. package/dist/src/lib/supabase-session.js +424 -0
  85. package/dist/src/lib/supabase-session.js.map +1 -0
  86. package/dist/src/lib/tidas-sdk-package-validator.js +493 -0
  87. package/dist/src/lib/tidas-sdk-package-validator.js.map +1 -0
  88. package/dist/src/lib/unstructured.js +203 -0
  89. package/dist/src/lib/unstructured.js.map +1 -0
  90. package/dist/src/lib/user-api-key.js +75 -0
  91. package/dist/src/lib/user-api-key.js.map +1 -0
  92. package/dist/src/lib/validation.js +191 -0
  93. package/dist/src/lib/validation.js.map +1 -0
  94. package/dist/src/main.js +38 -0
  95. package/dist/src/main.js.map +1 -0
  96. package/package.json +81 -0
@@ -0,0 +1,1110 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { writeJsonArtifact, writeJsonLinesArtifact, writeTextArtifact } from './artifacts.js';
4
+ import { CliError } from './errors.js';
5
+ import { coerceText, deepGet, extractFlowRecord, flowDatasetFromRow, isRecord, listify, loadRowsFromFile, normalizeText, } from './flow-governance.js';
6
+ import { invokeLlm, readLlmRuntimeEnv } from './llm.js';
7
+ const UUID_RE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/u;
8
+ function walkStrings(node) {
9
+ if (typeof node === 'string') {
10
+ return node.trim() ? [node.trim()] : [];
11
+ }
12
+ if (Array.isArray(node)) {
13
+ return node.flatMap((item) => walkStrings(item));
14
+ }
15
+ if (!isRecord(node)) {
16
+ return [];
17
+ }
18
+ const strings = [];
19
+ if (typeof node['#text'] === 'string' && String(node['#text']).trim()) {
20
+ strings.push(String(node['#text']).trim());
21
+ }
22
+ Object.values(node).forEach((value) => {
23
+ strings.push(...walkStrings(value));
24
+ });
25
+ return strings;
26
+ }
27
+ function langTextForLang(value, lang) {
28
+ for (const item of listify(value)) {
29
+ if (!isRecord(item)) {
30
+ continue;
31
+ }
32
+ if (coerceText(item['@xml:lang']).toLowerCase() !== lang.toLowerCase()) {
33
+ continue;
34
+ }
35
+ const text = coerceText(item['#text']);
36
+ if (text) {
37
+ return text;
38
+ }
39
+ }
40
+ for (const item of listify(value)) {
41
+ const text = coerceText(item);
42
+ if (text) {
43
+ return text;
44
+ }
45
+ }
46
+ return '';
47
+ }
48
+ function findUuidInNode(node) {
49
+ if (isRecord(node)) {
50
+ for (const key of ['@refObjectId', '@uri']) {
51
+ const raw = coerceText(node[key]);
52
+ const match = raw.match(UUID_RE);
53
+ if (match) {
54
+ return match[0].toLowerCase();
55
+ }
56
+ }
57
+ }
58
+ const raw = coerceText(node);
59
+ const match = raw.match(UUID_RE);
60
+ return match ? match[0].toLowerCase() : '';
61
+ }
62
+ function flowRoot(doc) {
63
+ return isRecord(doc.flowDataSet) ? { ...doc.flowDataSet } : { ...doc };
64
+ }
65
+ function flowUuid(flow) {
66
+ return coerceText(deepGet(flow, ['flowInformation', 'dataSetInformation', 'common:UUID']));
67
+ }
68
+ function flowVersion(flow) {
69
+ return coerceText(deepGet(flow, [
70
+ 'administrativeInformation',
71
+ 'publicationAndOwnership',
72
+ 'common:dataSetVersion',
73
+ ]));
74
+ }
75
+ function flowType(flow) {
76
+ return (coerceText(deepGet(flow, ['modellingAndValidation', 'LCIMethodAndAllocation', 'typeOfDataSet'])) || coerceText(deepGet(flow, ['modellingAndValidation', 'LCIMethod', 'typeOfDataSet'])));
77
+ }
78
+ function nameNode(flow) {
79
+ return deepGet(flow, ['flowInformation', 'dataSetInformation', 'name']);
80
+ }
81
+ function nameTexts(flow) {
82
+ return walkStrings(nameNode(flow));
83
+ }
84
+ function namePrimary(flow, lang) {
85
+ const name = nameNode(flow);
86
+ if (!isRecord(name)) {
87
+ return '';
88
+ }
89
+ return langTextForLang(name.baseName, lang);
90
+ }
91
+ function nameFingerprint(flow) {
92
+ const name = nameNode(flow);
93
+ if (!isRecord(name)) {
94
+ return '';
95
+ }
96
+ const parts = [];
97
+ for (const key of ['baseName', 'treatmentStandardsRoutes', 'mixAndLocationTypes']) {
98
+ const values = listify(name[key])
99
+ .map((item) => (isRecord(item) ? coerceText(item['#text']) : coerceText(item)))
100
+ .filter(Boolean);
101
+ if (values.length) {
102
+ parts.push(values.join(' | '));
103
+ }
104
+ }
105
+ const text = parts.length ? parts.join(' || ') : nameTexts(flow).join(' || ');
106
+ return normalizeText(text);
107
+ }
108
+ function classificationEntries(flow) {
109
+ return listify(deepGet(flow, [
110
+ 'flowInformation',
111
+ 'dataSetInformation',
112
+ 'classificationInformation',
113
+ 'common:classification',
114
+ 'common:class',
115
+ ]))
116
+ .filter(isRecord)
117
+ .map((item) => ({
118
+ level: coerceText(item['@level']),
119
+ class_id: coerceText(item['@classId']),
120
+ text: coerceText(item['#text']),
121
+ }));
122
+ }
123
+ function elementaryClassEntries(flow) {
124
+ return listify(deepGet(flow, [
125
+ 'flowInformation',
126
+ 'dataSetInformation',
127
+ 'classificationInformation',
128
+ 'common:elementaryFlowCategorization',
129
+ 'common:category',
130
+ ]))
131
+ .filter(isRecord)
132
+ .map((item) => ({
133
+ level: coerceText(item['@level']),
134
+ cat_id: coerceText(item['@catId']),
135
+ text: coerceText(item['#text']),
136
+ }));
137
+ }
138
+ function classificationLeaf(flow) {
139
+ const entries = classificationEntries(flow);
140
+ if (!entries.length) {
141
+ return {
142
+ class_id: '',
143
+ text: '',
144
+ key: '',
145
+ };
146
+ }
147
+ const leaf = entries[entries.length - 1];
148
+ const classId = leaf.class_id;
149
+ const key = `${classId}|${leaf.text}`.replace(/^\||\|$/gu, '');
150
+ return {
151
+ class_id: classId,
152
+ text: leaf.text,
153
+ key,
154
+ };
155
+ }
156
+ function flowProperties(flow) {
157
+ return listify(deepGet(flow, ['flowProperties', 'flowProperty'])).filter(isRecord);
158
+ }
159
+ function pickReferenceFlowProperty(flow) {
160
+ const props = flowProperties(flow);
161
+ for (const prop of props) {
162
+ const internalId = coerceText(prop['@dataSetInternalID']);
163
+ if (internalId === '0') {
164
+ return {
165
+ prop,
166
+ internalId,
167
+ };
168
+ }
169
+ }
170
+ if (props.length) {
171
+ return {
172
+ prop: props[0],
173
+ internalId: coerceText(props[0]['@dataSetInternalID']),
174
+ };
175
+ }
176
+ return {
177
+ prop: null,
178
+ internalId: '',
179
+ };
180
+ }
181
+ function quantitativeReferenceInternalId(flow) {
182
+ return coerceText(deepGet(flow, ['flowInformation', 'quantitativeReference', 'referenceToReferenceFlowProperty']));
183
+ }
184
+ function flowPropertyRef(prop) {
185
+ const ref = isRecord(prop.referenceToFlowPropertyDataSet)
186
+ ? prop.referenceToFlowPropertyDataSet
187
+ : null;
188
+ return {
189
+ uuid: findUuidInNode(ref),
190
+ version: coerceText(ref?.['@version']),
191
+ internal_id: coerceText(prop['@dataSetInternalID']),
192
+ short_name_en: langTextForLang(ref?.['common:shortDescription'], 'en'),
193
+ };
194
+ }
195
+ function computeSimilarity(left, right) {
196
+ if (!left || !right) {
197
+ return 0;
198
+ }
199
+ if (left === right) {
200
+ return 1;
201
+ }
202
+ const leftTokens = new Set(left.split(' ').filter(Boolean));
203
+ const rightTokens = new Set(right.split(' ').filter(Boolean));
204
+ if (!leftTokens.size || !rightTokens.size) {
205
+ return 0;
206
+ }
207
+ let intersection = 0;
208
+ leftTokens.forEach((token) => {
209
+ if (rightTokens.has(token)) {
210
+ intersection += 1;
211
+ }
212
+ });
213
+ return intersection / Math.max(leftTokens.size, rightTokens.size);
214
+ }
215
+ function createRuleFinding(flowUuidValue, baseVersion, severity, ruleId, message, options) {
216
+ return {
217
+ flow_uuid: flowUuidValue,
218
+ base_version: baseVersion,
219
+ severity,
220
+ rule_id: ruleId,
221
+ message,
222
+ fixability: options?.fixability ?? 'manual',
223
+ source: 'rule',
224
+ ...(options?.ruleSource ? { rule_source: options.ruleSource } : {}),
225
+ ...(options?.evidence ? { evidence: options.evidence } : {}),
226
+ ...(options?.action ? { action: options.action } : {}),
227
+ };
228
+ }
229
+ function applyMethodologyChecks(flow, flowUuidValue, baseVersion, ruleSource) {
230
+ const findings = [];
231
+ const allowedTypes = new Set(['elementary flow', 'product flow', 'waste flow']);
232
+ const typeOfDataset = flowType(flow).toLowerCase();
233
+ if (typeOfDataset && !allowedTypes.has(typeOfDataset)) {
234
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'error', 'methodology_invalid_type_of_dataset', 'typeOfDataSet not in allowed set: Elementary flow | Product flow | Waste flow.', {
235
+ evidence: {
236
+ typeOfDataSet: flowType(flow),
237
+ },
238
+ ruleSource,
239
+ }));
240
+ }
241
+ const baseNameItems = listify(deepGet(flow, ['flowInformation', 'dataSetInformation', 'name', 'baseName']));
242
+ const baseNameEn = langTextForLang(baseNameItems, 'en');
243
+ if (!baseNameEn) {
244
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'warning', 'methodology_missing_base_name_en', 'English baseName is missing (methodology marks English as mandatory).', {
245
+ fixability: 'auto',
246
+ ruleSource,
247
+ }));
248
+ }
249
+ if (baseNameItems.some((item) => {
250
+ const text = isRecord(item) ? coerceText(item['#text']) : coerceText(item);
251
+ return text.includes(';');
252
+ })) {
253
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'warning', 'methodology_basename_semicolon', 'baseName contains semicolon; methodology requires comma-separated descriptors.', {
254
+ fixability: 'auto',
255
+ ruleSource,
256
+ }));
257
+ }
258
+ const quantId = quantitativeReferenceInternalId(flow);
259
+ const propertyIds = new Set(flowProperties(flow)
260
+ .map((prop) => coerceText(prop['@dataSetInternalID']))
261
+ .filter(Boolean));
262
+ if (quantId && !propertyIds.has(quantId)) {
263
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'error', 'methodology_quant_ref_missing_target', 'referenceToReferenceFlowProperty points to a non-existing flowProperty internal ID.', {
264
+ evidence: {
265
+ referenceToReferenceFlowProperty: quantId,
266
+ available_internal_ids: [...propertyIds].sort(),
267
+ },
268
+ ruleSource,
269
+ }));
270
+ }
271
+ for (const [entries, label, idKey, maxLevel] of [
272
+ [classificationEntries(flow), 'product_classification', 'class_id', 4],
273
+ [elementaryClassEntries(flow), 'elementary_classification', 'cat_id', 2],
274
+ ]) {
275
+ const levels = [];
276
+ entries.forEach((entry) => {
277
+ const level = Number.parseInt(entry.level, 10);
278
+ if (Number.isInteger(level)) {
279
+ levels.push(level);
280
+ }
281
+ if (!coerceText(entry[idKey])) {
282
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'warning', `methodology_missing_${idKey}`, `${label} entry has level but missing ${idKey}.`, {
283
+ evidence: {
284
+ entry,
285
+ },
286
+ ruleSource,
287
+ }));
288
+ }
289
+ });
290
+ if (levels.length) {
291
+ const uniqueLevels = [...new Set(levels)].sort((left, right) => left - right);
292
+ const expectedLevels = [];
293
+ for (let level = uniqueLevels[0]; level <= Math.min(maxLevel, uniqueLevels[uniqueLevels.length - 1]); level += 1) {
294
+ expectedLevels.push(level);
295
+ }
296
+ if (uniqueLevels[0] !== 0 ||
297
+ JSON.stringify(uniqueLevels) !== JSON.stringify(expectedLevels)) {
298
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'warning', `methodology_${label}_level_gap`, `${label} levels should be continuous and start from 0.`, {
299
+ evidence: {
300
+ levels: uniqueLevels,
301
+ },
302
+ ruleSource,
303
+ }));
304
+ }
305
+ }
306
+ }
307
+ return findings;
308
+ }
309
+ function buildFlowSummaryAndRuleFindings(doc, methodologyRuleSource) {
310
+ const flow = flowRoot(doc);
311
+ const flowUuidValue = flowUuid(flow) || '(missing-uuid)';
312
+ const baseVersion = flowVersion(flow);
313
+ const typeOfDataset = flowType(flow);
314
+ const names = nameTexts(flow);
315
+ const leaf = classificationLeaf(flow);
316
+ const summary = {
317
+ flow_uuid: flowUuidValue,
318
+ base_version: baseVersion,
319
+ type_of_dataset: typeOfDataset,
320
+ names: {
321
+ primary_en: namePrimary(flow, 'en'),
322
+ primary_zh: namePrimary(flow, 'zh'),
323
+ all_texts: names.slice(0, 20),
324
+ },
325
+ classification: {
326
+ leaf,
327
+ path: classificationEntries(flow).slice(0, 20),
328
+ },
329
+ flow_property: {},
330
+ quantitative_reference: {
331
+ reference_flow_property_internal_id: quantitativeReferenceInternalId(flow),
332
+ },
333
+ unitgroup: {
334
+ uuid: '',
335
+ name: '',
336
+ reference_unit_name: '',
337
+ lookup_status: 'disabled',
338
+ lookup_source: '',
339
+ },
340
+ rule_signals: [],
341
+ similarity_candidates: [],
342
+ _name_fingerprint: nameFingerprint(flow),
343
+ };
344
+ const findings = [];
345
+ if (!typeOfDataset) {
346
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'error', 'missing_type_of_dataset', 'typeOfDataSet is missing under modellingAndValidation.LCIMethod.'));
347
+ }
348
+ else if (typeOfDataset.toLowerCase() === 'elementary flow') {
349
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'warning', 'elementary_flow_in_flow_review', 'Flow type is Elementary flow; check whether this batch should exclude it.', {
350
+ fixability: 'review-needed',
351
+ evidence: {
352
+ typeOfDataSet: typeOfDataset,
353
+ },
354
+ }));
355
+ }
356
+ if (!names.length) {
357
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'warning', 'missing_name_text', 'No textual entries found under flowInformation.dataSetInformation.name.'));
358
+ }
359
+ else if (names.some((value) => value.toLowerCase().includes('emergy'))) {
360
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'warning', 'name_contains_emergy', "Name subtree contains 'Emergy'.", {
361
+ fixability: 'review-needed',
362
+ evidence: {
363
+ matched_count: names.filter((value) => value.toLowerCase().includes('emergy')).length,
364
+ },
365
+ }));
366
+ }
367
+ if (!leaf.key) {
368
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'warning', 'missing_classification_leaf', 'Classification leaf is missing.'));
369
+ }
370
+ const { prop, internalId } = pickReferenceFlowProperty(flow);
371
+ if (!prop) {
372
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'error', 'missing_flow_property', 'No flowProperties.flowProperty entry found.'));
373
+ }
374
+ else {
375
+ const ref = flowPropertyRef(prop);
376
+ summary.flow_property = {
377
+ selected_internal_id: internalId || ref.internal_id,
378
+ referenced_uuid: ref.uuid,
379
+ referenced_version: ref.version,
380
+ referenced_short_name_en: ref.short_name_en,
381
+ available_count: flowProperties(flow).length,
382
+ };
383
+ if (!ref.uuid) {
384
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'error', 'invalid_flow_property_reference', 'Could not parse flow property UUID from referenceToFlowPropertyDataSet.', {
385
+ evidence: {
386
+ selected_internal_id: internalId || ref.internal_id,
387
+ },
388
+ }));
389
+ }
390
+ const quantId = summary.quantitative_reference.reference_flow_property_internal_id;
391
+ if (!quantId) {
392
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'warning', 'missing_quantitative_reference', 'referenceToReferenceFlowProperty is missing.', {
393
+ fixability: 'auto',
394
+ evidence: {
395
+ expected_internal_id: internalId || ref.internal_id,
396
+ },
397
+ }));
398
+ }
399
+ else if (internalId && quantId !== internalId) {
400
+ findings.push(createRuleFinding(flowUuidValue, baseVersion, 'warning', 'quantitative_reference_mismatch', 'Quantitative reference internal ID differs from selected reference flowProperty internal ID.', {
401
+ fixability: 'auto',
402
+ evidence: {
403
+ quant_ref_internal_id: quantId,
404
+ expected_internal_id: internalId,
405
+ },
406
+ action: 'Align quantitative reference internal ID to the selected flowProperty.',
407
+ }));
408
+ }
409
+ }
410
+ findings.push(...applyMethodologyChecks(flow, flowUuidValue, baseVersion, methodologyRuleSource));
411
+ summary.rule_signals = findings.slice(0, 20).map((finding) => ({
412
+ rule_id: coerceText(finding.rule_id),
413
+ severity: finding.severity,
414
+ message: coerceText(finding.message),
415
+ evidence: finding.evidence ?? {},
416
+ }));
417
+ return {
418
+ summary,
419
+ findings,
420
+ };
421
+ }
422
+ function buildSimilarity(summaries, threshold) {
423
+ const grouped = {};
424
+ const candidatesByFlow = {};
425
+ const pairs = [];
426
+ summaries.forEach((summary) => {
427
+ const leafKey = summary.classification.leaf.key;
428
+ const flowPropertyUuid = coerceText(summary.flow_property.referenced_uuid);
429
+ const unitgroupUuid = summary.unitgroup.uuid;
430
+ if (!leafKey || !summary._name_fingerprint) {
431
+ return;
432
+ }
433
+ const key = `${leafKey}::${flowPropertyUuid}::${unitgroupUuid}`;
434
+ grouped[key] ??= [];
435
+ grouped[key].push(summary);
436
+ });
437
+ Object.entries(grouped).forEach(([key, rows]) => {
438
+ if (rows.length < 2) {
439
+ return;
440
+ }
441
+ const [classificationGroup, flowPropertyUuid, unitgroupUuid] = key.split('::');
442
+ for (let leftIndex = 0; leftIndex < rows.length; leftIndex += 1) {
443
+ for (let rightIndex = leftIndex + 1; rightIndex < rows.length; rightIndex += 1) {
444
+ const left = rows[leftIndex];
445
+ const right = rows[rightIndex];
446
+ const similarity = computeSimilarity(left._name_fingerprint, right._name_fingerprint);
447
+ if (similarity < threshold) {
448
+ continue;
449
+ }
450
+ const pair = {
451
+ classification_group: classificationGroup,
452
+ flow_property_uuid: flowPropertyUuid,
453
+ unitgroup_uuid: unitgroupUuid,
454
+ left_flow_uuid: left.flow_uuid,
455
+ right_flow_uuid: right.flow_uuid,
456
+ left_version: left.base_version,
457
+ right_version: right.base_version,
458
+ similarity: Number(similarity.toFixed(6)),
459
+ left_name_en: left.names.primary_en,
460
+ right_name_en: right.names.primary_en,
461
+ };
462
+ pairs.push(pair);
463
+ for (const [source, target] of [
464
+ [left, right],
465
+ [right, left],
466
+ ]) {
467
+ candidatesByFlow[source.flow_uuid] ??= [];
468
+ candidatesByFlow[source.flow_uuid].push({
469
+ other_flow_uuid: target.flow_uuid,
470
+ other_base_version: target.base_version,
471
+ similarity: Number(similarity.toFixed(6)),
472
+ other_name_en: target.names.primary_en,
473
+ classification_group: classificationGroup,
474
+ });
475
+ }
476
+ }
477
+ }
478
+ });
479
+ Object.keys(candidatesByFlow).forEach((flowUuidValue) => {
480
+ candidatesByFlow[flowUuidValue].sort((left, right) => right.similarity - left.similarity ||
481
+ left.other_flow_uuid.localeCompare(right.other_flow_uuid));
482
+ candidatesByFlow[flowUuidValue] = candidatesByFlow[flowUuidValue].slice(0, 5);
483
+ });
484
+ return {
485
+ pairs,
486
+ candidatesByFlow,
487
+ };
488
+ }
489
+ function parseLlmJsonOutput(output) {
490
+ const start = output.indexOf('{');
491
+ const end = output.lastIndexOf('}');
492
+ const candidate = start >= 0 && end > start ? output.slice(start, end + 1) : output;
493
+ try {
494
+ const parsed = JSON.parse(candidate);
495
+ return isRecord(parsed) ? parsed : null;
496
+ }
497
+ catch {
498
+ return null;
499
+ }
500
+ }
501
+ function buildLlmPrompt(batch) {
502
+ return [
503
+ '请对以下 flow 摘要进行 LCI 复审,重点关注:',
504
+ '1) flow property 与 quantitative reference 是否合理/一致;',
505
+ '2) 同类别高相似度 flow 的重复/近重复风险;',
506
+ '3) 名称、分类、typeOfDataSet 的语义一致性与明显异常。',
507
+ '',
508
+ '规则:',
509
+ '- 只能依据输入摘要中的证据;',
510
+ '- 若证据不足,必须明确写证据不足;',
511
+ '- 对每条问题给出 flow_uuid;',
512
+ '- 输出必须是 JSON 对象。',
513
+ '',
514
+ '输出格式:{findings:[{flow_uuid, severity, fixability, evidence, action}]}。',
515
+ '',
516
+ `输入摘要:\n${JSON.stringify(batch, null, 2)}`,
517
+ ].join('\n');
518
+ }
519
+ function normalizeLlmFinding(item, summaryByUuid, fallbackFlowUuid = '') {
520
+ const flowUuidValue = coerceText(item.flow_uuid) || fallbackFlowUuid;
521
+ if (!flowUuidValue) {
522
+ return null;
523
+ }
524
+ const severity = (() => {
525
+ const value = coerceText(item.severity).toLowerCase();
526
+ if (value === 'error' || value === 'warning' || value === 'info') {
527
+ return value;
528
+ }
529
+ return 'warning';
530
+ })();
531
+ const evidence = item.evidence;
532
+ return {
533
+ flow_uuid: flowUuidValue,
534
+ base_version: summaryByUuid[flowUuidValue]?.base_version ?? '',
535
+ severity,
536
+ fixability: coerceText(item.fixability) || 'review-needed',
537
+ source: 'llm',
538
+ ...(isRecord(evidence)
539
+ ? { evidence }
540
+ : evidence === undefined
541
+ ? {}
542
+ : { evidence: { text: coerceText(evidence) } }),
543
+ ...(coerceText(item.action) || coerceText(item.suggestion) || coerceText(item.suggested_action)
544
+ ? {
545
+ action: coerceText(item.action) ||
546
+ coerceText(item.suggestion) ||
547
+ coerceText(item.suggested_action),
548
+ }
549
+ : {}),
550
+ };
551
+ }
552
+ async function runOptionalLlmReview(summaries, options) {
553
+ if (!options.enableLlm) {
554
+ return {
555
+ enabled: false,
556
+ reason: 'disabled',
557
+ batch_count: 0,
558
+ reviewed_flow_count: 0,
559
+ truncated: false,
560
+ batch_results: [],
561
+ llmFindings: [],
562
+ };
563
+ }
564
+ let env = readLlmRuntimeEnv(options.env);
565
+ if (options.llmModel) {
566
+ env = {
567
+ ...env,
568
+ model: options.llmModel,
569
+ };
570
+ }
571
+ const target = options.llmMaxFlows > 0 ? summaries.slice(0, options.llmMaxFlows) : summaries;
572
+ const batchSize = Math.max(1, options.llmBatchSize);
573
+ const batches = [];
574
+ for (let index = 0; index < target.length; index += batchSize) {
575
+ batches.push(target.slice(index, index + batchSize));
576
+ }
577
+ const summaryByUuid = Object.fromEntries(summaries.map((summary) => [summary.flow_uuid, summary]));
578
+ const batchResults = [];
579
+ const llmFindings = [];
580
+ for (let index = 0; index < batches.length; index += 1) {
581
+ const batch = batches[index];
582
+ const llmPayload = batch.map((summary) => {
583
+ const serializable = JSON.parse(JSON.stringify(summary));
584
+ delete serializable._name_fingerprint;
585
+ return serializable;
586
+ });
587
+ try {
588
+ const response = await invokeLlm({
589
+ env,
590
+ input: {
591
+ prompt: '你是严谨的LCA flow复审助手。只基于输入证据判断,不得臆造。输出必须是JSON对象。',
592
+ context: buildLlmPrompt(llmPayload),
593
+ },
594
+ fetchImpl: options.fetchImpl ?? globalThis.fetch,
595
+ timeoutMs: 45_000,
596
+ cacheDir: path.join(options.outDir, '.llm-cache'),
597
+ tracePath: path.join(options.outDir, 'llm-trace.jsonl'),
598
+ module: 'review-flow',
599
+ stage: `semantic-review-${index + 1}`,
600
+ runId: options.runId,
601
+ });
602
+ const parsed = parseLlmJsonOutput(response.output);
603
+ if (!parsed) {
604
+ batchResults.push({
605
+ batch_index: index + 1,
606
+ batch_size: batch.length,
607
+ enabled: true,
608
+ ok: false,
609
+ reason: 'llm_non_json_output',
610
+ raw_preview: response.output.slice(0, 500),
611
+ });
612
+ continue;
613
+ }
614
+ const rawFindings = Array.isArray(parsed.findings) ? parsed.findings : [];
615
+ rawFindings
616
+ .filter(isRecord)
617
+ .map((item) => normalizeLlmFinding(item, summaryByUuid, batch.length === 1 ? batch[0].flow_uuid : ''))
618
+ .filter((item) => item !== null)
619
+ .forEach((item) => {
620
+ llmFindings.push(item);
621
+ });
622
+ batchResults.push({
623
+ batch_index: index + 1,
624
+ batch_size: batch.length,
625
+ enabled: true,
626
+ ok: true,
627
+ });
628
+ }
629
+ catch (error) {
630
+ batchResults.push({
631
+ batch_index: index + 1,
632
+ batch_size: batch.length,
633
+ enabled: true,
634
+ ok: false,
635
+ reason: error instanceof Error ? error.message : String(error),
636
+ });
637
+ }
638
+ }
639
+ const deduped = [];
640
+ const seen = new Set();
641
+ llmFindings.forEach((finding) => {
642
+ const key = JSON.stringify({
643
+ flow_uuid: finding.flow_uuid,
644
+ severity: finding.severity,
645
+ fixability: finding.fixability,
646
+ evidence: finding.evidence ?? {},
647
+ action: finding.action ?? '',
648
+ });
649
+ if (seen.has(key)) {
650
+ return;
651
+ }
652
+ seen.add(key);
653
+ deduped.push(finding);
654
+ });
655
+ return {
656
+ enabled: true,
657
+ ok: batchResults.some((item) => item.ok),
658
+ reason: batchResults.some((item) => item.ok) ? undefined : 'all_batches_failed',
659
+ batch_count: batches.length,
660
+ reviewed_flow_count: target.length,
661
+ truncated: target.length < summaries.length,
662
+ batch_results: batchResults,
663
+ llmFindings: deduped,
664
+ };
665
+ }
666
+ function severityCounts(rows) {
667
+ const counts = {};
668
+ rows.forEach((row) => {
669
+ const key = row.severity;
670
+ counts[key] = (counts[key] ?? 0) + 1;
671
+ });
672
+ return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)));
673
+ }
674
+ function ruleCounts(rows) {
675
+ const counts = {};
676
+ rows.forEach((row) => {
677
+ const key = coerceText(row.rule_id);
678
+ counts[key] = (counts[key] ?? 0) + 1;
679
+ });
680
+ return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)));
681
+ }
682
+ function readJsonObject(filePath) {
683
+ try {
684
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
685
+ if (!isRecord(parsed)) {
686
+ throw new CliError(`Expected JSON object in flow file: ${filePath}`, {
687
+ code: 'FLOW_REVIEW_INVALID_FLOW_FILE',
688
+ exitCode: 2,
689
+ });
690
+ }
691
+ return parsed;
692
+ }
693
+ catch (error) {
694
+ if (error instanceof CliError) {
695
+ throw error;
696
+ }
697
+ throw new CliError(`Invalid flow JSON file: ${filePath}`, {
698
+ code: 'FLOW_REVIEW_INVALID_FLOW_FILE',
699
+ exitCode: 2,
700
+ details: String(error),
701
+ });
702
+ }
703
+ }
704
+ function materializeRowsFile(rowsFile, outDir) {
705
+ const rows = loadRowsFromFile(rowsFile);
706
+ const targetDir = path.join(outDir, 'review-input', 'flows');
707
+ mkdirSync(targetDir, { recursive: true });
708
+ const byKey = {};
709
+ let duplicateCount = 0;
710
+ rows.forEach((row, index) => {
711
+ const record = extractFlowRecord(row);
712
+ const flowId = record.id || `row-${index}`;
713
+ const version = record.version;
714
+ const key = `${flowId}@${version}`;
715
+ if (key in byKey) {
716
+ duplicateCount += 1;
717
+ }
718
+ byKey[key] = row;
719
+ });
720
+ const items = [];
721
+ Object.entries(byKey)
722
+ .sort(([left], [right]) => left.localeCompare(right))
723
+ .forEach(([key, row]) => {
724
+ const record = extractFlowRecord(row);
725
+ const fileName = `${record.id || key.split('@', 1)[0]}__${record.version}.json`;
726
+ const filePath = path.join(targetDir, fileName);
727
+ writeJsonArtifact(filePath, {
728
+ flowDataSet: flowDatasetFromRow(row),
729
+ });
730
+ items.push({
731
+ flow_key: key,
732
+ primary_name: record.name,
733
+ file: filePath,
734
+ });
735
+ });
736
+ const summaryPath = path.join(outDir, 'review-input', 'materialization-summary.json');
737
+ writeJsonArtifact(summaryPath, {
738
+ source_rows_file: path.resolve(rowsFile),
739
+ input_row_count: rows.length,
740
+ materialized_flow_count: Object.keys(byKey).length,
741
+ duplicate_input_rows_collapsed: duplicateCount,
742
+ flows_dir: targetDir,
743
+ items,
744
+ });
745
+ return {
746
+ flowsDir: targetDir,
747
+ summaryPath,
748
+ };
749
+ }
750
+ function resolveReviewInput(options) {
751
+ const declaredModes = [
752
+ Boolean(options.rowsFile),
753
+ Boolean(options.flowsDir),
754
+ Boolean(options.runRoot),
755
+ ].filter(Boolean);
756
+ if (declaredModes.length !== 1) {
757
+ throw new CliError('Flow review requires exactly one of --rows-file, --flows-dir, or --run-root.', {
758
+ code: 'FLOW_REVIEW_INPUT_MODE_REQUIRED',
759
+ exitCode: 2,
760
+ });
761
+ }
762
+ if (options.rowsFile) {
763
+ const materialized = materializeRowsFile(options.rowsFile, options.outDir);
764
+ return {
765
+ inputMode: 'rows_file',
766
+ effectiveFlowsDir: materialized.flowsDir,
767
+ materializationSummaryPath: materialized.summaryPath,
768
+ runId: options.runId || path.parse(options.rowsFile).name,
769
+ reviewInputSummary: {
770
+ input_mode: 'rows_file',
771
+ rows_file: path.resolve(options.rowsFile),
772
+ flows_dir: '',
773
+ run_root: '',
774
+ materialized_flows_dir: materialized.flowsDir,
775
+ effective_flows_dir: materialized.flowsDir,
776
+ },
777
+ };
778
+ }
779
+ if (options.flowsDir) {
780
+ const resolved = path.resolve(options.flowsDir);
781
+ return {
782
+ inputMode: 'flows_dir',
783
+ effectiveFlowsDir: resolved,
784
+ materializationSummaryPath: null,
785
+ runId: options.runId || path.basename(resolved),
786
+ reviewInputSummary: {
787
+ input_mode: 'flows_dir',
788
+ rows_file: '',
789
+ flows_dir: resolved,
790
+ run_root: '',
791
+ materialized_flows_dir: '',
792
+ effective_flows_dir: resolved,
793
+ },
794
+ };
795
+ }
796
+ const runRoot = path.resolve(options.runRoot);
797
+ const candidates = [path.join(runRoot, 'cache', 'flows'), path.join(runRoot, 'exports', 'flows')];
798
+ const effective = candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
799
+ return {
800
+ inputMode: 'run_root',
801
+ effectiveFlowsDir: effective,
802
+ materializationSummaryPath: null,
803
+ runId: options.runId || path.basename(runRoot),
804
+ reviewInputSummary: {
805
+ input_mode: 'run_root',
806
+ rows_file: '',
807
+ flows_dir: '',
808
+ run_root: runRoot,
809
+ materialized_flows_dir: '',
810
+ effective_flows_dir: effective,
811
+ },
812
+ };
813
+ }
814
+ function listFlowFiles(flowsDir) {
815
+ if (!existsSync(flowsDir) || !statSync(flowsDir).isDirectory()) {
816
+ throw new CliError(`Flow review directory not found: ${flowsDir}`, {
817
+ code: 'FLOW_REVIEW_DIR_NOT_FOUND',
818
+ exitCode: 2,
819
+ });
820
+ }
821
+ return readdirSync(flowsDir)
822
+ .filter((entry) => entry.endsWith('.json'))
823
+ .map((entry) => path.join(flowsDir, entry))
824
+ .filter((entry) => statSync(entry).isFile())
825
+ .sort((left, right) => left.localeCompare(right));
826
+ }
827
+ function stripInternalSummaryFields(summary) {
828
+ const serializable = JSON.parse(JSON.stringify(summary));
829
+ delete serializable._name_fingerprint;
830
+ return serializable;
831
+ }
832
+ function renderZhReview(options) {
833
+ const lines = [
834
+ '# flow_review_zh\n',
835
+ `- run_id: \`${options.runId}\`\n`,
836
+ `- logic_version: \`${options.logicVersion}\`\n`,
837
+ `- flows_dir: \`${options.flowsDir}\`\n`,
838
+ `- flow count: \`${options.flowSummaries.length}\`\n`,
839
+ '- with_reference_context: `false`\n',
840
+ `- methodology_rule_source: \`${options.summary.methodology_rule_source}\`\n`,
841
+ '\n## 基础统计\n',
842
+ `- rule-based findings: **${options.ruleFindings.length}**\n`,
843
+ `- LLM findings: **${options.llmFindings.length}**\n`,
844
+ `- merged findings: **${options.mergedFindings.length}**\n`,
845
+ ];
846
+ if (options.mergedFindings.length) {
847
+ lines.push('\n### Severity 统计\n');
848
+ Object.entries(options.summary.severity_counts).forEach(([key, value]) => {
849
+ lines.push(`- ${key}: ${value}\n`);
850
+ });
851
+ }
852
+ lines.push('\n## Flow 摘要(最多展示 100 条)\n', '|flow uuid|version|typeOfDataSet|name(en)|class leaf|flow property|规则信号数|相似候选数|\n', '|---|---|---|---|---|---|---:|---:|\n');
853
+ options.flowSummaries.slice(0, 100).forEach((row) => {
854
+ const summary = row;
855
+ lines.push(`|${summary.flow_uuid.replace(/\|/gu, '/')}|${summary.base_version.replace(/\|/gu, '/')}|${summary.type_of_dataset.replace(/\|/gu, '/')}|${summary.names.primary_en.replace(/\|/gu, '/')}|${summary.classification.leaf.text.replace(/\|/gu, '/')}|${coerceText(summary.flow_property.referenced_short_name_en ?? summary.flow_property.referenced_uuid).replace(/\|/gu, '/')}|${summary.rule_signals.length}|${summary.similarity_candidates.length}|\n`);
856
+ });
857
+ lines.push('\n## LLM 语义复审层\n');
858
+ if (!options.llmResult.enabled) {
859
+ lines.push(`- 未启用:\`${options.llmResult.reason ?? 'disabled'}\`\n`);
860
+ }
861
+ else if (!options.llmResult.ok) {
862
+ lines.push(`- 调用失败:\`${options.llmResult.reason ?? 'unknown'}\`\n`);
863
+ }
864
+ else if (!options.llmFindings.length) {
865
+ lines.push('- 未返回额外语义 findings。\n');
866
+ }
867
+ else {
868
+ if (options.llmResult.truncated) {
869
+ lines.push(`- 注意:LLM 仅复审前 \`${options.llmResult.reviewed_flow_count}\` 条(受 \`--llm-max-flows\` 限制)。\n`);
870
+ }
871
+ lines.push('\n|flow uuid|severity|fixability|evidence|action|\n|---|---|---|---|---|\n');
872
+ options.llmFindings.slice(0, 200).forEach((finding) => {
873
+ lines.push(`|${finding.flow_uuid.replace(/\|/gu, '/')}|${finding.severity.replace(/\|/gu, '/')}|${coerceText(finding.fixability).replace(/\|/gu, '/')}|${JSON.stringify(finding.evidence ?? {}).replace(/\|/gu, '/')}|${coerceText(finding.action).replace(/\|/gu, '/')}|\n`);
874
+ });
875
+ }
876
+ lines.push('\n## 说明\n', '- 当前 CLI 版本保持 local-first / artifact-first,不在 review flow 阶段接入 MCP。\n', '- flow property / unitgroup 的额外本地 registry 丰富暂未接入 CLI,本轮 summary 中统一标记为 disabled。\n');
877
+ return lines.join('');
878
+ }
879
+ function renderEnReview(options) {
880
+ const lines = [
881
+ '# flow_review_en\n',
882
+ `- run_id: \`${options.runId}\`\n`,
883
+ `- logic_version: \`${options.logicVersion}\`\n`,
884
+ `- flows_dir: \`${options.flowsDir}\`\n`,
885
+ `- flow count: \`${options.flowCount}\`\n`,
886
+ '- with_reference_context: `false`\n',
887
+ `- methodology_rule_source: \`${options.methodologyRuleSource}\`\n`,
888
+ '\n## Summary\n',
889
+ `- rule-based findings: **${options.ruleFindingCount}**\n`,
890
+ `- llm findings: **${options.llmFindingCount}**\n`,
891
+ ];
892
+ if (!options.llmResult.enabled) {
893
+ lines.push(`- LLM disabled: \`${options.llmResult.reason ?? 'disabled'}\`\n`);
894
+ }
895
+ else if (!options.llmResult.ok) {
896
+ lines.push(`- LLM failed: \`${options.llmResult.reason ?? 'unknown'}\`\n`);
897
+ }
898
+ else if (options.llmResult.truncated) {
899
+ lines.push(`- LLM reviewed only the first \`${options.llmResult.reviewed_flow_count}\` flows due to \`--llm-max-flows\`.\n`);
900
+ }
901
+ return lines.join('');
902
+ }
903
+ function renderTimingReview(options) {
904
+ const lines = ['# flow_review_timing\n', `- run_id: \`${options.runId}\`\n`];
905
+ if (options.startTs) {
906
+ lines.push(`- start: \`${options.startTs}\`\n`);
907
+ }
908
+ if (options.endTs) {
909
+ lines.push(`- end: \`${options.endTs}\`\n`);
910
+ }
911
+ if (options.startTs && options.endTs) {
912
+ const started = Date.parse(options.startTs);
913
+ const ended = Date.parse(options.endTs);
914
+ if (Number.isFinite(started) && Number.isFinite(ended)) {
915
+ lines.push(`- total elapsed: **${((ended - started) / 60_000).toFixed(2)} min**\n`);
916
+ }
917
+ }
918
+ lines.push(`- flow files reviewed: \`${options.flowCount}\`\n`);
919
+ lines.push('- major time consumers: flow JSON parsing, similarity grouping, optional LLM review batches.\n');
920
+ return lines.join('');
921
+ }
922
+ export async function runFlowReview(options) {
923
+ if (!options.outDir.trim()) {
924
+ throw new CliError('Missing required --out-dir value.', {
925
+ code: 'FLOW_REVIEW_OUT_DIR_REQUIRED',
926
+ exitCode: 2,
927
+ });
928
+ }
929
+ const outDir = path.resolve(options.outDir);
930
+ mkdirSync(outDir, { recursive: true });
931
+ const resolvedInput = resolveReviewInput({
932
+ ...options,
933
+ outDir,
934
+ });
935
+ const flowFiles = listFlowFiles(resolvedInput.effectiveFlowsDir);
936
+ if (!flowFiles.length) {
937
+ throw new CliError(`No flow JSON files found in ${resolvedInput.effectiveFlowsDir}`, {
938
+ code: 'FLOW_REVIEW_NO_FLOW_FILES',
939
+ exitCode: 2,
940
+ });
941
+ }
942
+ const methodologyRuleSource = options.methodologyId?.trim() || 'built_in';
943
+ const ruleFindings = [];
944
+ const flowSummaries = [];
945
+ flowFiles.forEach((filePath) => {
946
+ const doc = readJsonObject(filePath);
947
+ const { summary, findings } = buildFlowSummaryAndRuleFindings(doc, methodologyRuleSource);
948
+ summary.source_file = path.basename(filePath);
949
+ flowSummaries.push(summary);
950
+ ruleFindings.push(...findings);
951
+ });
952
+ const similarity = buildSimilarity(flowSummaries, options.similarityThreshold ?? 0.92);
953
+ flowSummaries.forEach((summary) => {
954
+ summary.similarity_candidates = similarity.candidatesByFlow[summary.flow_uuid] ?? [];
955
+ if (!summary.similarity_candidates.length) {
956
+ return;
957
+ }
958
+ ruleFindings.push(createRuleFinding(summary.flow_uuid, summary.base_version, 'warning', 'same_category_high_similarity', 'Another flow in the same classification/flowProperty/unitgroup group is highly similar.', {
959
+ fixability: 'review-needed',
960
+ evidence: {
961
+ candidates: summary.similarity_candidates.slice(0, 3),
962
+ },
963
+ }));
964
+ });
965
+ const llmRun = await runOptionalLlmReview(flowSummaries, {
966
+ enableLlm: Boolean(options.enableLlm),
967
+ llmModel: options.llmModel,
968
+ llmMaxFlows: options.llmMaxFlows ?? 120,
969
+ llmBatchSize: options.llmBatchSize ?? 20,
970
+ env: options.env ?? process.env,
971
+ fetchImpl: options.fetchImpl,
972
+ outDir,
973
+ runId: resolvedInput.runId,
974
+ });
975
+ const llmResult = {
976
+ enabled: llmRun.enabled,
977
+ ...(llmRun.ok === undefined ? {} : { ok: llmRun.ok }),
978
+ ...(llmRun.reason ? { reason: llmRun.reason } : {}),
979
+ batch_count: llmRun.batch_count,
980
+ reviewed_flow_count: llmRun.reviewed_flow_count,
981
+ truncated: llmRun.truncated,
982
+ batch_results: llmRun.batch_results,
983
+ };
984
+ const mergedFindings = [...ruleFindings, ...llmRun.llmFindings];
985
+ const savedSummaries = flowSummaries.map((summary) => stripInternalSummaryFields(summary));
986
+ const now = options.now ?? (() => new Date());
987
+ const reviewInputSummaryPath = path.join(outDir, 'review-input-summary.json');
988
+ const ruleFindingsPath = path.join(outDir, 'rule_findings.jsonl');
989
+ const llmFindingsPath = path.join(outDir, 'llm_findings.jsonl');
990
+ const findingsPath = path.join(outDir, 'findings.jsonl');
991
+ const flowSummariesPath = path.join(outDir, 'flow_summaries.jsonl');
992
+ const similarityPairsPath = path.join(outDir, 'similarity_pairs.jsonl');
993
+ const summaryPath = path.join(outDir, 'flow_review_summary.json');
994
+ const reviewZhPath = path.join(outDir, 'flow_review_zh.md');
995
+ const reviewEnPath = path.join(outDir, 'flow_review_en.md');
996
+ const timingPath = path.join(outDir, 'flow_review_timing.md');
997
+ const reportPath = path.join(outDir, 'flow_review_report.json');
998
+ writeJsonArtifact(reviewInputSummaryPath, resolvedInput.reviewInputSummary);
999
+ writeJsonLinesArtifact(ruleFindingsPath, ruleFindings);
1000
+ writeJsonLinesArtifact(llmFindingsPath, llmRun.llmFindings);
1001
+ writeJsonLinesArtifact(findingsPath, mergedFindings);
1002
+ writeJsonLinesArtifact(flowSummariesPath, savedSummaries);
1003
+ writeJsonLinesArtifact(similarityPairsPath, similarity.pairs);
1004
+ const report = {
1005
+ schema_version: 1,
1006
+ generated_at_utc: now().toISOString(),
1007
+ status: 'completed_local_flow_review',
1008
+ run_id: resolvedInput.runId,
1009
+ out_dir: outDir,
1010
+ input_mode: resolvedInput.inputMode,
1011
+ effective_flows_dir: resolvedInput.effectiveFlowsDir,
1012
+ logic_version: options.logicVersion?.trim() || 'flow-v1.0-cli',
1013
+ flow_count: flowSummaries.length,
1014
+ similarity_threshold: options.similarityThreshold ?? 0.92,
1015
+ methodology_rule_source: methodologyRuleSource,
1016
+ with_reference_context: false,
1017
+ reference_context_mode: 'disabled',
1018
+ rule_finding_count: ruleFindings.length,
1019
+ llm_finding_count: llmRun.llmFindings.length,
1020
+ finding_count: mergedFindings.length,
1021
+ severity_counts: severityCounts(mergedFindings),
1022
+ rule_counts: ruleCounts(ruleFindings),
1023
+ llm: llmResult,
1024
+ files: {
1025
+ review_input_summary: reviewInputSummaryPath,
1026
+ materialization_summary: resolvedInput.materializationSummaryPath,
1027
+ rule_findings: ruleFindingsPath,
1028
+ llm_findings: llmFindingsPath,
1029
+ findings: findingsPath,
1030
+ flow_summaries: flowSummariesPath,
1031
+ similarity_pairs: similarityPairsPath,
1032
+ summary: summaryPath,
1033
+ review_zh: reviewZhPath,
1034
+ review_en: reviewEnPath,
1035
+ timing: timingPath,
1036
+ report: reportPath,
1037
+ },
1038
+ };
1039
+ writeJsonArtifact(summaryPath, {
1040
+ run_id: report.run_id,
1041
+ logic_version: report.logic_version,
1042
+ flow_count: report.flow_count,
1043
+ with_reference_context: report.with_reference_context,
1044
+ reference_context_mode: report.reference_context_mode,
1045
+ similarity_threshold: report.similarity_threshold,
1046
+ methodology_rule_source: report.methodology_rule_source,
1047
+ rule_finding_count: report.rule_finding_count,
1048
+ llm_finding_count: report.llm_finding_count,
1049
+ finding_count: report.finding_count,
1050
+ severity_counts: report.severity_counts,
1051
+ rule_counts: report.rule_counts,
1052
+ llm: report.llm,
1053
+ });
1054
+ writeTextArtifact(reviewZhPath, renderZhReview({
1055
+ runId: report.run_id,
1056
+ logicVersion: report.logic_version,
1057
+ flowsDir: report.effective_flows_dir,
1058
+ flowSummaries: savedSummaries,
1059
+ ruleFindings,
1060
+ llmFindings: llmRun.llmFindings,
1061
+ llmResult,
1062
+ mergedFindings,
1063
+ summary: report,
1064
+ }));
1065
+ writeTextArtifact(reviewEnPath, renderEnReview({
1066
+ runId: report.run_id,
1067
+ logicVersion: report.logic_version,
1068
+ flowsDir: report.effective_flows_dir,
1069
+ flowCount: report.flow_count,
1070
+ ruleFindingCount: report.rule_finding_count,
1071
+ llmFindingCount: report.llm_finding_count,
1072
+ llmResult,
1073
+ methodologyRuleSource: report.methodology_rule_source,
1074
+ }));
1075
+ writeTextArtifact(timingPath, renderTimingReview({
1076
+ runId: report.run_id,
1077
+ startTs: options.startTs,
1078
+ endTs: options.endTs,
1079
+ flowCount: report.flow_count,
1080
+ }));
1081
+ writeJsonArtifact(reportPath, report);
1082
+ return report;
1083
+ }
1084
+ export const __testInternals = {
1085
+ applyMethodologyChecks,
1086
+ buildFlowSummaryAndRuleFindings,
1087
+ buildSimilarity,
1088
+ buildLlmPrompt,
1089
+ classificationLeaf,
1090
+ computeSimilarity,
1091
+ createRuleFinding,
1092
+ findUuidInNode,
1093
+ flowRoot,
1094
+ langTextForLang,
1095
+ listFlowFiles,
1096
+ materializeRowsFile,
1097
+ nameFingerprint,
1098
+ normalizeLlmFinding,
1099
+ parseLlmJsonOutput,
1100
+ readJsonObject,
1101
+ renderEnReview,
1102
+ renderTimingReview,
1103
+ renderZhReview,
1104
+ resolveReviewInput,
1105
+ runOptionalLlmReview,
1106
+ ruleCounts,
1107
+ severityCounts,
1108
+ walkStrings,
1109
+ };
1110
+ //# sourceMappingURL=review-flow.js.map