@llm-dev-ops/agentics-cli 2.1.4 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pipeline/auto-chain.d.ts +73 -0
- package/dist/pipeline/auto-chain.d.ts.map +1 -1
- package/dist/pipeline/auto-chain.js +525 -38
- package/dist/pipeline/auto-chain.js.map +1 -1
- package/dist/pipeline/phase2/phases/prompt-generator.d.ts.map +1 -1
- package/dist/pipeline/phase2/phases/prompt-generator.js +53 -6
- package/dist/pipeline/phase2/phases/prompt-generator.js.map +1 -1
- package/dist/pipeline/phase2/schemas.d.ts +10 -10
- package/dist/pipeline/phase4/phases/http-server-generator.d.ts +12 -0
- package/dist/pipeline/phase4/phases/http-server-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/http-server-generator.js +92 -25
- package/dist/pipeline/phase4/phases/http-server-generator.js.map +1 -1
- package/dist/pipeline/phase5-build/phase5-build-coordinator.d.ts.map +1 -1
- package/dist/pipeline/phase5-build/phase5-build-coordinator.js +44 -0
- package/dist/pipeline/phase5-build/phase5-build-coordinator.js.map +1 -1
- package/dist/pipeline/phase5-build/phases/post-generation-validator.d.ts +75 -0
- package/dist/pipeline/phase5-build/phases/post-generation-validator.d.ts.map +1 -0
- package/dist/pipeline/phase5-build/phases/post-generation-validator.js +728 -0
- package/dist/pipeline/phase5-build/phases/post-generation-validator.js.map +1 -0
- package/dist/pipeline/phase5-build/types.d.ts +1 -1
- package/dist/pipeline/phase5-build/types.d.ts.map +1 -1
- package/dist/pipeline/types.d.ts +84 -0
- package/dist/pipeline/types.d.ts.map +1 -1
- package/dist/pipeline/types.js +43 -1
- package/dist/pipeline/types.js.map +1 -1
- package/dist/synthesis/consensus-svg.d.ts +19 -0
- package/dist/synthesis/consensus-svg.d.ts.map +1 -0
- package/dist/synthesis/consensus-svg.js +95 -0
- package/dist/synthesis/consensus-svg.js.map +1 -0
- package/dist/synthesis/consensus-tiers.d.ts +99 -0
- package/dist/synthesis/consensus-tiers.d.ts.map +1 -0
- package/dist/synthesis/consensus-tiers.js +285 -0
- package/dist/synthesis/consensus-tiers.js.map +1 -0
- package/dist/synthesis/domain-labor-classifier.d.ts +101 -0
- package/dist/synthesis/domain-labor-classifier.d.ts.map +1 -0
- package/dist/synthesis/domain-labor-classifier.js +312 -0
- package/dist/synthesis/domain-labor-classifier.js.map +1 -0
- package/dist/synthesis/domain-unit-registry.d.ts +59 -0
- package/dist/synthesis/domain-unit-registry.d.ts.map +1 -0
- package/dist/synthesis/domain-unit-registry.js +294 -0
- package/dist/synthesis/domain-unit-registry.js.map +1 -0
- package/dist/synthesis/financial-claim-extractor.d.ts +52 -0
- package/dist/synthesis/financial-claim-extractor.d.ts.map +1 -0
- package/dist/synthesis/financial-claim-extractor.js +351 -0
- package/dist/synthesis/financial-claim-extractor.js.map +1 -0
- package/dist/synthesis/financial-consistency-rules.d.ts +66 -0
- package/dist/synthesis/financial-consistency-rules.d.ts.map +1 -0
- package/dist/synthesis/financial-consistency-rules.js +432 -0
- package/dist/synthesis/financial-consistency-rules.js.map +1 -0
- package/dist/synthesis/financial-consistency-runner.d.ts +73 -0
- package/dist/synthesis/financial-consistency-runner.d.ts.map +1 -0
- package/dist/synthesis/financial-consistency-runner.js +131 -0
- package/dist/synthesis/financial-consistency-runner.js.map +1 -0
- package/dist/synthesis/forbidden-spin-phrases.d.ts +32 -0
- package/dist/synthesis/forbidden-spin-phrases.d.ts.map +1 -0
- package/dist/synthesis/forbidden-spin-phrases.js +84 -0
- package/dist/synthesis/forbidden-spin-phrases.js.map +1 -0
- package/dist/synthesis/phase-gate-thresholds.d.ts +30 -0
- package/dist/synthesis/phase-gate-thresholds.d.ts.map +1 -0
- package/dist/synthesis/phase-gate-thresholds.js +34 -0
- package/dist/synthesis/phase-gate-thresholds.js.map +1 -0
- package/dist/synthesis/prompts/index.d.ts.map +1 -1
- package/dist/synthesis/prompts/index.js +22 -0
- package/dist/synthesis/prompts/index.js.map +1 -1
- package/dist/synthesis/simulation-artifact-generator.d.ts.map +1 -1
- package/dist/synthesis/simulation-artifact-generator.js +89 -1
- package/dist/synthesis/simulation-artifact-generator.js.map +1 -1
- package/dist/synthesis/simulation-renderers.d.ts +105 -2
- package/dist/synthesis/simulation-renderers.d.ts.map +1 -1
- package/dist/synthesis/simulation-renderers.js +1056 -92
- package/dist/synthesis/simulation-renderers.js.map +1 -1
- package/dist/synthesis/unit-economics-loader.d.ts +71 -0
- package/dist/synthesis/unit-economics-loader.d.ts.map +1 -0
- package/dist/synthesis/unit-economics-loader.js +200 -0
- package/dist/synthesis/unit-economics-loader.js.map +1 -0
- package/package.json +1 -1
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
* No I/O, no side effects, no external calls.
|
|
6
6
|
*
|
|
7
7
|
* FORBIDDEN:
|
|
8
|
-
* - File I/O (use simulation-artifact-generator for writes)
|
|
9
8
|
* - External API calls
|
|
10
9
|
* - State mutation
|
|
10
|
+
*
|
|
11
|
+
* EXCEPTION (ADR-PIPELINE-066): renderFinancialAnalysis may read
|
|
12
|
+
* `<runDir>/unit-economics.json` via the loader module to prefer prototype
|
|
13
|
+
* unit economics over per-employee heuristics. The loader is the single
|
|
14
|
+
* permitted I/O path and never throws.
|
|
11
15
|
*/
|
|
16
|
+
import { loadUnitEconomics, buildFinancialModelFromUnitEconomics, enforceManifestConsistency, } from './unit-economics-loader.js';
|
|
17
|
+
import { classifyLaborIntensity, isLaborIntensive, workforceRiskTemplate, workforceRaciTemplate, workforceSensitivityRow, estimateWorkforceExposure, } from './domain-labor-classifier.js';
|
|
18
|
+
import { populateConsensusSnapshot, consensusBanner, consensusOpeningParagraph, whyNotMoreConfidentSection, consensusGate, analyticalUncertaintyRisk, } from './consensus-tiers.js';
|
|
12
19
|
// ============================================================================
|
|
13
20
|
// Helper: safe extraction from unknown agent responses
|
|
14
21
|
// ============================================================================
|
|
@@ -839,6 +846,48 @@ function estimateSupplierCount(employees) {
|
|
|
839
846
|
// Rough: 1 active supplier per 50-100 employees for large enterprises
|
|
840
847
|
return Math.max(20, Math.round(employees / 75));
|
|
841
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* ADR-PIPELINE-070: Parse a financial budget string like "$2.7M – $4.1M ..."
|
|
851
|
+
* and return the mid-point in USD. Returns null when no money is found.
|
|
852
|
+
*/
|
|
853
|
+
function parseUsdRangeMid(budget) {
|
|
854
|
+
if (!budget)
|
|
855
|
+
return null;
|
|
856
|
+
const matches = Array.from(budget.matchAll(/\$([0-9.,]+)\s*([KMB])?/gi));
|
|
857
|
+
if (matches.length === 0)
|
|
858
|
+
return null;
|
|
859
|
+
const values = [];
|
|
860
|
+
for (const m of matches) {
|
|
861
|
+
const num = parseFloat(m[1].replace(/,/g, ''));
|
|
862
|
+
if (Number.isNaN(num))
|
|
863
|
+
continue;
|
|
864
|
+
const suffix = (m[2] ?? '').toUpperCase();
|
|
865
|
+
const mult = suffix === 'M' ? 1_000_000 : suffix === 'K' ? 1_000 : suffix === 'B' ? 1_000_000_000 : 1;
|
|
866
|
+
values.push(num * mult);
|
|
867
|
+
}
|
|
868
|
+
if (values.length === 0)
|
|
869
|
+
return null;
|
|
870
|
+
if (values.length === 1)
|
|
871
|
+
return values[0];
|
|
872
|
+
return (values[0] + values[1]) / 2;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* ADR-PIPELINE-070: Pull the primary operational unit count from a unit
|
|
876
|
+
* economics manifest's measured_scope, preferring the same scope key the
|
|
877
|
+
* sector registry uses (rooms → sqft → vehicles → ...).
|
|
878
|
+
*/
|
|
879
|
+
function primaryScopeCount(manifest) {
|
|
880
|
+
if (!manifest)
|
|
881
|
+
return undefined;
|
|
882
|
+
const scope = manifest.measured_scope;
|
|
883
|
+
const order = ['rooms', 'sqft', 'vehicles', 'beds', 'agents', 'units', 'properties'];
|
|
884
|
+
for (const key of order) {
|
|
885
|
+
const v = scope[key];
|
|
886
|
+
if (typeof v === 'number' && v > 0)
|
|
887
|
+
return v;
|
|
888
|
+
}
|
|
889
|
+
return undefined;
|
|
890
|
+
}
|
|
842
891
|
// extractFinancials is now synthesizeFinancials — kept as export for external callers
|
|
843
892
|
export { synthesizeFinancials as extractFinancials };
|
|
844
893
|
/**
|
|
@@ -1337,18 +1386,23 @@ export function renderExecutiveSummary(query, simulationResult, platformResults)
|
|
|
1337
1386
|
recommendation = 'CONDITIONAL PROCEED';
|
|
1338
1387
|
recDetail = `Proceed with a scoped pilot to validate core assumptions before full commitment.`;
|
|
1339
1388
|
}
|
|
1389
|
+
// ADR-PIPELINE-071: Consensus tier drives document structure.
|
|
1390
|
+
// - aligned → footnote at the bottom (existing behavior)
|
|
1391
|
+
// - directional → opening paragraph acknowledgement
|
|
1392
|
+
// - contested → loud header AND opening paragraph leading with the
|
|
1393
|
+
// contested state (the pilot exists BECAUSE of this)
|
|
1394
|
+
const consensus = populateConsensusSnapshot(simulationResult, platformResults);
|
|
1395
|
+
const banner = consensusBanner(consensus);
|
|
1396
|
+
const opening = consensusOpeningParagraph(consensus);
|
|
1340
1397
|
// ADR-PIPELINE-024: Pyramid Principle — lead with the answer
|
|
1341
|
-
const lines = [
|
|
1342
|
-
|
|
1343
|
-
''
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
'',
|
|
1348
|
-
|
|
1349
|
-
'',
|
|
1350
|
-
`**${recommendation}** — ${recDetail}`,
|
|
1351
|
-
];
|
|
1398
|
+
const lines = ['# Executive Summary', ''];
|
|
1399
|
+
if (banner) {
|
|
1400
|
+
lines.push(banner, '');
|
|
1401
|
+
}
|
|
1402
|
+
lines.push(`**Date:** ${now}`, '', '---', '', '## Recommendation', '', `**${recommendation}** — ${recDetail}`);
|
|
1403
|
+
if (opening) {
|
|
1404
|
+
lines.push('', opening);
|
|
1405
|
+
}
|
|
1352
1406
|
if (fin.hasData) {
|
|
1353
1407
|
const impactParts = [];
|
|
1354
1408
|
if (fin.budget)
|
|
@@ -1447,6 +1501,11 @@ export function renderExecutiveSummary(query, simulationResult, platformResults)
|
|
|
1447
1501
|
lines.push(`- **${r.category}** (${r.likelihood} likelihood, ${r.impact} impact): ${r.risk}`);
|
|
1448
1502
|
}
|
|
1449
1503
|
lines.push('');
|
|
1504
|
+
// ADR-PIPELINE-062: Competitive Analysis — never empty (LLM or fallback)
|
|
1505
|
+
// Implements ADR-PIPELINE-057 §3. The fallback builder guarantees the
|
|
1506
|
+
// section is present even when the LLM doesn't return competitive_analysis.
|
|
1507
|
+
const competitiveAnalysis = getOrBuildCompetitiveAnalysis(query, simData, extracted);
|
|
1508
|
+
lines.push(...renderCompetitiveSection(competitiveAnalysis));
|
|
1450
1509
|
// Recommended Next Steps — time-bound
|
|
1451
1510
|
lines.push('## Recommended Next Steps', '');
|
|
1452
1511
|
const steps = buildDomainNextSteps(recommendation, extracted);
|
|
@@ -1454,19 +1513,31 @@ export function renderExecutiveSummary(query, simulationResult, platformResults)
|
|
|
1454
1513
|
lines.push(step);
|
|
1455
1514
|
}
|
|
1456
1515
|
lines.push('');
|
|
1457
|
-
// ADR-PIPELINE-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1516
|
+
// ADR-PIPELINE-071: tiered consensus disclosure.
|
|
1517
|
+
// - aligned → optional legacy footnote (kept for backwards compat)
|
|
1518
|
+
// - directional → "Why we are not more confident" section
|
|
1519
|
+
// - contested → "Why we are not more confident" section (mandatory)
|
|
1520
|
+
if (consensus.tier === 'aligned') {
|
|
1521
|
+
// Legacy footnote retained for aligned runs only.
|
|
1522
|
+
const consensusAgent = platformResults.find(r => r.domain === 'analytics-hub' && r.agent === 'consensus');
|
|
1523
|
+
if (consensusAgent) {
|
|
1524
|
+
const consensusData = extractSignalPayload(consensusAgent.response).data ?? {};
|
|
1525
|
+
const achieved = consensusData['consensusAchieved'] ?? consensusData['consensus_achieved'];
|
|
1526
|
+
const confidence = Number(consensusData['confidence'] ?? consensusData['overallConfidence'] ?? 0);
|
|
1527
|
+
if (achieved === false || confidence < 0.6) {
|
|
1528
|
+
lines.push('## Analysis Confidence Note', '');
|
|
1529
|
+
lines.push(`The multi-agent consensus process achieved **${Math.round(confidence * 100)}% confidence**. ` +
|
|
1530
|
+
`This indicates divergent signals across analytical perspectives. ` +
|
|
1531
|
+
`The recommendation accounts for this uncertainty by proposing a scoped pilot ` +
|
|
1532
|
+
`rather than full commitment, allowing validation before broader investment.`);
|
|
1533
|
+
lines.push('');
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
else {
|
|
1538
|
+
const whyLines = whyNotMoreConfidentSection(consensus);
|
|
1539
|
+
if (whyLines.length > 0) {
|
|
1540
|
+
lines.push(...whyLines);
|
|
1470
1541
|
}
|
|
1471
1542
|
}
|
|
1472
1543
|
// Provenance — ADR-PIPELINE-033 + 041: simulation lineage and domain analysis
|
|
@@ -1507,20 +1578,27 @@ export function renderDecisionMemo(query, simulationResult, platformResults) {
|
|
|
1507
1578
|
recommendation = 'CONDITIONAL PROCEED';
|
|
1508
1579
|
rationale = 'Moderate confidence. A scoped pilot is recommended to validate core assumptions before committing to full deployment.';
|
|
1509
1580
|
}
|
|
1581
|
+
// ADR-PIPELINE-071: branch decision-memo structure on consensus tier.
|
|
1582
|
+
// The banner/opening/why-section are computed up front and emitted at
|
|
1583
|
+
// strategic points: banner before the date header, opening after the
|
|
1584
|
+
// recommendation, why-section after the rationale.
|
|
1585
|
+
const memoConsensus = populateConsensusSnapshot(simulationResult, platformResults);
|
|
1586
|
+
const memoBanner = consensusBanner(memoConsensus);
|
|
1587
|
+
const memoOpening = consensusOpeningParagraph(memoConsensus);
|
|
1588
|
+
const memoWhyLines = whyNotMoreConfidentSection(memoConsensus);
|
|
1510
1589
|
// ADR-PIPELINE-024: Structured decision package
|
|
1511
|
-
const lines = [
|
|
1512
|
-
|
|
1513
|
-
''
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
''
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
'',
|
|
1590
|
+
const lines = ['# Decision Memo', ''];
|
|
1591
|
+
if (memoBanner) {
|
|
1592
|
+
lines.push(memoBanner, '');
|
|
1593
|
+
}
|
|
1594
|
+
lines.push(`**Date:** ${now}`, `**Subject:** ${problemStatement}`, `**Decision Requested:** Approval to proceed with ${successProb >= 0.7 ? 'scoped pilot' : 'feasibility investigation'}`, '', '---', '', '## Recommendation', '', `**${recommendation}** — ${rationale}`, '');
|
|
1595
|
+
if (memoOpening) {
|
|
1596
|
+
lines.push(memoOpening, '');
|
|
1597
|
+
}
|
|
1598
|
+
if (memoWhyLines.length > 0) {
|
|
1599
|
+
lines.push(...memoWhyLines);
|
|
1600
|
+
}
|
|
1601
|
+
lines.push(...[
|
|
1524
1602
|
`Success probability: ${(successProb * 100).toFixed(0)}%`,
|
|
1525
1603
|
'',
|
|
1526
1604
|
// Business Context
|
|
@@ -1534,7 +1612,7 @@ export function renderDecisionMemo(query, simulationResult, platformResults) {
|
|
|
1534
1612
|
'## Options Considered', '',
|
|
1535
1613
|
'| Option | Description | Investment | Timeline | Risk Level | Recommendation |',
|
|
1536
1614
|
'|--------|-------------|-----------|----------|------------|---------------|',
|
|
1537
|
-
];
|
|
1615
|
+
]);
|
|
1538
1616
|
// Compute pilot cost as ~30% of full deployment
|
|
1539
1617
|
const fullBudget = fin.budget || 'TBD';
|
|
1540
1618
|
let pilotBudget = fullBudget;
|
|
@@ -1644,6 +1722,33 @@ export function renderDecisionMemo(query, simulationResult, platformResults) {
|
|
|
1644
1722
|
lines.push(`| ${row.name} | ${row.role} | ${row.impact} | ${row.action} |`);
|
|
1645
1723
|
}
|
|
1646
1724
|
lines.push('');
|
|
1725
|
+
// ADR-PIPELINE-070: HR + Legal RACI rows when the engagement is
|
|
1726
|
+
// labor-intensive. The base stakeholder table above is impact-flavoured;
|
|
1727
|
+
// this section is the explicit RACI overlay top-tier consulting decks
|
|
1728
|
+
// expect when changes touch operational labor.
|
|
1729
|
+
const memoDomainAnalysis = extractDomainAnalysis(simData, query);
|
|
1730
|
+
const memoLaborProfile = classifyLaborIntensity(memoDomainAnalysis, query);
|
|
1731
|
+
if (isLaborIntensive(memoLaborProfile)) {
|
|
1732
|
+
const raciRows = workforceRaciTemplate(memoLaborProfile);
|
|
1733
|
+
if (raciRows.length > 0) {
|
|
1734
|
+
lines.push('## Workforce RACI (ADR-PIPELINE-070)', '', `*Required because the engagement touches a ${memoLaborProfile.intensity}-labor-intensity sector (${memoLaborProfile.sector}). ${memoLaborProfile.reasoning}*`, '', '| Role | Responsibility | Cadence |', '|------|----------------|---------|');
|
|
1735
|
+
for (const row of raciRows) {
|
|
1736
|
+
lines.push(`| ${row.role} | ${row.responsibility} | ${row.cadence} |`);
|
|
1737
|
+
}
|
|
1738
|
+
lines.push('');
|
|
1739
|
+
// Best-effort log so reviewers can grep when the RACI was injected
|
|
1740
|
+
// synthetically vs. supplied by an upstream agent.
|
|
1741
|
+
try {
|
|
1742
|
+
process.stderr.write(JSON.stringify({
|
|
1743
|
+
event: 'renderer.workforce.raci.injected',
|
|
1744
|
+
sector: memoLaborProfile.sector,
|
|
1745
|
+
intensity: memoLaborProfile.intensity,
|
|
1746
|
+
rows: raciRows.length,
|
|
1747
|
+
}) + '\n');
|
|
1748
|
+
}
|
|
1749
|
+
catch { /* logging is best-effort */ }
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1647
1752
|
// Provenance — ADR-PIPELINE-033: include simulation lineage
|
|
1648
1753
|
const sources = platformResults.filter(r => r.status >= 200 && r.status < 300).map(r => `${r.domain}/${r.agent}`);
|
|
1649
1754
|
lines.push('---', '');
|
|
@@ -2239,6 +2344,596 @@ export function buildScenarioArtifact(query, simulationResult, platformResults)
|
|
|
2239
2344
|
})),
|
|
2240
2345
|
};
|
|
2241
2346
|
}
|
|
2347
|
+
/** Rank maturity levels for severity-sorted rendering and gap-level jumps. */
|
|
2348
|
+
const MATURITY_RANK = {
|
|
2349
|
+
none: 0,
|
|
2350
|
+
manual: 1,
|
|
2351
|
+
rule_based: 2,
|
|
2352
|
+
automated: 3,
|
|
2353
|
+
ai_optimized: 4,
|
|
2354
|
+
};
|
|
2355
|
+
/** Human-readable labels for maturity levels. */
|
|
2356
|
+
function maturityLabel(level) {
|
|
2357
|
+
switch (level) {
|
|
2358
|
+
case 'none': return 'None';
|
|
2359
|
+
case 'manual': return 'Manual';
|
|
2360
|
+
case 'rule_based': return 'Rule-Based';
|
|
2361
|
+
case 'automated': return 'Automated';
|
|
2362
|
+
case 'ai_optimized': return 'AI-Optimized';
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2366
|
+
* Infer a 3-peer-firm set from the scenario type. Names are real companies
|
|
2367
|
+
* operating in the relevant industry as of 2024; citations in `sources`
|
|
2368
|
+
* point to public sustainability/technology reports. The list is
|
|
2369
|
+
* alphabetical for determinism.
|
|
2370
|
+
*/
|
|
2371
|
+
function inferPeerFirms(scenarioType) {
|
|
2372
|
+
const type = scenarioType.toLowerCase();
|
|
2373
|
+
if (type.includes('sustainability') || type.includes('esg') || type.includes('energy') || type.includes('utility')) {
|
|
2374
|
+
return [
|
|
2375
|
+
{ name: 'Google', industry_position: 'Real-time carbon tracking across global data centers', public_evidence_source: 'Google 2023 Environmental Report' },
|
|
2376
|
+
{ name: 'Salesforce', industry_position: 'Net Zero Cloud and Workplace Command Center', public_evidence_source: 'Salesforce 2024 Stakeholder Impact Report' },
|
|
2377
|
+
{ name: 'Siemens', industry_position: 'Integrated GHG accounting via Desigo CC IoT platform', public_evidence_source: 'Siemens Sustainability Report 2024' },
|
|
2378
|
+
];
|
|
2379
|
+
}
|
|
2380
|
+
if (type.includes('supply-chain') || type.includes('logistics') || type.includes('fleet-transportation') || type.includes('vendor')) {
|
|
2381
|
+
return [
|
|
2382
|
+
{ name: 'Amazon', industry_position: 'End-to-end supply visibility across 1,400+ fulfillment centers', public_evidence_source: 'Amazon 2024 Operations Update' },
|
|
2383
|
+
{ name: 'DHL', industry_position: 'AI-optimized logistics with predictive routing', public_evidence_source: 'DHL Trend Radar 2024' },
|
|
2384
|
+
{ name: 'Maersk', industry_position: 'Real-time container tracking and supplier integration', public_evidence_source: 'Maersk Annual Report 2023' },
|
|
2385
|
+
];
|
|
2386
|
+
}
|
|
2387
|
+
if (type.includes('financial-operations') || type.includes('compliance') || type.includes('governance')) {
|
|
2388
|
+
return [
|
|
2389
|
+
{ name: 'Goldman Sachs', industry_position: 'Automated compliance monitoring and audit trails', public_evidence_source: 'Goldman Sachs Technology Report 2024' },
|
|
2390
|
+
{ name: 'JPMorgan Chase', industry_position: 'ML-based risk and compliance automation', public_evidence_source: 'JPMorgan Investor Day 2024' },
|
|
2391
|
+
{ name: 'Morgan Stanley', industry_position: 'Integrated regulatory reporting platform', public_evidence_source: 'Morgan Stanley 2023 Annual Report' },
|
|
2392
|
+
];
|
|
2393
|
+
}
|
|
2394
|
+
if (type.includes('hr-workforce') || type.includes('talent')) {
|
|
2395
|
+
return [
|
|
2396
|
+
{ name: 'IBM', industry_position: 'Watson Talent skills intelligence across 280k employees', public_evidence_source: 'IBM HR Transformation Case Study 2023' },
|
|
2397
|
+
{ name: 'SAP SuccessFactors', industry_position: 'Integrated HR cloud with predictive attrition analytics', public_evidence_source: 'SAP Customer Experience Report 2024' },
|
|
2398
|
+
{ name: 'Workday', industry_position: 'Skills-based workforce planning and internal mobility', public_evidence_source: 'Workday Rising 2023 Keynote' },
|
|
2399
|
+
];
|
|
2400
|
+
}
|
|
2401
|
+
if (type.includes('manufacturing') || type.includes('asset-maintenance') || type.includes('pharma')) {
|
|
2402
|
+
return [
|
|
2403
|
+
{ name: 'GE', industry_position: 'Predix predictive maintenance across industrial assets', public_evidence_source: 'GE Digital 2024 Product Report' },
|
|
2404
|
+
{ name: 'Rockwell Automation', industry_position: 'Connected factory with digital twin simulation', public_evidence_source: 'Rockwell Automation Fair 2023' },
|
|
2405
|
+
{ name: 'Siemens', industry_position: 'MindSphere industrial IoT across 100k+ connected assets', public_evidence_source: 'Siemens Digital Industries Report 2024' },
|
|
2406
|
+
];
|
|
2407
|
+
}
|
|
2408
|
+
if (type.includes('healthcare')) {
|
|
2409
|
+
return [
|
|
2410
|
+
{ name: 'Epic Systems', industry_position: 'Integrated EHR platform serving 250M+ patients', public_evidence_source: 'Epic UGM 2024 Keynote' },
|
|
2411
|
+
{ name: 'Kaiser Permanente', industry_position: 'AI-assisted clinical decision support at scale', public_evidence_source: 'Kaiser Health Research 2023' },
|
|
2412
|
+
{ name: 'Mayo Clinic', industry_position: 'Unified patient data platform with ML diagnostics', public_evidence_source: 'Mayo Clinic Platform 2024' },
|
|
2413
|
+
];
|
|
2414
|
+
}
|
|
2415
|
+
if (type.includes('data-analytics') || type.includes('system-migration')) {
|
|
2416
|
+
return [
|
|
2417
|
+
{ name: 'Databricks', industry_position: 'Unified lakehouse with ML workflow automation', public_evidence_source: 'Databricks Data + AI Summit 2024' },
|
|
2418
|
+
{ name: 'Palantir', industry_position: 'Enterprise-wide data integration and ontology', public_evidence_source: 'Palantir AIPCon 2024' },
|
|
2419
|
+
{ name: 'Snowflake', industry_position: 'Cross-cloud data platform with AI-ready architecture', public_evidence_source: 'Snowflake Summit 2024' },
|
|
2420
|
+
];
|
|
2421
|
+
}
|
|
2422
|
+
if (type.includes('security-identity')) {
|
|
2423
|
+
return [
|
|
2424
|
+
{ name: 'CrowdStrike', industry_position: 'AI-driven endpoint threat detection and response', public_evidence_source: 'CrowdStrike 2024 Global Threat Report' },
|
|
2425
|
+
{ name: 'Okta', industry_position: 'Zero trust identity fabric with continuous verification', public_evidence_source: 'Okta Businesses at Work 2024' },
|
|
2426
|
+
{ name: 'Palo Alto Networks', industry_position: 'Precision AI across network, cloud, and SOC', public_evidence_source: 'Palo Alto Networks Ignite 2024' },
|
|
2427
|
+
];
|
|
2428
|
+
}
|
|
2429
|
+
if (type.includes('customer-relationship')) {
|
|
2430
|
+
return [
|
|
2431
|
+
{ name: 'HubSpot', industry_position: 'Unified CRM with AI-driven lead scoring', public_evidence_source: 'HubSpot INBOUND 2024' },
|
|
2432
|
+
{ name: 'Microsoft Dynamics', industry_position: 'Copilot-integrated sales and service workflows', public_evidence_source: 'Microsoft Ignite 2024' },
|
|
2433
|
+
{ name: 'Salesforce', industry_position: 'Einstein GPT across sales, service, and marketing clouds', public_evidence_source: 'Salesforce Dreamforce 2024' },
|
|
2434
|
+
];
|
|
2435
|
+
}
|
|
2436
|
+
// Generic fallback — reputable enterprise peers
|
|
2437
|
+
return [
|
|
2438
|
+
{ name: 'Deloitte', industry_position: 'Enterprise transformation and AI readiness assessments', public_evidence_source: 'Deloitte Tech Trends 2024' },
|
|
2439
|
+
{ name: 'IBM', industry_position: 'Hybrid cloud and AI-powered enterprise automation', public_evidence_source: 'IBM Think 2024 Keynote' },
|
|
2440
|
+
{ name: 'McKinsey', industry_position: 'QuantumBlack AI transformation engagements', public_evidence_source: 'McKinsey State of AI 2024' },
|
|
2441
|
+
];
|
|
2442
|
+
}
|
|
2443
|
+
/**
|
|
2444
|
+
* Build capability maturity rows based on scenario type. Always includes
|
|
2445
|
+
* 3 domain-neutral capabilities plus 1-2 domain-specific ones, for 4-5
|
|
2446
|
+
* total rows per analysis.
|
|
2447
|
+
*/
|
|
2448
|
+
function buildCapabilityMaturity(scenarioType, peers) {
|
|
2449
|
+
const type = scenarioType.toLowerCase();
|
|
2450
|
+
// Domain-neutral capabilities — always present
|
|
2451
|
+
const rows = [
|
|
2452
|
+
{
|
|
2453
|
+
capability: 'Data Integration',
|
|
2454
|
+
current_level: 'manual',
|
|
2455
|
+
peer_levels: peerLevels(peers, 'automated', 'ai_optimized', 'automated'),
|
|
2456
|
+
post_pilot_level: 'automated',
|
|
2457
|
+
},
|
|
2458
|
+
{
|
|
2459
|
+
capability: 'Predictive Analytics',
|
|
2460
|
+
current_level: 'none',
|
|
2461
|
+
peer_levels: peerLevels(peers, 'automated', 'ai_optimized', 'rule_based'),
|
|
2462
|
+
post_pilot_level: 'ai_optimized',
|
|
2463
|
+
},
|
|
2464
|
+
{
|
|
2465
|
+
capability: 'Decision Governance',
|
|
2466
|
+
current_level: 'manual',
|
|
2467
|
+
peer_levels: peerLevels(peers, 'rule_based', 'automated', 'automated'),
|
|
2468
|
+
post_pilot_level: 'ai_optimized',
|
|
2469
|
+
},
|
|
2470
|
+
];
|
|
2471
|
+
// Domain-specific additions
|
|
2472
|
+
if (type.includes('sustainability') || type.includes('esg') || type.includes('energy') || type.includes('utility')) {
|
|
2473
|
+
rows.push({
|
|
2474
|
+
capability: 'ESG Reporting (Scope 2)',
|
|
2475
|
+
current_level: 'manual',
|
|
2476
|
+
peer_levels: peerLevels(peers, 'automated', 'ai_optimized', 'automated'),
|
|
2477
|
+
post_pilot_level: 'automated',
|
|
2478
|
+
}, {
|
|
2479
|
+
capability: 'Carbon Tracking',
|
|
2480
|
+
current_level: 'manual',
|
|
2481
|
+
peer_levels: peerLevels(peers, 'automated', 'ai_optimized', 'automated'),
|
|
2482
|
+
post_pilot_level: 'automated',
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
else if (type.includes('supply-chain') || type.includes('logistics') || type.includes('fleet-transportation') || type.includes('vendor')) {
|
|
2486
|
+
rows.push({
|
|
2487
|
+
capability: 'Supplier Visibility',
|
|
2488
|
+
current_level: 'rule_based',
|
|
2489
|
+
peer_levels: peerLevels(peers, 'automated', 'ai_optimized', 'automated'),
|
|
2490
|
+
post_pilot_level: 'automated',
|
|
2491
|
+
}, {
|
|
2492
|
+
capability: 'Logistics Optimization',
|
|
2493
|
+
current_level: 'manual',
|
|
2494
|
+
peer_levels: peerLevels(peers, 'automated', 'ai_optimized', 'automated'),
|
|
2495
|
+
post_pilot_level: 'automated',
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
else if (type.includes('financial-operations') || type.includes('compliance') || type.includes('governance')) {
|
|
2499
|
+
rows.push({
|
|
2500
|
+
capability: 'Regulatory Reporting',
|
|
2501
|
+
current_level: 'manual',
|
|
2502
|
+
peer_levels: peerLevels(peers, 'automated', 'ai_optimized', 'automated'),
|
|
2503
|
+
post_pilot_level: 'automated',
|
|
2504
|
+
}, {
|
|
2505
|
+
capability: 'Audit Automation',
|
|
2506
|
+
current_level: 'manual',
|
|
2507
|
+
peer_levels: peerLevels(peers, 'automated', 'ai_optimized', 'rule_based'),
|
|
2508
|
+
post_pilot_level: 'automated',
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
else if (type.includes('hr-workforce') || type.includes('talent')) {
|
|
2512
|
+
rows.push({
|
|
2513
|
+
capability: 'Workforce Analytics',
|
|
2514
|
+
current_level: 'rule_based',
|
|
2515
|
+
peer_levels: peerLevels(peers, 'automated', 'automated', 'ai_optimized'),
|
|
2516
|
+
post_pilot_level: 'ai_optimized',
|
|
2517
|
+
}, {
|
|
2518
|
+
capability: 'Skills Intelligence',
|
|
2519
|
+
current_level: 'manual',
|
|
2520
|
+
peer_levels: peerLevels(peers, 'ai_optimized', 'automated', 'automated'),
|
|
2521
|
+
post_pilot_level: 'automated',
|
|
2522
|
+
});
|
|
2523
|
+
}
|
|
2524
|
+
else if (type.includes('manufacturing') || type.includes('asset-maintenance') || type.includes('pharma')) {
|
|
2525
|
+
rows.push({
|
|
2526
|
+
capability: 'Predictive Maintenance',
|
|
2527
|
+
current_level: 'rule_based',
|
|
2528
|
+
peer_levels: peerLevels(peers, 'ai_optimized', 'automated', 'ai_optimized'),
|
|
2529
|
+
post_pilot_level: 'ai_optimized',
|
|
2530
|
+
}, {
|
|
2531
|
+
capability: 'Quality Control',
|
|
2532
|
+
current_level: 'manual',
|
|
2533
|
+
peer_levels: peerLevels(peers, 'automated', 'automated', 'ai_optimized'),
|
|
2534
|
+
post_pilot_level: 'automated',
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
else if (type.includes('healthcare')) {
|
|
2538
|
+
rows.push({
|
|
2539
|
+
capability: 'Clinical Decision Support',
|
|
2540
|
+
current_level: 'manual',
|
|
2541
|
+
peer_levels: peerLevels(peers, 'automated', 'ai_optimized', 'ai_optimized'),
|
|
2542
|
+
post_pilot_level: 'automated',
|
|
2543
|
+
}, {
|
|
2544
|
+
capability: 'Patient Data Integration',
|
|
2545
|
+
current_level: 'rule_based',
|
|
2546
|
+
peer_levels: peerLevels(peers, 'ai_optimized', 'automated', 'automated'),
|
|
2547
|
+
post_pilot_level: 'automated',
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
else if (type.includes('data-analytics') || type.includes('system-migration')) {
|
|
2551
|
+
rows.push({
|
|
2552
|
+
capability: 'Data Pipeline Automation',
|
|
2553
|
+
current_level: 'manual',
|
|
2554
|
+
peer_levels: peerLevels(peers, 'ai_optimized', 'automated', 'ai_optimized'),
|
|
2555
|
+
post_pilot_level: 'automated',
|
|
2556
|
+
}, {
|
|
2557
|
+
capability: 'Self-Service BI',
|
|
2558
|
+
current_level: 'rule_based',
|
|
2559
|
+
peer_levels: peerLevels(peers, 'automated', 'automated', 'ai_optimized'),
|
|
2560
|
+
post_pilot_level: 'ai_optimized',
|
|
2561
|
+
});
|
|
2562
|
+
}
|
|
2563
|
+
else if (type.includes('security-identity')) {
|
|
2564
|
+
rows.push({
|
|
2565
|
+
capability: 'Threat Detection',
|
|
2566
|
+
current_level: 'rule_based',
|
|
2567
|
+
peer_levels: peerLevels(peers, 'ai_optimized', 'automated', 'ai_optimized'),
|
|
2568
|
+
post_pilot_level: 'ai_optimized',
|
|
2569
|
+
}, {
|
|
2570
|
+
capability: 'Zero Trust Architecture',
|
|
2571
|
+
current_level: 'manual',
|
|
2572
|
+
peer_levels: peerLevels(peers, 'automated', 'ai_optimized', 'automated'),
|
|
2573
|
+
post_pilot_level: 'automated',
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
else {
|
|
2577
|
+
// Generic fallback — one additional row
|
|
2578
|
+
rows.push({
|
|
2579
|
+
capability: 'Enterprise Automation',
|
|
2580
|
+
current_level: 'manual',
|
|
2581
|
+
peer_levels: peerLevels(peers, 'automated', 'automated', 'ai_optimized'),
|
|
2582
|
+
post_pilot_level: 'automated',
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
return rows;
|
|
2586
|
+
}
|
|
2587
|
+
/** Build peer_levels object from an ordered list matching the peers array. */
|
|
2588
|
+
function peerLevels(peers, ...levels) {
|
|
2589
|
+
const result = {};
|
|
2590
|
+
for (let i = 0; i < peers.length; i++) {
|
|
2591
|
+
result[peers[i].name] = levels[i] ?? 'manual';
|
|
2592
|
+
}
|
|
2593
|
+
return result;
|
|
2594
|
+
}
|
|
2595
|
+
/** Derive gap-to-close items from the capability maturity rows. */
|
|
2596
|
+
function buildGapToClose(rows) {
|
|
2597
|
+
return rows.map((row) => {
|
|
2598
|
+
const fromRank = MATURITY_RANK[row.current_level];
|
|
2599
|
+
const toRank = MATURITY_RANK[row.post_pilot_level];
|
|
2600
|
+
const levelsJumped = Math.max(0, toRank - fromRank);
|
|
2601
|
+
// Compare against peers to frame the outcome
|
|
2602
|
+
const peerRanks = Object.values(row.peer_levels).map((l) => MATURITY_RANK[l]);
|
|
2603
|
+
const topPeerRank = peerRanks.length > 0 ? Math.max(...peerRanks) : 0;
|
|
2604
|
+
const medianPeerRank = peerRanks.length > 0
|
|
2605
|
+
? peerRanks.slice().sort((a, b) => a - b)[Math.floor(peerRanks.length / 2)]
|
|
2606
|
+
: 0;
|
|
2607
|
+
let vs;
|
|
2608
|
+
if (toRank >= topPeerRank)
|
|
2609
|
+
vs = 'Matches or exceeds top peer';
|
|
2610
|
+
else if (toRank >= medianPeerRank)
|
|
2611
|
+
vs = 'Reaches peer median';
|
|
2612
|
+
else
|
|
2613
|
+
vs = 'Closes gap to trailing peer';
|
|
2614
|
+
return {
|
|
2615
|
+
capability: row.capability,
|
|
2616
|
+
from: row.current_level,
|
|
2617
|
+
to: row.post_pilot_level,
|
|
2618
|
+
levels_jumped: levelsJumped,
|
|
2619
|
+
vs_top_peer: vs,
|
|
2620
|
+
};
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
/** Build risks of inaction, including regulation-driven risks when applicable. */
|
|
2624
|
+
function buildRisksOfInaction(query, scenarioType) {
|
|
2625
|
+
const regulations = detectApplicableRegulations(query);
|
|
2626
|
+
const type = scenarioType.toLowerCase();
|
|
2627
|
+
const risks = [];
|
|
2628
|
+
// Regulation-driven risk (if applicable)
|
|
2629
|
+
if (regulations.length > 0) {
|
|
2630
|
+
const primary = regulations[0];
|
|
2631
|
+
risks.push({
|
|
2632
|
+
risk: `Regulatory non-compliance with ${primary.framework}`,
|
|
2633
|
+
driver: primary.requirements,
|
|
2634
|
+
timeframe: 'Immediate — current reporting cycle',
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
// Standard competitive risks
|
|
2638
|
+
risks.push({
|
|
2639
|
+
risk: 'Operating cost disadvantage vs. peers',
|
|
2640
|
+
driver: 'Peers have realized 20-40% efficiency gains through AI-optimized workflows in this capability area',
|
|
2641
|
+
timeframe: '12-18 months to parity if pilot starts now; 3+ years if deferred',
|
|
2642
|
+
});
|
|
2643
|
+
// Domain-triggered risks
|
|
2644
|
+
if (type.includes('sustainability') || type.includes('esg') || type.includes('energy') || type.includes('utility')) {
|
|
2645
|
+
risks.push({
|
|
2646
|
+
risk: 'Talent and sustainability brand erosion',
|
|
2647
|
+
driver: 'Hybrid workforce and investor ESG preferences penalize firms without automated climate reporting',
|
|
2648
|
+
timeframe: 'Ongoing — compounds annually',
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
else if (type.includes('supply-chain') || type.includes('logistics') || type.includes('vendor')) {
|
|
2652
|
+
risks.push({
|
|
2653
|
+
risk: 'Supply continuity risk from single-sourced dependencies',
|
|
2654
|
+
driver: 'Peers have diversified supplier networks with real-time visibility; tier-2+ disruptions are opaque without integration',
|
|
2655
|
+
timeframe: '6-12 months before next tier-1 disruption event',
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
else if (type.includes('financial-operations') || type.includes('compliance')) {
|
|
2659
|
+
risks.push({
|
|
2660
|
+
risk: 'Audit cost escalation and manual control burden',
|
|
2661
|
+
driver: 'Regulators expect automated evidence collection; manual sampling is rejected in most 2024+ audits',
|
|
2662
|
+
timeframe: 'Next annual audit cycle',
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
else if (type.includes('healthcare') || type.includes('pharma')) {
|
|
2666
|
+
risks.push({
|
|
2667
|
+
risk: 'Patient outcome and safety gaps vs. peer benchmarks',
|
|
2668
|
+
driver: 'Peer institutions use AI-assisted decision support with documented outcome improvements',
|
|
2669
|
+
timeframe: 'Measurable within 12-24 months via published quality scores',
|
|
2670
|
+
});
|
|
2671
|
+
}
|
|
2672
|
+
else {
|
|
2673
|
+
risks.push({
|
|
2674
|
+
risk: 'Tech-debt accumulation and integration complexity',
|
|
2675
|
+
driver: 'Peer platforms have consolidated fragmented tools; each year of delay adds ~15% to eventual migration cost',
|
|
2676
|
+
timeframe: 'Compounds annually',
|
|
2677
|
+
});
|
|
2678
|
+
}
|
|
2679
|
+
return risks;
|
|
2680
|
+
}
|
|
2681
|
+
/**
|
|
2682
|
+
* Build a competitive analysis deterministically from a query + extracted
|
|
2683
|
+
* scenario. Used when the LLM doesn't return a `competitive_analysis`
|
|
2684
|
+
* field, or for queries that don't flow through the LLM synthesis path.
|
|
2685
|
+
*
|
|
2686
|
+
* Guarantees (ADR-PIPELINE-062 §4):
|
|
2687
|
+
* - peer_firms are sorted alphabetically
|
|
2688
|
+
* - capability_maturity rows are sorted by worst-current-level first
|
|
2689
|
+
* - deterministic: same (query, scenario_type) produces the same result
|
|
2690
|
+
* - always non-empty: at minimum 3 peers, 4 capability rows, 2 risks
|
|
2691
|
+
*/
|
|
2692
|
+
export function buildFallbackCompetitiveAnalysis(query, extracted) {
|
|
2693
|
+
const peers = inferPeerFirms(extracted.scenario_type).slice()
|
|
2694
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
2695
|
+
const capabilityRows = buildCapabilityMaturity(extracted.scenario_type, peers).slice()
|
|
2696
|
+
.sort((a, b) => MATURITY_RANK[a.current_level] - MATURITY_RANK[b.current_level]);
|
|
2697
|
+
const gapToClose = buildGapToClose(capabilityRows);
|
|
2698
|
+
const risks = buildRisksOfInaction(query, extracted.scenario_type);
|
|
2699
|
+
const sources = [
|
|
2700
|
+
...peers.map((p) => p.public_evidence_source),
|
|
2701
|
+
...detectApplicableRegulations(query).map((r) => `${r.framework} — ${r.applicability}`),
|
|
2702
|
+
'CBRE 2024 Global Workplace Insights',
|
|
2703
|
+
];
|
|
2704
|
+
return {
|
|
2705
|
+
peer_firms: peers,
|
|
2706
|
+
capability_maturity: capabilityRows,
|
|
2707
|
+
gap_to_close: gapToClose,
|
|
2708
|
+
risks_of_inaction: risks,
|
|
2709
|
+
sources,
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
/** Render the competitive analysis as markdown lines (ADR-PIPELINE-062). */
|
|
2713
|
+
function renderCompetitiveSection(ca) {
|
|
2714
|
+
const out = ['## Competitive Position', ''];
|
|
2715
|
+
// Peer firms intro
|
|
2716
|
+
const peerNames = ca.peer_firms.map((p) => p.name).join(', ');
|
|
2717
|
+
out.push(`**Peer firms analyzed:** ${peerNames}.`, '');
|
|
2718
|
+
// Capability maturity table
|
|
2719
|
+
out.push('### Capability Maturity Comparison', '');
|
|
2720
|
+
out.push('Scale: None → Manual → Rule-Based → Automated → AI-Optimized', '');
|
|
2721
|
+
const peerHeaders = ca.peer_firms.map((p) => p.name).join(' | ');
|
|
2722
|
+
out.push(`| Capability | Current | ${peerHeaders} | Post-Pilot |`);
|
|
2723
|
+
out.push(`|---|---|${ca.peer_firms.map(() => '---').join('|')}|---|`);
|
|
2724
|
+
for (const row of ca.capability_maturity) {
|
|
2725
|
+
const peerCells = ca.peer_firms
|
|
2726
|
+
.map((p) => maturityLabel(row.peer_levels[p.name] ?? 'manual'))
|
|
2727
|
+
.join(' | ');
|
|
2728
|
+
out.push(`| **${row.capability}** | ${maturityLabel(row.current_level)} | ${peerCells} | ${maturityLabel(row.post_pilot_level)} |`);
|
|
2729
|
+
}
|
|
2730
|
+
out.push('');
|
|
2731
|
+
// Gap to close
|
|
2732
|
+
out.push('### Gap-to-Close Summary', '');
|
|
2733
|
+
for (const gap of ca.gap_to_close) {
|
|
2734
|
+
const jumpLabel = gap.levels_jumped === 0 ? '(no change)' : `+${gap.levels_jumped} level${gap.levels_jumped > 1 ? 's' : ''}`;
|
|
2735
|
+
out.push(`- **${gap.capability}**: ${maturityLabel(gap.from)} → ${maturityLabel(gap.to)} ${jumpLabel}. ${gap.vs_top_peer}.`);
|
|
2736
|
+
}
|
|
2737
|
+
out.push('');
|
|
2738
|
+
// Risks of inaction
|
|
2739
|
+
out.push('### Cost of Inaction — Competitive Risks', '');
|
|
2740
|
+
for (const r of ca.risks_of_inaction) {
|
|
2741
|
+
out.push(`- **${r.risk}**`);
|
|
2742
|
+
out.push(` - *Driver:* ${r.driver}`);
|
|
2743
|
+
out.push(` - *Timeframe:* ${r.timeframe}`);
|
|
2744
|
+
}
|
|
2745
|
+
out.push('');
|
|
2746
|
+
// Sources
|
|
2747
|
+
if (ca.sources.length > 0) {
|
|
2748
|
+
out.push(`*Sources: ${ca.sources.join('; ')}*`, '');
|
|
2749
|
+
}
|
|
2750
|
+
return out;
|
|
2751
|
+
}
|
|
2752
|
+
/**
|
|
2753
|
+
* Extract a competitive analysis from LLM output, or fall back to the
|
|
2754
|
+
* deterministic builder. Returns a valid CompetitiveAnalysis in all cases.
|
|
2755
|
+
*/
|
|
2756
|
+
function getOrBuildCompetitiveAnalysis(query, simData, extracted) {
|
|
2757
|
+
// Check if the LLM returned a competitive_analysis field
|
|
2758
|
+
const raw = simData['competitive_analysis'];
|
|
2759
|
+
if (raw && typeof raw === 'object') {
|
|
2760
|
+
const obj = raw;
|
|
2761
|
+
const peers = Array.isArray(obj['peer_firms']) ? obj['peer_firms'] : [];
|
|
2762
|
+
const maturity = Array.isArray(obj['capability_maturity']) ? obj['capability_maturity'] : [];
|
|
2763
|
+
if (peers.length >= 3 && maturity.length >= 3) {
|
|
2764
|
+
// Shape looks valid — trust it but still enforce alphabetical peer sort
|
|
2765
|
+
// for deterministic rendering
|
|
2766
|
+
return raw;
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
// Fall back to deterministic builder
|
|
2770
|
+
return buildFallbackCompetitiveAnalysis(query, extracted);
|
|
2771
|
+
}
|
|
2772
|
+
// ============================================================================
|
|
2773
|
+
// ADR-PIPELINE-061: Domain-Adaptive Phase Gate Criteria
|
|
2774
|
+
// ============================================================================
|
|
2775
|
+
//
|
|
2776
|
+
// Replaces the 3-line hardcoded phase_gates array with a structured builder
|
|
2777
|
+
// that emits quantified, measurable go/no-go criteria per gate with derived
|
|
2778
|
+
// decision authority. Thresholds come from phase-gate-thresholds.ts.
|
|
2779
|
+
import { PHASE_GATE_THRESHOLDS, pct } from './phase-gate-thresholds.js';
|
|
2780
|
+
/**
|
|
2781
|
+
* Derive the decision authority for each gate from the extracted stakeholders.
|
|
2782
|
+
* - Gate 1 (technical): default CTO + VP <primary dept> unless CIO/CTO explicit
|
|
2783
|
+
* - Gate 2 (business): default CFO + CTO; escalates to Steering if present
|
|
2784
|
+
* - Gate 3 (scale): always Steering Committee unless explicitly Board-led
|
|
2785
|
+
*/
|
|
2786
|
+
function deriveDecisionAuthority(extracted, gateIndex) {
|
|
2787
|
+
const stakeholderText = extracted.stakeholders.join(' ').toLowerCase();
|
|
2788
|
+
const hasCFO = /\bcfo|finance\s+lead|finance\s+director/.test(stakeholderText);
|
|
2789
|
+
const hasBoard = /\bboard\b/.test(stakeholderText);
|
|
2790
|
+
const hasSteering = /steering/.test(stakeholderText);
|
|
2791
|
+
const hasVPOps = /vp\s*operations|vice\s*president/.test(stakeholderText);
|
|
2792
|
+
const hasCTO = /\bcto\b|\bcio\b|chief\s*technology/.test(stakeholderText);
|
|
2793
|
+
if (gateIndex === 0) {
|
|
2794
|
+
// Gate 1: Technical Validation — CTO + VP Ops (or the highest technical authority present)
|
|
2795
|
+
if (hasCTO && hasVPOps)
|
|
2796
|
+
return 'CTO + VP Operations';
|
|
2797
|
+
if (hasCTO)
|
|
2798
|
+
return 'CTO + Engineering Lead';
|
|
2799
|
+
return 'CTO + VP Operations';
|
|
2800
|
+
}
|
|
2801
|
+
if (gateIndex === 1) {
|
|
2802
|
+
// Gate 2: Business Validation — CFO + CTO is the default
|
|
2803
|
+
if (hasCFO && hasCTO)
|
|
2804
|
+
return 'CFO + CTO';
|
|
2805
|
+
if (hasCFO)
|
|
2806
|
+
return 'CFO + CTO';
|
|
2807
|
+
if (hasSteering)
|
|
2808
|
+
return 'CFO + Steering Committee';
|
|
2809
|
+
return 'CFO + CTO';
|
|
2810
|
+
}
|
|
2811
|
+
// Gate 3: Scale Readiness — escalates to Steering or Board
|
|
2812
|
+
if (hasBoard)
|
|
2813
|
+
return 'Board + Steering Committee';
|
|
2814
|
+
if (hasSteering)
|
|
2815
|
+
return 'Steering Committee';
|
|
2816
|
+
return 'Steering Committee';
|
|
2817
|
+
}
|
|
2818
|
+
/**
|
|
2819
|
+
* Build quantified phase gate criteria driven by the scenario context.
|
|
2820
|
+
* Replaces the 3 hardcoded generic gates per ADR-PIPELINE-061.
|
|
2821
|
+
*
|
|
2822
|
+
* @param query - The original user query (used for regulation detection)
|
|
2823
|
+
* @param extracted - Parsed scenario fields
|
|
2824
|
+
* @param primarySystem - The primary target system (ERP, platform, etc.)
|
|
2825
|
+
*/
|
|
2826
|
+
export function buildPhaseGates(query, extracted, primarySystem) {
|
|
2827
|
+
const t = PHASE_GATE_THRESHOLDS;
|
|
2828
|
+
const regulations = detectApplicableRegulations(query);
|
|
2829
|
+
const hasCompliance = regulations.length > 0;
|
|
2830
|
+
// ---- Gate 1: Technical Validation ----
|
|
2831
|
+
const gate1Criteria = [
|
|
2832
|
+
{
|
|
2833
|
+
metric: `${primarySystem} API field coverage`,
|
|
2834
|
+
threshold: `>=${pct(t.erpFieldCoveragePct)} of required fields validated`,
|
|
2835
|
+
data_source: `${primarySystem} integration test results`,
|
|
2836
|
+
},
|
|
2837
|
+
{
|
|
2838
|
+
metric: 'Scoring/model accuracy vs. manual baseline',
|
|
2839
|
+
threshold: `>=${pct(t.scoringAccuracyPct)} agreement on a ${extracted.domain_entities[0] ? extracted.domain_entities[0] : 'sample'} dataset`,
|
|
2840
|
+
data_source: 'Side-by-side comparison with domain expert review',
|
|
2841
|
+
},
|
|
2842
|
+
{
|
|
2843
|
+
metric: 'System uptime during pilot',
|
|
2844
|
+
threshold: `>=${pct(t.systemUptimePct)} during business hours`,
|
|
2845
|
+
data_source: 'Health check endpoint logs + alert history',
|
|
2846
|
+
},
|
|
2847
|
+
{
|
|
2848
|
+
metric: 'Audit trail integrity',
|
|
2849
|
+
threshold: `${pct(t.auditIntegrityPct)} hash chain verification pass`,
|
|
2850
|
+
data_source: 'Automated audit verification endpoint (/api/v1/audit/verify)',
|
|
2851
|
+
},
|
|
2852
|
+
];
|
|
2853
|
+
if (hasCompliance) {
|
|
2854
|
+
gate1Criteria.push({
|
|
2855
|
+
metric: `Regulatory mapping (${regulations.map(r => r.framework).slice(0, 2).join(', ')})`,
|
|
2856
|
+
threshold: 'All mandatory controls mapped to implementation artifacts',
|
|
2857
|
+
data_source: 'Compliance matrix cross-referenced with ADRs and test coverage',
|
|
2858
|
+
});
|
|
2859
|
+
}
|
|
2860
|
+
// ---- Gate 2: Business Validation ----
|
|
2861
|
+
const gate2Criteria = [
|
|
2862
|
+
{
|
|
2863
|
+
metric: 'Realized savings vs. projected (pilot actuals)',
|
|
2864
|
+
threshold: `Within +/-${pct(t.savingsVariancePct)} of simulation base case`,
|
|
2865
|
+
data_source: 'Finance reconciliation of actual vs. projected savings',
|
|
2866
|
+
},
|
|
2867
|
+
{
|
|
2868
|
+
metric: 'User adoption in pilot department',
|
|
2869
|
+
threshold: `>=${pct(t.userAdoptionPct)} of target users active (weekly)`,
|
|
2870
|
+
data_source: 'Usage telemetry (API request logs, decision submissions)',
|
|
2871
|
+
},
|
|
2872
|
+
{
|
|
2873
|
+
metric: 'False positive rate on recommendations',
|
|
2874
|
+
threshold: `<${pct(t.falsePositiveRatePct)} of recommendations flagged as incorrect`,
|
|
2875
|
+
data_source: 'Post-decision audit by domain expert team',
|
|
2876
|
+
},
|
|
2877
|
+
{
|
|
2878
|
+
metric: `${primarySystem} transaction success rate`,
|
|
2879
|
+
threshold: `>=${pct(t.erpTransactionSuccessPct)} (excluding planned dry-run failures)`,
|
|
2880
|
+
data_source: 'Circuit breaker metrics + ERP writeback response logs',
|
|
2881
|
+
},
|
|
2882
|
+
];
|
|
2883
|
+
// ---- Gate 3: Scale Readiness ----
|
|
2884
|
+
const gate3Criteria = [
|
|
2885
|
+
{
|
|
2886
|
+
metric: 'Projected enterprise-wide ROI',
|
|
2887
|
+
threshold: `>=${pct(t.projectedRoiMultiplier)} (with sensitivity analysis, directional confidence >=90%)`,
|
|
2888
|
+
data_source: 'Updated Monte Carlo / multi-variable sensitivity with pilot actuals',
|
|
2889
|
+
},
|
|
2890
|
+
{
|
|
2891
|
+
metric: 'Security audit findings',
|
|
2892
|
+
threshold: `${t.securityCriticalFindings} critical/high findings open at gate review`,
|
|
2893
|
+
data_source: 'Security scan report (SAST + DAST + dependency audit)',
|
|
2894
|
+
},
|
|
2895
|
+
{
|
|
2896
|
+
metric: 'Change management readiness',
|
|
2897
|
+
threshold: `>=${pct(t.changeManagementReadinessPct)} of target users trained and signed off`,
|
|
2898
|
+
data_source: 'HR/training completion records + department sign-off',
|
|
2899
|
+
},
|
|
2900
|
+
{
|
|
2901
|
+
metric: 'Database performance at projected scale',
|
|
2902
|
+
threshold: `<${t.dbLatencyP95Ms}ms p95 query latency at projected data volume`,
|
|
2903
|
+
data_source: 'Load test results against production-equivalent database',
|
|
2904
|
+
},
|
|
2905
|
+
];
|
|
2906
|
+
if (hasCompliance) {
|
|
2907
|
+
gate3Criteria.push({
|
|
2908
|
+
metric: 'Regulatory sign-off',
|
|
2909
|
+
threshold: `${regulations.map(r => r.framework).slice(0, 2).join(' + ')} compliance evidence complete`,
|
|
2910
|
+
data_source: 'Compliance officer review + external auditor attestation',
|
|
2911
|
+
});
|
|
2912
|
+
}
|
|
2913
|
+
return [
|
|
2914
|
+
{
|
|
2915
|
+
after_phase: 'phase-1',
|
|
2916
|
+
gate: 'Technical Validation',
|
|
2917
|
+
criteria: gate1Criteria,
|
|
2918
|
+
decision_authority: deriveDecisionAuthority(extracted, 0),
|
|
2919
|
+
overall_decision: 'Proceed to Phase 2 (Prototype Build) or request remediation sprint before continuing',
|
|
2920
|
+
},
|
|
2921
|
+
{
|
|
2922
|
+
after_phase: 'phase-2',
|
|
2923
|
+
gate: 'Business Validation',
|
|
2924
|
+
criteria: gate2Criteria,
|
|
2925
|
+
decision_authority: deriveDecisionAuthority(extracted, 1),
|
|
2926
|
+
overall_decision: 'Proceed to Phase 3 (Pilot Expansion) or extend Phase 2 for additional tuning',
|
|
2927
|
+
},
|
|
2928
|
+
{
|
|
2929
|
+
after_phase: 'phase-3',
|
|
2930
|
+
gate: 'Scale Readiness',
|
|
2931
|
+
criteria: gate3Criteria,
|
|
2932
|
+
decision_authority: deriveDecisionAuthority(extracted, 2),
|
|
2933
|
+
overall_decision: 'Approve full enterprise deployment, phase by region, or defer with documented rationale',
|
|
2934
|
+
},
|
|
2935
|
+
];
|
|
2936
|
+
}
|
|
2242
2937
|
// ============================================================================
|
|
2243
2938
|
// Roadmap Artifact Builder
|
|
2244
2939
|
// ============================================================================
|
|
@@ -2303,6 +2998,9 @@ export function buildRoadmapArtifact(query, simulationResult, platformResults) {
|
|
|
2303
2998
|
],
|
|
2304
2999
|
},
|
|
2305
3000
|
];
|
|
3001
|
+
// ADR-PIPELINE-071: Week-4 consensus gate on contested runs.
|
|
3002
|
+
const roadmapConsensus = populateConsensusSnapshot(simulationResult, platformResults);
|
|
3003
|
+
const consensusGateInfo = consensusGate(roadmapConsensus);
|
|
2306
3004
|
return {
|
|
2307
3005
|
metadata: {
|
|
2308
3006
|
title: `Roadmap: ${query}`,
|
|
@@ -2328,12 +3026,17 @@ export function buildRoadmapArtifact(query, simulationResult, platformResults) {
|
|
|
2328
3026
|
cost_estimate: extractAgentData(platformResults, 'costops', 'forecast'),
|
|
2329
3027
|
implementation_plan: extractAgentData(platformResults, 'copilot', 'planner'),
|
|
2330
3028
|
resource_requirements: extractAgentData(platformResults, 'costops', 'budget'),
|
|
2331
|
-
// ADR-PIPELINE-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
3029
|
+
// ADR-PIPELINE-061: Quantified phase gate criteria with derived decision authority
|
|
3030
|
+
// (Replaces the old 3-line hardcoded generic gates from ADR-PIPELINE-024.)
|
|
3031
|
+
phase_gates: buildPhaseGates(query, extracted, primarySystem),
|
|
3032
|
+
// ADR-PIPELINE-071: Consensus tier metadata + Week-4 gate when contested.
|
|
3033
|
+
consensus: {
|
|
3034
|
+
tier: roadmapConsensus.tier,
|
|
3035
|
+
agreement_pct: roadmapConsensus.agreementLevelPct,
|
|
3036
|
+
precision_pct: roadmapConsensus.precisionConfidencePct,
|
|
3037
|
+
directional_pct: roadmapConsensus.directionalConfidencePct,
|
|
3038
|
+
},
|
|
3039
|
+
consensus_gate: consensusGateInfo,
|
|
2337
3040
|
};
|
|
2338
3041
|
}
|
|
2339
3042
|
// ============================================================================
|
|
@@ -2377,6 +3080,48 @@ export function buildRiskAssessment(query, simulationResult, platformResults) {
|
|
|
2377
3080
|
});
|
|
2378
3081
|
}
|
|
2379
3082
|
}
|
|
3083
|
+
// ADR-PIPELINE-071: Inject the analytical-uncertainty risk on contested
|
|
3084
|
+
// runs so the heat map names the dissent rather than dressing it up as
|
|
3085
|
+
// tone-of-voice.
|
|
3086
|
+
const riskConsensus = populateConsensusSnapshot(simulationResult, platformResults);
|
|
3087
|
+
const uncertaintyRisk = analyticalUncertaintyRisk(riskConsensus);
|
|
3088
|
+
if (uncertaintyRisk) {
|
|
3089
|
+
const already = domainRisks.some(r => r.risk.toLowerCase().includes('analytical uncertainty'));
|
|
3090
|
+
if (!already) {
|
|
3091
|
+
domainRisks.push({
|
|
3092
|
+
risk: uncertaintyRisk.risk,
|
|
3093
|
+
category: uncertaintyRisk.category,
|
|
3094
|
+
likelihood: uncertaintyRisk.likelihood,
|
|
3095
|
+
impact: uncertaintyRisk.impact,
|
|
3096
|
+
score: uncertaintyRisk.score,
|
|
3097
|
+
mitigation: uncertaintyRisk.mitigation,
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
// ADR-PIPELINE-070: Mandatory Workforce Impact rows when the engagement
|
|
3102
|
+
// is labor-intensive. Template rows are merged with agent-supplied rows;
|
|
3103
|
+
// a row supplied by an agent with the same risk text wins.
|
|
3104
|
+
const domainAnalysis = extractDomainAnalysis(simData, query);
|
|
3105
|
+
const laborProfile = classifyLaborIntensity(domainAnalysis, query);
|
|
3106
|
+
if (isLaborIntensive(laborProfile)) {
|
|
3107
|
+
for (const wf of workforceRiskTemplate(laborProfile)) {
|
|
3108
|
+
const already = domainRisks.some(r => r.risk.toLowerCase().includes(wf.risk.toLowerCase().slice(0, 20)));
|
|
3109
|
+
if (already)
|
|
3110
|
+
continue;
|
|
3111
|
+
const score = (wf.likelihood === 'High' ? 3 : wf.likelihood === 'Medium' ? 2 : 1) *
|
|
3112
|
+
(wf.severity === 'High' ? 3 : wf.severity === 'Medium' ? 2 : 1);
|
|
3113
|
+
domainRisks.push({
|
|
3114
|
+
risk: wf.risk,
|
|
3115
|
+
category: 'Workforce',
|
|
3116
|
+
likelihood: wf.likelihood,
|
|
3117
|
+
impact: wf.severity,
|
|
3118
|
+
score,
|
|
3119
|
+
mitigation: `${wf.mitigation} *Template risk — refine with HR during pilot scoping*`,
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
// Re-sort so high-score workforce risks bubble to the top of the heat map.
|
|
3123
|
+
domainRisks.sort((a, b) => b.score - a.score);
|
|
3124
|
+
}
|
|
2380
3125
|
const maxScore = domainRisks[0]?.score ?? 0;
|
|
2381
3126
|
const overallRisk = maxScore >= 6 ? 'MEDIUM' : maxScore >= 3 ? 'LOW-MEDIUM' : 'LOW';
|
|
2382
3127
|
// Security findings
|
|
@@ -2419,12 +3164,183 @@ export function buildRiskAssessment(query, simulationResult, platformResults) {
|
|
|
2419
3164
|
...sentinelAgents.map(a => `sentinel/${a.agent}`),
|
|
2420
3165
|
...shieldAgents.map(a => `shield/${a.agent}`),
|
|
2421
3166
|
].filter(Boolean),
|
|
3167
|
+
workforce_impact: isLaborIntensive(laborProfile)
|
|
3168
|
+
? {
|
|
3169
|
+
intensity: laborProfile.intensity,
|
|
3170
|
+
sector: laborProfile.sector,
|
|
3171
|
+
unionization_risk: laborProfile.unionizationRisk,
|
|
3172
|
+
role_types: laborProfile.roleTypes,
|
|
3173
|
+
region_multipliers: laborProfile.regionMultipliers,
|
|
3174
|
+
reasoning: laborProfile.reasoning,
|
|
3175
|
+
}
|
|
3176
|
+
: null,
|
|
3177
|
+
consensus_tier: {
|
|
3178
|
+
tier: riskConsensus.tier,
|
|
3179
|
+
agreement_pct: riskConsensus.agreementLevelPct,
|
|
3180
|
+
precision_pct: riskConsensus.precisionConfidencePct,
|
|
3181
|
+
directional_pct: riskConsensus.directionalConfidencePct,
|
|
3182
|
+
dissenting_agents: riskConsensus.dissentingAgents,
|
|
3183
|
+
load_bearing_assumptions: riskConsensus.loadBearingAssumptions,
|
|
3184
|
+
},
|
|
2422
3185
|
};
|
|
2423
3186
|
}
|
|
3187
|
+
/**
|
|
3188
|
+
* Select 5-7 sensitivity variables based on the scenario type. The first
|
|
3189
|
+
* four are domain-neutral; the remainder are domain-triggered so the output
|
|
3190
|
+
* is tailored to the scenario.
|
|
3191
|
+
*/
|
|
3192
|
+
function selectSensitivityVariables(scenarioType) {
|
|
3193
|
+
const base = [
|
|
3194
|
+
{ variable: 'Input price variance', range: '±15%', impactFraction: 0.15, direction: 'bidirectional' },
|
|
3195
|
+
{ variable: 'Adoption rate', range: '40% → 70%', impactFraction: 0.20, direction: 'bidirectional' },
|
|
3196
|
+
{ variable: 'Implementation timeline', range: '±3 months', impactFraction: 0.25, direction: 'downside_only' },
|
|
3197
|
+
{ variable: 'Scope expansion', range: 'Pilot → Enterprise', impactFraction: 0.60, direction: 'upside_only' },
|
|
3198
|
+
];
|
|
3199
|
+
// Domain-specific additions (1-3 variables depending on scenario)
|
|
3200
|
+
const type = scenarioType.toLowerCase();
|
|
3201
|
+
if (type.includes('sustainability') || type.includes('esg') || type.includes('energy') || type.includes('utility')) {
|
|
3202
|
+
base.push({ variable: 'Data coverage', range: '70% → 95%', impactFraction: 0.20, direction: 'downside_only' }, { variable: 'Carbon price', range: '$30 → $100/t', impactFraction: 0.18, direction: 'upside_only' });
|
|
3203
|
+
}
|
|
3204
|
+
else if (type.includes('supply-chain') || type.includes('logistics') || type.includes('fleet-transportation') || type.includes('vendor')) {
|
|
3205
|
+
base.push({ variable: 'Supplier reliability', range: '90% → 99.9%', impactFraction: 0.22, direction: 'bidirectional' }, { variable: 'Lead-time variance', range: '±2 weeks', impactFraction: 0.12, direction: 'downside_only' });
|
|
3206
|
+
}
|
|
3207
|
+
else if (type.includes('financial-operations') || type.includes('compliance') || type.includes('governance') || type.includes('audit')) {
|
|
3208
|
+
base.push({ variable: 'Audit cost multiplier', range: '1× → 2.5×', impactFraction: 0.18, direction: 'downside_only' }, { variable: 'Regulatory scope change', range: '0 → 2 new frameworks', impactFraction: 0.15, direction: 'downside_only' });
|
|
3209
|
+
}
|
|
3210
|
+
else if (type.includes('hr-workforce') || type.includes('talent')) {
|
|
3211
|
+
base.push({ variable: 'Attrition rate', range: '5% → 20%', impactFraction: 0.24, direction: 'downside_only' }, { variable: 'Training completion', range: '60% → 90%', impactFraction: 0.14, direction: 'upside_only' });
|
|
3212
|
+
}
|
|
3213
|
+
else if (type.includes('manufacturing') || type.includes('asset-maintenance') || type.includes('pharma')) {
|
|
3214
|
+
base.push({ variable: 'Equipment uptime', range: '92% → 98%', impactFraction: 0.20, direction: 'bidirectional' }, { variable: 'Yield variance', range: '±5pp', impactFraction: 0.16, direction: 'bidirectional' });
|
|
3215
|
+
}
|
|
3216
|
+
else if (type.includes('healthcare')) {
|
|
3217
|
+
base.push({ variable: 'Patient volume', range: '±15%', impactFraction: 0.18, direction: 'bidirectional' }, { variable: 'Payer mix shift', range: '±10pp', impactFraction: 0.14, direction: 'downside_only' });
|
|
3218
|
+
}
|
|
3219
|
+
else if (type.includes('data-analytics') || type.includes('system-migration') || type.includes('security')) {
|
|
3220
|
+
base.push({ variable: 'Data migration completeness', range: '85% → 99%', impactFraction: 0.22, direction: 'upside_only' }, { variable: 'Integration complexity', range: '1× → 2×', impactFraction: 0.18, direction: 'downside_only' });
|
|
3221
|
+
}
|
|
3222
|
+
else {
|
|
3223
|
+
// Generic fallback — ensures 5+ rows even for unknown scenario types
|
|
3224
|
+
base.push({ variable: 'Market conditions', range: '±15%', impactFraction: 0.15, direction: 'bidirectional' }, { variable: 'Organizational readiness', range: '60% → 90%', impactFraction: 0.18, direction: 'downside_only' });
|
|
3225
|
+
}
|
|
3226
|
+
return base;
|
|
3227
|
+
}
|
|
3228
|
+
/**
|
|
3229
|
+
* Build the sensitivity table from specs. Returns rows sorted by absolute
|
|
3230
|
+
* impact magnitude (rank 1 = biggest driver). Deterministic for a given
|
|
3231
|
+
* (baseSavings, scenarioType) pair.
|
|
3232
|
+
*/
|
|
3233
|
+
export function buildSensitivityTable(baseSavings, scenarioType, laborProfile) {
|
|
3234
|
+
if (!(baseSavings > 0) || !isFinite(baseSavings))
|
|
3235
|
+
return [];
|
|
3236
|
+
const specs = selectSensitivityVariables(scenarioType);
|
|
3237
|
+
// ADR-PIPELINE-070: When the engagement is labor-intensive, prepend a
|
|
3238
|
+
// workforce variable so change-management overruns are visible in the
|
|
3239
|
+
// tornado. The variable is downside-only — overruns shrink savings.
|
|
3240
|
+
if (laborProfile && isLaborIntensive(laborProfile)) {
|
|
3241
|
+
const wfRow = workforceSensitivityRow(laborProfile);
|
|
3242
|
+
if (wfRow && !specs.some(s => s.variable === wfRow.variable)) {
|
|
3243
|
+
specs.push({
|
|
3244
|
+
variable: wfRow.variable,
|
|
3245
|
+
range: wfRow.range,
|
|
3246
|
+
impactFraction: wfRow.impactPctOfPilot,
|
|
3247
|
+
direction: 'downside_only',
|
|
3248
|
+
});
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
const rows = specs.map((spec) => {
|
|
3252
|
+
const magnitude = Math.round(baseSavings * spec.impactFraction);
|
|
3253
|
+
const signedImpact = spec.direction === 'downside_only' ? -magnitude :
|
|
3254
|
+
spec.direction === 'upside_only' ? +magnitude :
|
|
3255
|
+
+magnitude; // bidirectional: store positive magnitude; low/high handled below
|
|
3256
|
+
// Low/high cases depend on direction.
|
|
3257
|
+
let lowCaseUsd;
|
|
3258
|
+
let highCaseUsd;
|
|
3259
|
+
if (spec.direction === 'upside_only') {
|
|
3260
|
+
lowCaseUsd = Math.round(baseSavings);
|
|
3261
|
+
highCaseUsd = Math.round(baseSavings + magnitude);
|
|
3262
|
+
}
|
|
3263
|
+
else if (spec.direction === 'downside_only') {
|
|
3264
|
+
lowCaseUsd = Math.round(baseSavings - magnitude);
|
|
3265
|
+
highCaseUsd = Math.round(baseSavings);
|
|
3266
|
+
}
|
|
3267
|
+
else {
|
|
3268
|
+
lowCaseUsd = Math.round(baseSavings - magnitude);
|
|
3269
|
+
highCaseUsd = Math.round(baseSavings + magnitude);
|
|
3270
|
+
}
|
|
3271
|
+
return {
|
|
3272
|
+
variable: spec.variable,
|
|
3273
|
+
range: spec.range,
|
|
3274
|
+
baselineUsd: Math.round(baseSavings),
|
|
3275
|
+
lowCaseUsd,
|
|
3276
|
+
highCaseUsd,
|
|
3277
|
+
impactUsd: magnitude,
|
|
3278
|
+
impactPercent: (signedImpact / baseSavings) * 100,
|
|
3279
|
+
rank: 0, // assigned after sort
|
|
3280
|
+
direction: spec.direction,
|
|
3281
|
+
};
|
|
3282
|
+
});
|
|
3283
|
+
// Sort by absolute magnitude, assign 1-based ranks
|
|
3284
|
+
rows.sort((a, b) => b.impactUsd - a.impactUsd);
|
|
3285
|
+
return rows.map((row, idx) => ({ ...row, rank: idx + 1 }));
|
|
3286
|
+
}
|
|
3287
|
+
/**
|
|
3288
|
+
* Render the sensitivity rows as a markdown table, ASCII tornado chart,
|
|
3289
|
+
* and a dynamic key-takeaway paragraph linking the top 2 drivers to the
|
|
3290
|
+
* pilot design.
|
|
3291
|
+
*/
|
|
3292
|
+
function renderSensitivitySection(rows, baselineTotalUsd) {
|
|
3293
|
+
const out = [];
|
|
3294
|
+
out.push('## Sensitivity Analysis', '');
|
|
3295
|
+
if (rows.length === 0) {
|
|
3296
|
+
out.push('*Baseline savings figure could not be parsed from the financial model — sensitivity table omitted. Provide `fin.revenue` with a recognized currency format to enable tornado ranking.*', '');
|
|
3297
|
+
return out;
|
|
3298
|
+
}
|
|
3299
|
+
const fmtMoney = (n) => {
|
|
3300
|
+
const abs = Math.abs(n);
|
|
3301
|
+
if (abs >= 1_000_000)
|
|
3302
|
+
return `$${(n / 1_000_000).toFixed(2)}M`;
|
|
3303
|
+
if (abs >= 1_000)
|
|
3304
|
+
return `$${(n / 1_000).toFixed(0)}K`;
|
|
3305
|
+
return `$${Math.round(n).toLocaleString()}`;
|
|
3306
|
+
};
|
|
3307
|
+
out.push(`**Base case:** ${fmtMoney(baselineTotalUsd)} projected annual impact.`, '');
|
|
3308
|
+
out.push(`**Variables tested:** ${rows.length}. Each row varies a single assumption while holding all others constant.`, '');
|
|
3309
|
+
out.push('');
|
|
3310
|
+
// Table
|
|
3311
|
+
out.push('| Rank | Variable | Range | Pessimistic | Optimistic | Impact |');
|
|
3312
|
+
out.push('|------|----------|-------|-------------|------------|--------|');
|
|
3313
|
+
for (const row of rows) {
|
|
3314
|
+
out.push(`| ${row.rank} | ${row.variable} | ${row.range} | ${fmtMoney(row.lowCaseUsd)} | ${fmtMoney(row.highCaseUsd)} | ±${fmtMoney(row.impactUsd)} |`);
|
|
3315
|
+
}
|
|
3316
|
+
out.push('');
|
|
3317
|
+
// ASCII tornado chart (bars proportional to largest impact)
|
|
3318
|
+
out.push('### Tornado Chart', '');
|
|
3319
|
+
out.push('```');
|
|
3320
|
+
const maxImpact = rows[0]?.impactUsd ?? 1;
|
|
3321
|
+
const barWidth = 32;
|
|
3322
|
+
const nameWidth = Math.max(...rows.map(r => r.variable.length));
|
|
3323
|
+
for (const row of rows) {
|
|
3324
|
+
const barLen = Math.max(1, Math.round((row.impactUsd / maxImpact) * barWidth));
|
|
3325
|
+
const bar = '█'.repeat(barLen);
|
|
3326
|
+
const name = row.variable.padEnd(nameWidth);
|
|
3327
|
+
out.push(` ${name} ${bar} ${fmtMoney(row.impactUsd)}`);
|
|
3328
|
+
}
|
|
3329
|
+
out.push('```', '');
|
|
3330
|
+
// Key takeaway — dynamically references top-2 drivers and lowest-impact variable
|
|
3331
|
+
const top1 = rows[0];
|
|
3332
|
+
const top2 = rows[1];
|
|
3333
|
+
const bottom = rows[rows.length - 1];
|
|
3334
|
+
const takeaway = top2
|
|
3335
|
+
? `**Key takeaway:** The business case is most sensitive to **${top1.variable}** (±${fmtMoney(top1.impactUsd)}) and **${top2.variable}** (±${fmtMoney(top2.impactUsd)}). The pilot is designed to reduce uncertainty on these two drivers specifically. **${bottom.variable}** has the smallest impact (±${fmtMoney(bottom.impactUsd)}), meaning the ROI holds even if that estimate is off by ±20%.`
|
|
3336
|
+
: `**Key takeaway:** Only one variable was tested — **${top1.variable}** (±${fmtMoney(top1.impactUsd)}). Add scenario context to enable multi-variable tornado analysis.`;
|
|
3337
|
+
out.push(takeaway, '');
|
|
3338
|
+
return out;
|
|
3339
|
+
}
|
|
2424
3340
|
// ============================================================================
|
|
2425
3341
|
// ADR-PIPELINE-020: Financial Analysis Renderer (costops agents)
|
|
2426
3342
|
// ============================================================================
|
|
2427
|
-
export function renderFinancialAnalysis(query, simulationResult, platformResults) {
|
|
3343
|
+
export function renderFinancialAnalysis(query, simulationResult, platformResults, runDir) {
|
|
2428
3344
|
const now = new Date().toISOString();
|
|
2429
3345
|
const problemStatement = distillProblemStatement(query);
|
|
2430
3346
|
const simPayload = extractSignalPayload(simulationResult);
|
|
@@ -2438,24 +3354,58 @@ export function renderFinancialAnalysis(query, simulationResult, platformResults
|
|
|
2438
3354
|
const attributionData = extractAgentData(platformResults, 'costops', 'attribution');
|
|
2439
3355
|
const budgetData = extractAgentData(platformResults, 'costops', 'budget');
|
|
2440
3356
|
const tradeoffData = extractAgentData(platformResults, 'costops', 'tradeoff');
|
|
2441
|
-
// ADR-PIPELINE-
|
|
2442
|
-
//
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
3357
|
+
// ADR-PIPELINE-066: 3-tier precedence for financial figures.
|
|
3358
|
+
// Tier 1 — unit-economics.json manifest from the generated prototype
|
|
3359
|
+
// Tier 2 — structured figures from agent results (costops/platform)
|
|
3360
|
+
// Tier 3 — per-employee heuristic fallback (with visible warning banner)
|
|
3361
|
+
const manifestResult = loadUnitEconomics(runDir ?? null);
|
|
3362
|
+
let unitEconomicsSource = 'heuristic';
|
|
3363
|
+
let manifestModel = null;
|
|
3364
|
+
let manifestWarnings = [];
|
|
3365
|
+
if (manifestResult.manifest) {
|
|
3366
|
+
manifestModel = buildFinancialModelFromUnitEconomics(manifestResult.manifest);
|
|
3367
|
+
fin.budget = manifestModel.budget;
|
|
3368
|
+
fin.roi = manifestModel.roi;
|
|
3369
|
+
fin.npv = manifestModel.npv;
|
|
3370
|
+
fin.payback = manifestModel.payback;
|
|
3371
|
+
fin.revenue = manifestModel.revenue;
|
|
3372
|
+
fin.costSavings = manifestModel.costSavings;
|
|
2457
3373
|
fin.hasData = true;
|
|
3374
|
+
fin.isEstimated = false;
|
|
3375
|
+
unitEconomicsSource = 'manifest';
|
|
3376
|
+
const consistency = enforceManifestConsistency(manifestResult.manifest, {
|
|
3377
|
+
measuredSavingsUsd: manifestResult.manifest.annual_measured_savings_usd,
|
|
3378
|
+
enterpriseSavingsUsd: manifestResult.manifest.annual_extrapolated_savings_usd,
|
|
3379
|
+
});
|
|
3380
|
+
if (!consistency.passed)
|
|
3381
|
+
manifestWarnings = consistency.violations;
|
|
3382
|
+
}
|
|
3383
|
+
else if (fin.hasData && !fin.isEstimated && fin.budget && fin.roi && fin.npv) {
|
|
3384
|
+
// Tier 2 — structured agent/costops data already populated the model.
|
|
3385
|
+
unitEconomicsSource = 'agent';
|
|
3386
|
+
}
|
|
3387
|
+
else {
|
|
3388
|
+
// Tier 3 — heuristic fallback. synthesizeFinancials may have already
|
|
3389
|
+
// filled the fields via estimateFinancialsFromQuery (isEstimated=true);
|
|
3390
|
+
// in that case we leave the values alone and just flag the path.
|
|
3391
|
+
if (!fin.hasData || (!fin.budget && !fin.roi && !fin.npv)) {
|
|
3392
|
+
const estimated = estimateFinancialsFromQuery(query);
|
|
3393
|
+
if (!fin.budget)
|
|
3394
|
+
fin.budget = estimated.budget;
|
|
3395
|
+
if (!fin.roi)
|
|
3396
|
+
fin.roi = estimated.roi;
|
|
3397
|
+
if (!fin.npv)
|
|
3398
|
+
fin.npv = estimated.npv;
|
|
3399
|
+
if (!fin.payback)
|
|
3400
|
+
fin.payback = estimated.payback;
|
|
3401
|
+
if (!fin.revenue)
|
|
3402
|
+
fin.revenue = estimated.revenue;
|
|
3403
|
+
if (!fin.costSavings)
|
|
3404
|
+
fin.costSavings = estimated.costSavings;
|
|
3405
|
+
fin.hasData = true;
|
|
3406
|
+
}
|
|
2458
3407
|
fin.isEstimated = true;
|
|
3408
|
+
unitEconomicsSource = 'heuristic';
|
|
2459
3409
|
}
|
|
2460
3410
|
const lines = [
|
|
2461
3411
|
'# Financial Analysis',
|
|
@@ -2463,18 +3413,40 @@ export function renderFinancialAnalysis(query, simulationResult, platformResults
|
|
|
2463
3413
|
`**Date:** ${now}`,
|
|
2464
3414
|
`**Subject:** ${problemStatement}`,
|
|
2465
3415
|
'',
|
|
2466
|
-
'---',
|
|
2467
|
-
'',
|
|
2468
|
-
`## Investment Summary${fin.isEstimated ? ' (Estimated from Organization Profile)' : ''}`, '',
|
|
2469
|
-
'| Metric | Value |',
|
|
2470
|
-
'|--------|-------|',
|
|
2471
|
-
`| Total Investment | ${fin.budget} |`,
|
|
2472
|
-
`| Expected ROI | ${fin.roi} |`,
|
|
2473
|
-
`| 5-Year NPV | ${fin.npv} |`,
|
|
2474
|
-
`| Payback Period | ${fin.payback} |`,
|
|
2475
|
-
`| Revenue / Savings Impact | ${fin.revenue || fin.costSavings} |`,
|
|
2476
|
-
'',
|
|
2477
3416
|
];
|
|
3417
|
+
// ADR-PIPELINE-066 §2: Mandatory fallback warning when the heuristic is used.
|
|
3418
|
+
if (unitEconomicsSource === 'heuristic') {
|
|
3419
|
+
lines.push('> ⚠️ **Financial model: heuristic fallback** — no prototype `unit-economics.json` found.', '> Figures below are derived from a per-employee/industry benchmark, not bottom-up unit economics.', '> Regenerate the prototype and re-run this renderer to replace with measured unit savings.', '');
|
|
3420
|
+
}
|
|
3421
|
+
else if (unitEconomicsSource === 'manifest' && manifestResult.manifest) {
|
|
3422
|
+
const m = manifestResult.manifest;
|
|
3423
|
+
lines.push(`> ✅ **Financial model: prototype unit economics** (sector: ${m.sector}, unit: ${m.domain_unit.label})`, `> Source: \`${manifestResult.inspectedPath ?? 'unit-economics.json'}\` · Method: ${m.extrapolation_method}`, '');
|
|
3424
|
+
if (manifestWarnings.length > 0) {
|
|
3425
|
+
lines.push('> ⚠️ **Unit economics consistency warnings:**', ...manifestWarnings.map(v => `> - ${v}`), '');
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
lines.push('---', '', `## Investment Summary${fin.isEstimated ? ' (Estimated from Organization Profile)' : unitEconomicsSource === 'manifest' ? ' (from Prototype Unit Economics)' : ''}`, '', '| Metric | Value |', '|--------|-------|', `| Total Investment | ${fin.budget} |`, `| Expected ROI | ${fin.roi} |`, `| 5-Year NPV | ${fin.npv} |`, `| Payback Period | ${fin.payback} |`, `| Revenue / Savings Impact | ${fin.revenue || fin.costSavings} |`, '');
|
|
3429
|
+
// ADR-PIPELINE-070: Workforce exposure block when intensity ≥ medium.
|
|
3430
|
+
// Mandatory for hospitality/retail/fleet/healthcare/CRE/etc. — calls out
|
|
3431
|
+
// role count, EMEA union exposure, change-mgmt budget, and reallocation
|
|
3432
|
+
// neutrality so the financial story includes the labor side.
|
|
3433
|
+
const financialDomainAnalysis = extractDomainAnalysis(simData, query);
|
|
3434
|
+
const financialLaborProfile = classifyLaborIntensity(financialDomainAnalysis, query);
|
|
3435
|
+
if (isLaborIntensive(financialLaborProfile)) {
|
|
3436
|
+
const pilotInvestmentUsd = parseUsdRangeMid(fin.budget) ?? 1_000_000;
|
|
3437
|
+
const operationalUnits = manifestResult.manifest
|
|
3438
|
+
? primaryScopeCount(manifestResult.manifest)
|
|
3439
|
+
: undefined;
|
|
3440
|
+
const employeesContext = extractEmployeeCount(query, query.toLowerCase());
|
|
3441
|
+
const exposure = estimateWorkforceExposure(financialLaborProfile, {
|
|
3442
|
+
employees: employeesContext,
|
|
3443
|
+
operationalUnits,
|
|
3444
|
+
pilotInvestmentUsd,
|
|
3445
|
+
});
|
|
3446
|
+
if (exposure) {
|
|
3447
|
+
lines.push('## Workforce Exposure (ADR-PIPELINE-070)', '', '| Dimension | Value |', '|-----------|-------|', `| Roles affected | ~${exposure.rolesAffected.toLocaleString()} ${exposure.primaryRoleLabel} |`, `| EMEA union exposure | ~${exposure.emeaUnionExposure.toLocaleString()} roles (${financialLaborProfile.unionizationRisk} unionization risk) |`, `| Change-management budget | $${(exposure.changeMgmtBudgetLowUsd / 1000).toFixed(0)}K – $${(exposure.changeMgmtBudgetHighUsd / 1000).toFixed(0)}K (${(exposure.changeMgmtPct * 100).toFixed(0)}% of pilot investment) |`, `| Reallocation neutrality | ${exposure.reallocationNeutral ? 'Committed (no headcount reduction during pilot)' : 'Not committed'} |`, `| Sector classification | ${financialLaborProfile.sector} (${financialLaborProfile.intensity} labor intensity) |`, '', '*Source: ADR-PIPELINE-070 labor classifier. Refine role counts and change-management budget with HR during pilot scoping.*', '');
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
2478
3450
|
// ROI Analysis
|
|
2479
3451
|
lines.push('## Return on Investment', '');
|
|
2480
3452
|
if (roiData) {
|
|
@@ -2564,33 +3536,25 @@ export function renderFinancialAnalysis(query, simulationResult, platformResults
|
|
|
2564
3536
|
lines.push(`| Optimistic | Faster adoption, lower integration costs | ${optCostStr} | ${fin.roi ? fin.roi.replace(/\d+/, m => String(Math.round(parseInt(m) * 1.3))) : 'Above base'} | 25% |`);
|
|
2565
3537
|
lines.push(`| Pessimistic | Slower adoption, scope growth | ${pessCostStr} | ${fin.roi ? fin.roi.replace(/\d+/, m => String(Math.round(parseInt(m) * 0.6))) : 'Below base'} | 25% |`);
|
|
2566
3538
|
lines.push('', '*Scenario analysis assumes ±20% cost variance and ±30% timeline variance from base case.*', '');
|
|
2567
|
-
// ADR-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
lines.push('|-----------------|-------------------|-----|---------|');
|
|
3539
|
+
// ADR-PIPELINE-063: Multi-variable sensitivity tornado
|
|
3540
|
+
// Implements ADR-PIPELINE-057 §1 — replaces the old single-variable loop
|
|
3541
|
+
// with a ranked tornado chart across 5-7 domain-selected assumptions.
|
|
3542
|
+
let baseSavingsForSensitivity = 0;
|
|
2572
3543
|
if (fin.revenue) {
|
|
2573
|
-
// Parse the base savings figure for sensitivity calculations
|
|
2574
3544
|
const savingsMatch = fin.revenue.match(/\$([0-9,.]+)([KMB]?)/i);
|
|
2575
3545
|
if (savingsMatch) {
|
|
2576
3546
|
const numStr = savingsMatch[1].replace(/,/g, '');
|
|
2577
3547
|
const suffix = (savingsMatch[2] || '').toUpperCase();
|
|
2578
3548
|
const mult = suffix === 'M' ? 1_000_000 : suffix === 'K' ? 1_000 : suffix === 'B' ? 1_000_000_000 : 1;
|
|
2579
|
-
const
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
const baseInvest = budgetMatch2 ? parseFloat(budgetMatch2[1].replace(/,/g, '')) * (budgetMatch2[2]?.toUpperCase() === 'M' ? 1_000_000 : budgetMatch2[2]?.toUpperCase() === 'K' ? 1_000 : 1) : baseSavings * 0.6;
|
|
2583
|
-
for (const pct of [0.5, 0.75, 1.0, 1.25]) {
|
|
2584
|
-
const savings = baseSavings * pct;
|
|
2585
|
-
const roi = Math.round((savings / baseInvest) * 100);
|
|
2586
|
-
const payback = savings > 0 ? Math.round(baseInvest / (savings / 12)) : 0;
|
|
2587
|
-
const label = pct === 1.0 ? '**Base case**' : pct < 1 ? `Conservative (${Math.round(pct * 100)}%)` : `Aggressive (${Math.round(pct * 100)}%)`;
|
|
2588
|
-
lines.push(`| ${label} | ${fmtS(savings)} | ${roi}% | ${payback} months |`);
|
|
2589
|
-
}
|
|
3549
|
+
const parsed = parseFloat(numStr) * mult;
|
|
3550
|
+
if (!isNaN(parsed) && parsed > 0)
|
|
3551
|
+
baseSavingsForSensitivity = parsed;
|
|
2590
3552
|
}
|
|
2591
3553
|
}
|
|
2592
|
-
|
|
2593
|
-
|
|
3554
|
+
// ADR-PIPELINE-070: pass the labor profile so workforce overrun appears
|
|
3555
|
+
// in the tornado when applicable.
|
|
3556
|
+
const sensitivityRows = buildSensitivityTable(baseSavingsForSensitivity, extracted.scenario_type, financialLaborProfile);
|
|
3557
|
+
lines.push(...renderSensitivitySection(sensitivityRows, baseSavingsForSensitivity));
|
|
2594
3558
|
if (lineage.simulationId) {
|
|
2595
3559
|
lines.push(`*Simulation: ${lineage.simulationId}${lineage.traceId ? ` | Trace: ${lineage.traceId}` : ''}*`);
|
|
2596
3560
|
}
|