Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 | 8x 8x 8x 8x 8x 8x 8x 454x 246x 129x 34x 298x 298x 717x 99x 24x 21x 3x 1x 1x 1x 2x 29x 25x 8x 108x 2x 106x 2x 104x 104x 104x 104x 104x 47x 47x 40x 40x 40x 40x 47x 47x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 18x 18x 16x 9x 10x 10x 16x 16x 16x 23x 23x 29x 29x 25x 29x 29x 24x 24x 29x 29x 29x 29x 29x 29x 29x 29x 23x 22x 22x 22x 22x 22x 22x 22x 22x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 42x 14x 56x 14x 14x 26x 14x 3x 3x 3x 3x 24x 24x 24x 15x 6x 4x 3x 24x 1x 1x 19x 2x 1x 22x 4x 18x 27x 18x 18x 27x 18x 22x 27x 27x 14x 14x 14x 17x 17x 17x 17x 17x 20x 14x 10x 1x 17x 3x 2x 2x 4x 3x 2x 2x 4x 2x 2x 14x 14x 14x 14x 70x 350x 350x 350x 70x 70x 14x 14x 13x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 13x 80x 80x 80x 80x 80x 312x 312x 312x 70x 70x 70x 70x 70x 14x 13x 13x 16x 16x 16x 16x 16x 16x 16x 16x 13x 14x 13x 26x 26x 13x 26x 13x 31x 26x 1x 25x 1x 24x 24x 75x | // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
/**
* @module Utils/PoliticalRiskAssessment
* @description Pure political risk assessment utility functions adapted from ISMS risk
* methodologies (Likelihood × Impact, Value at Risk, Annual Rate of Occurrence) for
* European Parliament political intelligence.
*
* All functions are stateless and produce no side effects.
*
* Inspiration: Hack23 ISMS Risk Assessment Methodology
* (https://github.com/Hack23/ISMS-PUBLIC/blob/main/Risk_Assessment_Methodology.md)
*/
import type { ConfidenceLevel } from '../types/analysis.js';
import type { ArticleCategory } from '../types/common.js';
import type { PoliticalActorType } from '../types/political-classification.js';
import type {
PoliticalRiskLikelihood,
PoliticalRiskImpact,
PoliticalRiskLevel,
PoliticalRiskScore,
PoliticalCapitalAtRisk,
PoliticalRiskDriver,
PoliticalThreatCategory,
LegislativeVelocityRisk,
LegislativeStage,
QuantitativeSWOT,
ScoredSWOTItem,
CrossImpactEntry,
SwotItemTrend,
AgentRiskAssessmentWorkflow,
RiskAssessmentStep,
PoliticalRiskSummary,
RiskLevelCounts,
} from '../types/political-risk.js';
// ─── Likelihood & Impact lookup tables ───────────────────────────────────────
/** Numeric values for each likelihood level */
const LIKELIHOOD_VALUES: Readonly<Record<PoliticalRiskLikelihood, number>> = {
rare: 0.1,
unlikely: 0.3,
possible: 0.5,
likely: 0.7,
almost_certain: 0.9,
};
/** Numeric values for each impact level */
const IMPACT_VALUES: Readonly<Record<PoliticalRiskImpact, number>> = {
negligible: 1,
minor: 2,
moderate: 3,
major: 4,
severe: 5,
};
/** Expected stage durations in days (historical parliamentary averages) */
const EXPECTED_STAGE_DAYS: Readonly<Record<LegislativeStage, number>> = {
proposal: 90,
committee: 180,
plenary_first: 60,
trilogue: 120,
plenary_second: 45,
adopted: 0,
stalled: 365,
};
// ─── Risk level thresholds ───────────────────────────────────────────────────
/** Score thresholds for risk level bands (score = likelihood × impact) */
const RISK_LEVEL_THRESHOLDS = {
LOW_MAX: 1.0,
MEDIUM_MAX: 2.0,
HIGH_MAX: 3.5,
} as const;
/**
* Cross-impact: fraction of strength score that reduces a threat's net effect.
* A strength with score 5 reduces a threat by 5 × 0.2 = 1.0 units.
*/
const STRENGTH_MITIGATION_COEFFICIENT = 0.2;
/**
* Cross-impact: fraction of weakness score that amplifies a threat's net effect.
* A weakness with score 5 amplifies a threat by 5 × 0.15 = 0.75 units.
*/
const WEAKNESS_AMPLIFICATION_COEFFICIENT = 0.15;
/**
* Default legislative stage used when the raw stage string is not recognised.
* Committee is chosen as the most common intermediate stage in EP procedures.
*/
const DEFAULT_LEGISLATIVE_STAGE: LegislativeStage = 'committee';
// ─── Private helpers ─────────────────────────────────────────────────────────
/**
* Derive risk level from a composite score.
*
* @param score - Risk score (likelihood × impact)
* @returns Risk level band
*/
function deriveRiskLevel(score: number): PoliticalRiskLevel {
if (score <= RISK_LEVEL_THRESHOLDS.LOW_MAX) return 'low';
if (score <= RISK_LEVEL_THRESHOLDS.MEDIUM_MAX) return 'medium';
if (score <= RISK_LEVEL_THRESHOLDS.HIGH_MAX) return 'high';
return 'critical';
}
/**
* Clamp a number to [min, max].
*
* Non-finite values are normalised deterministically to avoid leaking
* NaN/Infinity into downstream calculations:
* - NaN → min
* - +Infinity → max
* - -Infinity → min
*
* @param value - Number to clamp
* @param min - Lower bound
* @param max - Upper bound
* @returns Clamped value
*/
function clamp(value: number, min: number, max: number): number {
Iif (!Number.isFinite(value)) {
if (value === Infinity) return max;
// NaN or -Infinity
return min;
}
return Math.min(max, Math.max(min, value));
}
/**
* Round to two decimal places.
*
* @param value - Number to round
* @returns Rounded value
*/
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
/**
* Safely coerce an unknown value to a string.
*
* @param val - Unknown value
* @returns String or empty string
*/
function asStr(val: unknown): string {
return typeof val === 'string' ? val : '';
}
/**
* Safely coerce an unknown value to a finite number.
*
* @param val - Unknown value
* @param fallback - Default when not a finite number
* @returns Finite number or fallback
*/
function asNum(val: unknown, fallback = 0): number {
if (typeof val === 'number' && Number.isFinite(val)) {
return val;
}
if (typeof val === 'string') {
const parsed = Number(val.trim());
Eif (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
}
/**
* Coerce an unknown value to a Record or null.
*
* @param input - Value to coerce
* @returns Record or null
*/
function toRecord(input: unknown): Record<string, unknown> | null {
if (input === null || input === undefined || typeof input !== 'object') return null;
return input as Record<string, unknown>;
}
// ─── Risk heat map emoji ─────────────────────────────────────────────────────
/** Emoji cell for the risk heat map table */
const HEAT_MAP_CELLS: Readonly<Record<PoliticalRiskLevel, string>> = {
low: '🟢',
medium: '🟡',
high: '🟠',
critical: '🔴',
};
// ─── Exported core scoring functions ─────────────────────────────────────────
/**
* Calculate a political risk score from likelihood and impact levels.
* Implements the ISMS-inspired Likelihood × Impact framework adapted for
* European Parliament political intelligence.
*
* Risk Score = likelihoodValue × impactValue
* - low: 0–≤1.0 | medium: >1.0–≤2.0 | high: >2.0–≤3.5 | critical: >3.5
*
* @param likelihood - Likelihood level of the political risk
* @param impact - Impact level if the risk occurs
* @param riskId - Optional risk identifier (defaults to "RISK-AUTO")
* @param description - Optional risk description
* @param evidence - Optional supporting evidence strings
* @param mitigatingFactors - Optional factors that reduce likelihood or impact
* @param confidence - Optional confidence level (defaults to 'medium')
* @returns Fully populated PoliticalRiskScore
*/
export function calculatePoliticalRiskScore(
likelihood: PoliticalRiskLikelihood,
impact: PoliticalRiskImpact,
riskId = 'RISK-AUTO',
description = '',
evidence: readonly string[] = [],
mitigatingFactors: readonly string[] = [],
confidence: ConfidenceLevel = 'medium'
): PoliticalRiskScore {
if (!Object.hasOwn(LIKELIHOOD_VALUES, likelihood)) {
throw new Error(
`Invalid likelihood: "${String(likelihood)}". Expected one of: ${Object.keys(LIKELIHOOD_VALUES).join(', ')}`
);
}
if (!Object.hasOwn(IMPACT_VALUES, impact)) {
throw new Error(
`Invalid impact: "${String(impact)}". Expected one of: ${Object.keys(IMPACT_VALUES).join(', ')}`
);
}
// eslint-disable-next-line security/detect-object-injection -- key validated via Object.hasOwn above
const likelihoodValue = LIKELIHOOD_VALUES[likelihood];
// eslint-disable-next-line security/detect-object-injection -- key validated via Object.hasOwn above
const impactValue = IMPACT_VALUES[impact];
const riskScore = round2(likelihoodValue * impactValue);
const riskLevel = deriveRiskLevel(riskScore);
return {
riskId,
description,
likelihood,
likelihoodValue,
impact,
impactValue,
riskScore,
riskLevel,
confidence,
evidence: [...evidence],
mitigatingFactors: [...mitigatingFactors],
};
}
/**
* Assess Political Capital at Risk (PCaR) for a named political actor.
* Adapted from ISMS Value at Risk: quantifies political capital exposure
* given observable risk drivers derived from parliamentary data.
*
* Capital at Risk is estimated as: sum(driver contributions) * currentCapital / 100
* clamped to [0, currentCapital].
*
* @param actor - Name or identifier of the political actor
* @param actorType - Type classification of the actor
* @param currentCapital - Current political capital score (0–100)
* @param riskDrivers - List of risk drivers affecting this actor
* @param timeHorizon - Assessment time horizon
* @param confidenceInterval - Statistical confidence interval (e.g. 95)
* @returns Populated PoliticalCapitalAtRisk structure
*/
export function assessPoliticalCapitalAtRisk(
actor: string,
actorType: PoliticalActorType,
currentCapital: number,
riskDrivers: readonly PoliticalRiskDriver[],
timeHorizon: PoliticalCapitalAtRisk['timeHorizon'] = 'quarter',
confidenceInterval = 95
): PoliticalCapitalAtRisk {
const cappedCapital = clamp(currentCapital, 0, 100);
const totalContribution = riskDrivers.reduce((sum, d) => {
const numericContribution = Number(d.contribution);
Iif (!Number.isFinite(numericContribution)) {
return sum;
}
const safeContribution = clamp(numericContribution, -100, 100);
return sum + safeContribution;
}, 0);
const capitalAtRisk = round2(clamp((totalContribution / 100) * cappedCapital, 0, cappedCapital));
return {
actor,
actorType,
currentCapital: round2(cappedCapital),
capitalAtRisk,
riskDrivers: [...riskDrivers],
timeHorizon,
confidenceInterval: clamp(confidenceInterval, 0, 100),
};
}
/**
* Build a quantitative SWOT analysis from scored items.
* Extends the existing SwotAnalysis pattern with numerical scoring and
* a cross-impact matrix showing how strengths/weaknesses interact with threats.
*
* Strategic Position Score = (sumStrengths + sumOpportunities) /
* ((sumStrengths + sumOpportunities + sumWeaknesses + sumThreats) / 10)
* Range: 0–10; above 5 = net-positive strategic position.
*
* @param title - Optional title for the analysis
* @param strengths - Scored strength items
* @param weaknesses - Scored weakness items
* @param opportunities - Scored opportunity items
* @param threats - Scored threat items
* @returns QuantitativeSWOT with cross-impact matrix and strategic position score
*/
export function buildQuantitativeSWOT(
title: string | undefined,
strengths: readonly ScoredSWOTItem[],
weaknesses: readonly ScoredSWOTItem[],
opportunities: readonly ScoredSWOTItem[],
threats: readonly ScoredSWOTItem[]
): QuantitativeSWOT {
const sumStrengths = strengths.reduce((s, i) => s + i.score, 0);
const sumWeaknesses = weaknesses.reduce((s, i) => s + i.score, 0);
const sumOpportunities = opportunities.reduce((s, i) => s + i.score, 0);
const sumThreats = threats.reduce((s, i) => s + i.score, 0);
const totalScore = sumStrengths + sumWeaknesses + sumOpportunities + sumThreats;
const positiveScore = sumStrengths + sumOpportunities;
// Strategic position: 0–10 scale; 5 = neutral
const strategicPositionScore =
totalScore > 0 ? round2(clamp((positiveScore / totalScore) * 10, 0, 10)) : 5;
// Build cross-impact matrix: each strength/weakness × each threat
const crossImpactMatrix: CrossImpactEntry[] = [];
strengths.forEach((strength, si) => {
threats.forEach((_threat, ti) => {
// Strengths reduce threat impact; contribution proportional to strength score
const netEffect = round2(-(strength.score * STRENGTH_MITIGATION_COEFFICIENT));
crossImpactMatrix.push({
swotIndex: si,
swotType: 'strength',
threatIndex: ti,
netEffect,
// eslint-disable-next-line security/detect-object-injection -- ti is array index from forEach
rationale: `Strength "${strength.description}" partially mitigates threat "${threats[ti]?.description ?? ''}"`,
});
});
});
weaknesses.forEach((weakness, wi) => {
threats.forEach((_threat, ti) => {
// Weaknesses amplify threat impact; contribution proportional to weakness score
const netEffect = round2(weakness.score * WEAKNESS_AMPLIFICATION_COEFFICIENT);
crossImpactMatrix.push({
swotIndex: wi,
swotType: 'weakness',
threatIndex: ti,
netEffect,
// eslint-disable-next-line security/detect-object-injection -- ti is array index from forEach
rationale: `Weakness "${weakness.description}" amplifies threat "${threats[ti]?.description ?? ''}"`,
});
});
});
const overallAssessment =
strategicPositionScore >= 7
? 'Strong strategic position: strengths and opportunities outweigh weaknesses and threats.'
: strategicPositionScore >= 5
? 'Moderate strategic position: balanced strengths and risks requiring careful monitoring.'
: 'Weak strategic position: weaknesses and threats dominate — urgent mitigation needed.';
const result: QuantitativeSWOT = {
strengths,
weaknesses,
opportunities,
threats,
crossImpactMatrix,
strategicPositionScore,
overallAssessment,
...(title !== undefined ? { title } : {}),
};
return result;
}
/**
* Assess legislative velocity risks for a set of procedures.
* Adapted from ISMS Annual Rate of Occurrence: procedures spending significantly
* longer than the historical average in a stage are assigned higher risk scores.
*
* @param procedures - Array of raw procedure objects (from MCP or fallback data)
* @returns Array of LegislativeVelocityRisk objects sorted by risk score (highest first)
*/
export function assessLegislativeVelocityRisk(
procedures: readonly unknown[]
): LegislativeVelocityRisk[] {
const results: LegislativeVelocityRisk[] = [];
for (const raw of procedures) {
const p = toRecord(raw);
if (!p) continue;
const procedureId = asStr(p['procedureId']) || asStr(p['id']);
const title = asStr(p['title']);
if (!procedureId || !title) continue;
const stageRaw = asStr(p['stage']).toLowerCase().replace(/\s+/g, '_');
const currentStage: LegislativeStage = isLegislativeStage(stageRaw)
? stageRaw
: DEFAULT_LEGISLATIVE_STAGE;
const daysInCurrentStage = Math.max(0, Math.round(asNum(p['daysInCurrentStage'])));
// eslint-disable-next-line security/detect-object-injection -- key validated by isLegislativeStage
const expectedDays = EXPECTED_STAGE_DAYS[currentStage];
// Velocity ratio: how many times the expected duration has passed
const velocityRatio = expectedDays > 0 ? daysInCurrentStage / expectedDays : 0;
// Map velocity ratio to likelihood/impact for the risk score
const likelihood = velocityRatioToLikelihood(velocityRatio);
const impact = stageToImpact(currentStage);
const velocityRisk = calculatePoliticalRiskScore(
likelihood,
impact,
`VEL-${procedureId}`,
`Legislative velocity risk: ${title} has been in ${currentStage} stage for ${daysInCurrentStage} days (expected: ${expectedDays})`,
[
`Stage: ${currentStage}`,
`Days in stage: ${daysInCurrentStage}`,
`Expected: ${expectedDays} days`,
],
velocityRatio < 1 ? ['Procedure is within expected timeline'] : [],
velocityRatio < 0.5 ? 'high' : 'medium'
);
const predictedCompletion = asStr(p['predictedCompletion']) || null;
results.push({
procedureId,
title,
currentStage,
daysInCurrentStage,
expectedDaysForStage: expectedDays,
velocityRisk,
predictedCompletion,
});
}
// Sort by risk score descending
return results.sort((a, b) => b.velocityRisk.riskScore - a.velocityRisk.riskScore);
}
/**
* Run a full agentic risk assessment workflow (identify → analyze → evaluate → treat).
* Inspired by ISMS AI Agent-Driven Risk Assessment methodology, providing a
* structured, auditable trace for agentic processes.
*
* @param assessmentId - Unique identifier for this assessment run
* @param date - ISO date string for the assessment
* @param articleType - Article category this assessment is produced for
* @param identifiedRisks - Risks identified in the identify step
* @param riskDrivers - Risk drivers analysed in the analyze step
* @param mitigations - Mitigations recommended in the treat step
* @returns Fully populated AgentRiskAssessmentWorkflow
*/
export function runAgentRiskAssessment(
assessmentId: string,
date: string,
articleType: ArticleCategory,
identifiedRisks: readonly PoliticalRiskScore[],
riskDrivers: readonly PoliticalRiskDriver[],
mitigations: readonly string[]
): AgentRiskAssessmentWorkflow {
// Step 1: Identify — clone to prevent external mutation of the audit trace
const identifyStep: RiskAssessmentStep = { type: 'identify', risks: [...identifiedRisks] };
// Step 2: Analyze — clone to prevent external mutation
const analyzeStep: RiskAssessmentStep = { type: 'analyze', drivers: [...riskDrivers] };
// Step 3: Evaluate — sort risks by score to build evaluation matrix
const evaluateMatrix = [...identifiedRisks].sort((a, b) => b.riskScore - a.riskScore);
const evaluateStep: RiskAssessmentStep = { type: 'evaluate', matrix: evaluateMatrix };
// Step 4: Treat — clone to prevent external mutation
const treatStep: RiskAssessmentStep = { type: 'treat', mitigations: [...mitigations] };
const steps: RiskAssessmentStep[] = [identifyStep, analyzeStep, evaluateStep, treatStep];
// Synthesise an overall risk profile from all identified risks
const overallRiskProfile = synthesiseOverallRisk(identifiedRisks, assessmentId, date);
return {
assessmentId,
date,
articleType,
steps,
overallRiskProfile,
recommendations: [...mitigations],
};
}
/**
* Generate a structured markdown document from an agent risk assessment workflow.
* Produces a YAML-frontmatter header and all risk sections in markdown format
* suitable for writing to `analysis-output/{date}/risk-scoring/agent-risk-workflow.md`.
*
* @param assessment - Completed agent risk assessment workflow
* @returns Markdown string with YAML frontmatter and full risk analysis
*/
export function generateRiskAssessmentMarkdown(assessment: AgentRiskAssessmentWorkflow): string {
const { assessmentId, date, articleType, overallRiskProfile, steps, recommendations } =
assessment;
const riskCounts = countRisks(steps);
const frontmatter = [
'---',
`title: "Political Risk Assessment"`,
`date: "${sanitizeYamlValue(date)}"`,
`assessmentId: "${sanitizeYamlValue(assessmentId)}"`,
`articleType: "${sanitizeYamlValue(articleType)}"`,
`analysisType: "risk-scoring"`,
`overallRiskLevel: "${sanitizeYamlValue(overallRiskProfile.riskLevel)}"`,
`confidence: "${sanitizeYamlValue(overallRiskProfile.confidence)}"`,
`methods: ["risk-matrix"]`,
`riskCount: { low: ${riskCounts.low}, medium: ${riskCounts.medium}, high: ${riskCounts.high}, critical: ${riskCounts.critical} }`,
'---',
].join('\n');
const safeAssessmentId = sanitizeMarkdownContent(assessmentId);
const safeDate = sanitizeMarkdownContent(date);
const safeArticleType = sanitizeMarkdownContent(articleType);
const safeOverallRiskLevel = sanitizeMarkdownContent(overallRiskProfile.riskLevel).toUpperCase();
const safeConfidence = sanitizeMarkdownContent(overallRiskProfile.confidence);
const safeOverallRiskScore = sanitizeMarkdownContent(String(overallRiskProfile.riskScore));
const header = `\n# Political Risk Assessment\n\n**Assessment ID**: ${safeAssessmentId} \n**Date**: ${safeDate} \n**Article Type**: ${safeArticleType} \n**Overall Risk Level**: ${safeOverallRiskLevel} (score: ${safeOverallRiskScore}) \n**Confidence**: ${safeConfidence}\n`;
const heatMap = buildRiskHeatMapMarkdown();
const identifyStep = steps.find((s) => s.type === 'identify');
const risksSection =
identifyStep?.type === 'identify' ? buildRisksMarkdown(identifyStep.risks) : '';
const evaluateStep = steps.find((s) => s.type === 'evaluate');
const evaluateSection =
evaluateStep?.type === 'evaluate' ? buildEvaluateMarkdown(evaluateStep.matrix) : '';
const treatStep = steps.find((s) => s.type === 'treat');
const treatSection = treatStep?.type === 'treat' ? buildTreatMarkdown(treatStep.mitigations) : '';
const recommendationsSection =
recommendations.length > 0
? `\n## Recommendations\n\n${recommendations.map((r) => `- ${sanitizeMarkdownContent(r)}`).join('\n')}\n`
: '';
return [
frontmatter,
header,
heatMap,
risksSection,
evaluateSection,
treatSection,
recommendationsSection,
]
.filter(Boolean)
.join('\n');
}
/**
* Generate a complete political risk summary combining all assessment outputs.
*
* @param date - ISO date string for the summary
* @param topRisks - Top identified political risks (sorted by score)
* @param capitalAtRisk - Political capital at risk for key actors
* @param quantitativeSwot - Quantitative SWOT analysis
* @param legislativeVelocityRisks - Legislative velocity risk indicators
* @returns PoliticalRiskSummary with aggregated metrics
*/
export function generatePoliticalRiskSummary(
date: string,
topRisks: readonly PoliticalRiskScore[],
capitalAtRisk: readonly PoliticalCapitalAtRisk[],
quantitativeSwot: QuantitativeSWOT,
legislativeVelocityRisks: readonly LegislativeVelocityRisk[]
): PoliticalRiskSummary {
const riskCount = countRisksFromArray(topRisks);
const overallRiskLevel = deriveOverallRiskLevel(topRisks);
const confidence = deriveOverallConfidence(topRisks);
return {
date,
overallRiskLevel,
confidence,
riskCount,
topRisks,
capitalAtRisk,
quantitativeSwot,
legislativeVelocityRisks,
};
}
// ─── Private helper functions ─────────────────────────────────────────────────
/**
* Check whether a string is a valid LegislativeStage.
*
* @param s - String to check
* @returns True if valid stage
*/
function isLegislativeStage(s: string): s is LegislativeStage {
Iif (s === '__proto__' || s === 'constructor' || s === 'prototype') {
return false;
}
return Object.hasOwn(EXPECTED_STAGE_DAYS, s);
}
/**
* Map a velocity ratio to a risk likelihood level.
*
* @param ratio - daysInStage / expectedDays
* @returns Likelihood level
*/
function velocityRatioToLikelihood(ratio: number): PoliticalRiskLikelihood {
if (ratio < 0.5) return 'rare';
if (ratio < 1.0) return 'unlikely';
if (ratio < 1.5) return 'possible';
if (ratio < 2.0) return 'likely';
return 'almost_certain';
}
/**
* Map a legislative stage to a default risk impact level.
*
* @param stage - Legislative stage
* @returns Impact level
*/
function stageToImpact(stage: LegislativeStage): PoliticalRiskImpact {
switch (stage) {
case 'adopted':
return 'negligible';
case 'proposal':
return 'minor';
case 'committee':
case 'plenary_first':
return 'moderate';
case 'trilogue':
case 'plenary_second':
return 'major';
case 'stalled':
return 'severe';
default:
return 'moderate';
}
}
/**
* Synthesise an overall risk profile from a set of individual risk scores.
* Uses the highest risk score as the representative profile.
*
* @param risks - All identified risk scores
* @param assessmentId - Assessment identifier
* @param date - Assessment date
* @returns Overall composite PoliticalRiskScore
*/
function synthesiseOverallRisk(
risks: readonly PoliticalRiskScore[],
assessmentId: string,
date: string
): PoliticalRiskScore {
if (risks.length === 0) {
return calculatePoliticalRiskScore(
'rare',
'negligible',
`OVERALL-${assessmentId}`,
`Overall risk profile for assessment ${assessmentId} on ${date}`,
[],
[],
'low'
);
}
// Safe: risks.length > 0 is guaranteed by the guard above
const firstRisk = risks[0]!;
const maxRisk = risks.reduce((max, r) => (r.riskScore > max.riskScore ? r : max), firstRisk);
// Count confidence levels to pick the dominant one
const confidenceCounts: Record<ConfidenceLevel, number> = { high: 0, medium: 0, low: 0 };
for (const r of risks) {
confidenceCounts[r.confidence]++;
}
const dominantConfidence: ConfidenceLevel =
confidenceCounts.high >= confidenceCounts.medium &&
confidenceCounts.high >= confidenceCounts.low
? 'high'
: confidenceCounts.medium >= confidenceCounts.low
? 'medium'
: 'low';
// Use maxRisk fields to maintain the invariant: riskScore = likelihoodValue × impactValue
return {
riskId: `OVERALL-${assessmentId}`,
description: `Overall risk profile: ${risks.length} risks identified; highest: ${maxRisk.description}`,
likelihood: maxRisk.likelihood,
likelihoodValue: maxRisk.likelihoodValue,
impact: maxRisk.impact,
impactValue: maxRisk.impactValue,
riskScore: maxRisk.riskScore,
riskLevel: maxRisk.riskLevel,
confidence: dominantConfidence,
evidence: risks.flatMap((r) => r.evidence).slice(0, 5),
mitigatingFactors: risks.flatMap((r) => r.mitigatingFactors).slice(0, 5),
};
}
/**
* Count risks by level from workflow steps.
*
* @param steps - Risk assessment steps
* @returns Counts per risk level
*/
function countRisks(
steps: readonly { type: string; risks?: readonly PoliticalRiskScore[] }[]
): RiskLevelCounts {
const identifyStep = steps.find((s) => s.type === 'identify');
const risks: readonly PoliticalRiskScore[] =
identifyStep && 'risks' in identifyStep ? (identifyStep.risks ?? []) : [];
return countRisksFromArray(risks);
}
/**
* Count risks by level from an array of risk scores.
*
* @param risks - Array of political risk scores
* @returns Counts per risk level
*/
function countRisksFromArray(risks: readonly PoliticalRiskScore[]): RiskLevelCounts {
let low = 0;
let medium = 0;
let high = 0;
let critical = 0;
for (const r of risks) {
if (r.riskLevel === 'low') low++;
else if (r.riskLevel === 'medium') medium++;
else if (r.riskLevel === 'high') high++;
else Eif (r.riskLevel === 'critical') critical++;
}
return { low, medium, high, critical };
}
/**
* Derive overall risk level from a set of risk scores.
* Takes the highest level present.
*
* @param risks - Array of political risk scores
* @returns Highest risk level present, or 'low' if empty
*/
function deriveOverallRiskLevel(risks: readonly PoliticalRiskScore[]): PoliticalRiskLevel {
if (risks.length === 0) return 'low';
const ORDER: PoliticalRiskLevel[] = ['low', 'medium', 'high', 'critical'];
return risks.reduce<PoliticalRiskLevel>((max, r) => {
return ORDER.indexOf(r.riskLevel) > ORDER.indexOf(max) ? r.riskLevel : max;
}, 'low');
}
/**
* Derive an overall confidence level from a set of risk scores.
*
* @param risks - Array of political risk scores
* @returns Dominant confidence level
*/
function deriveOverallConfidence(risks: readonly PoliticalRiskScore[]): ConfidenceLevel {
if (risks.length === 0) return 'low';
const counts: Record<ConfidenceLevel, number> = { high: 0, medium: 0, low: 0 };
for (const r of risks) {
counts[r.confidence]++;
}
Iif (counts.high >= counts.medium && counts.high >= counts.low) return 'high';
Eif (counts.medium >= counts.low) return 'medium';
return 'low';
}
/**
* Build a markdown risk heat map table.
*
* @returns Markdown string with the risk heat map
*/
function buildRiskHeatMapMarkdown(): string {
const impacts: PoliticalRiskImpact[] = ['severe', 'major', 'moderate', 'minor', 'negligible'];
const likelihoods: PoliticalRiskLikelihood[] = [
'rare',
'unlikely',
'possible',
'likely',
'almost_certain',
];
const header = `## Risk Heat Map\n\n| Impact ↓ / Likelihood → | Rare | Unlikely | Possible | Likely | Almost Certain |\n|--------------------------|------|----------|----------|--------|----------------|`;
const rows = impacts.map((impact) => {
const cells = likelihoods.map((likelihood) => {
// eslint-disable-next-line security/detect-object-injection -- keys are typed PoliticalRiskLikelihood/Impact from const arrays
const score = LIKELIHOOD_VALUES[likelihood] * IMPACT_VALUES[impact];
const level = deriveRiskLevel(round2(score));
// eslint-disable-next-line security/detect-object-injection -- key is a typed PoliticalRiskLevel
return HEAT_MAP_CELLS[level];
});
const impactLabel = `**${impact.charAt(0).toUpperCase() + impact.slice(1)}**`;
return `| ${impactLabel} | ${cells.join(' | ')} |`;
});
return `${header}\n${rows.join('\n')}\n`;
}
/**
* Build markdown for identified risks.
*
* @param risks - Identified risk scores
* @returns Markdown string
*/
function buildRisksMarkdown(risks: readonly PoliticalRiskScore[]): string {
if (risks.length === 0) return '';
const lines = risks.map((r) => {
const safeRiskId = sanitizeMarkdownContent(r.riskId);
const headingId = safeRiskId.length > 0 ? safeRiskId : 'RISK-UNKNOWN';
const safeDescription = sanitizeMarkdownContent(r.description);
const headingDescription = safeDescription.length > 0 ? safeDescription : headingId;
const safeEvidence = r.evidence.map((e) => sanitizeMarkdownContent(e)).filter(Boolean);
const evidence = safeEvidence.length > 0 ? `\n- **Evidence**: ${safeEvidence.join('; ')}` : '';
const safeMitigations = r.mitigatingFactors
.map((m) => sanitizeMarkdownContent(m))
.filter(Boolean);
const mitigations =
safeMitigations.length > 0 ? `\n- **Mitigating Factors**: ${safeMitigations.join('; ')}` : '';
const safeLikelihood = sanitizeMarkdownContent(String(r.likelihood));
const safeLikelihoodValue = sanitizeMarkdownContent(String(r.likelihoodValue));
const safeImpact = sanitizeMarkdownContent(String(r.impact));
const safeImpactValue = sanitizeMarkdownContent(String(r.impactValue));
const safeRiskScore = sanitizeMarkdownContent(String(r.riskScore));
const safeRiskLevel = sanitizeMarkdownContent(String(r.riskLevel)).toUpperCase();
const safeConfidence = sanitizeMarkdownContent(String(r.confidence));
return [
`### ${headingId}: ${headingDescription}`,
`- **Likelihood**: ${safeLikelihood} (${safeLikelihoodValue}) | **Impact**: ${safeImpact} (${safeImpactValue}) | **Score**: ${safeRiskScore} (${safeRiskLevel}) | **Confidence**: ${safeConfidence}${evidence}${mitigations}`,
].join('\n');
});
return `\n## Identified Risks\n\n${lines.join('\n\n')}\n`;
}
/**
* Sanitize a value for safe inclusion in a Markdown table cell.
* Escapes backslash and pipe characters and replaces newlines with spaces.
*
* @param value - Raw cell value
* @returns Sanitized string safe for Markdown tables
*/
function sanitizeMarkdownTableCell(value: string | undefined | null): string {
const normalized = (value ?? '').trim();
Iif (normalized === '') {
return 'N/A';
}
const withoutNewlines = normalized.replace(/[\r\n]+/g, ' ');
const escapedBackslashes = withoutNewlines.replace(/\\/g, '\\\\');
return escapedBackslashes.replace(/\|/g, '\\|');
}
/**
* Sanitize a value for safe inclusion in Markdown headings and bullet content.
* Strips newlines to prevent document structure injection.
*
* @param value - Raw value
* @returns Sanitized string safe for Markdown headings/bullets
*/
function sanitizeMarkdownContent(value: unknown): string {
const normalized = String(value ?? '').trim();
Iif (normalized === '') {
return '';
}
return normalized.replace(/[\r\n]+/g, ' ');
}
/**
* Sanitize a YAML scalar value for safe inclusion in YAML frontmatter.
* Escapes double quotes and strips newlines to prevent YAML injection.
*
* @param value - Raw value
* @returns Sanitized string safe for YAML double-quoted scalars
*/
function sanitizeYamlValue(value: unknown): string {
const normalized = String(value ?? '').trim();
Iif (normalized === '') {
return '';
}
const withoutNewlines = normalized.replace(/[\r\n]+/g, ' ');
const escapedBackslashes = withoutNewlines.replace(/\\/g, '\\\\');
return escapedBackslashes.replace(/"/g, '\\"');
}
/**
* Build markdown for the evaluation matrix (risks ranked by score).
*
* @param matrix - Risks sorted by score
* @returns Markdown string
*/
function buildEvaluateMarkdown(matrix: readonly PoliticalRiskScore[]): string {
if (matrix.length === 0) return '';
const header = `\n## Risk Evaluation Matrix\n\n| Rank | Risk ID | Description | Score | Level | Confidence |\n|------|---------|-------------|-------|-------|------------|`;
const rows = matrix.map((r, i) => {
const riskId = sanitizeMarkdownTableCell(r.riskId);
const rawDesc = r.description ?? '';
const truncatedDesc = rawDesc.length > 60 ? `${rawDesc.substring(0, 60)}…` : rawDesc;
const descCell = sanitizeMarkdownTableCell(truncatedDesc);
const levelCell = sanitizeMarkdownTableCell(String(r.riskLevel).toUpperCase());
const confidenceCell = sanitizeMarkdownTableCell(String(r.confidence));
const riskScoreCell = sanitizeMarkdownTableCell(String(r.riskScore));
return `| ${i + 1} | ${riskId} | ${descCell} | ${riskScoreCell} | ${levelCell} | ${confidenceCell} |`;
});
return `${header}\n${rows.join('\n')}\n`;
}
/**
* Build markdown for the risk treatment / mitigation section.
*
* @param mitigations - List of mitigation actions
* @returns Markdown string
*/
function buildTreatMarkdown(mitigations: readonly string[]): string {
if (!Array.isArray(mitigations) || mitigations.length === 0) return '';
const sanitizedItems = mitigations
.map((m) => sanitizeMarkdownContent(String(m ?? '')))
.filter((m) => m.length > 0);
Iif (sanitizedItems.length === 0) return '';
const items = sanitizedItems.map((m) => `- ${m}`).join('\n');
return `\n## Risk Treatment Plan\n\n${items}\n`;
}
// ─── Factory helpers for creating scored SWOT items ──────────────────────────
/**
* Create a scored SWOT item for a strength or weakness (score 0–5).
* A score of 0 represents a neutral or not-currently-relevant factor.
*
* @param description - Description of the factor
* @param score - Magnitude score (0–5; clamped)
* @param evidence - Supporting evidence
* @param confidence - Confidence level
* @param trend - Trend direction
* @returns ScoredSWOTItem
*/
export function createScoredSWOTItem(
description: string,
score: number,
evidence: readonly string[] = [],
confidence: ConfidenceLevel = 'medium',
trend: SwotItemTrend = 'stable'
): ScoredSWOTItem {
return {
description,
score: round2(clamp(score, 0, 5)),
evidence: [...evidence],
confidence,
trend,
};
}
/**
* Create a scored SWOT item for an opportunity or threat
* (score = probability × impact, range 0–4.5).
*
* @param description - Description of the factor
* @param likelihood - Likelihood of occurrence
* @param impact - Impact if it occurs
* @param evidence - Supporting evidence
* @param confidence - Confidence level
* @param trend - Trend direction
* @returns ScoredSWOTItem
*/
export function createScoredOpportunityOrThreat(
description: string,
likelihood: PoliticalRiskLikelihood,
impact: PoliticalRiskImpact,
evidence: readonly string[] = [],
confidence: ConfidenceLevel = 'medium',
trend: SwotItemTrend = 'stable'
): ScoredSWOTItem {
if (!Object.hasOwn(LIKELIHOOD_VALUES, likelihood)) {
throw new Error(
`Invalid likelihood: "${String(likelihood)}". Expected one of: ${Object.keys(LIKELIHOOD_VALUES).join(', ')}`
);
}
if (!Object.hasOwn(IMPACT_VALUES, impact)) {
throw new Error(
`Invalid impact: "${String(impact)}". Expected one of: ${Object.keys(IMPACT_VALUES).join(', ')}`
);
}
// eslint-disable-next-line security/detect-object-injection -- keys validated via Object.hasOwn above
const score = round2(LIKELIHOOD_VALUES[likelihood] * IMPACT_VALUES[impact]);
return {
description,
score,
evidence: [...evidence],
confidence,
trend,
};
}
/**
* Create a political risk driver.
*
* @param description - Description of the driver
* @param category - Threat category
* @param contribution - Percentage contribution to total risk (0–100)
* @param trend - Whether risk is increasing, stable, or decreasing
* @returns PoliticalRiskDriver
*/
export function createRiskDriver(
description: string,
category: PoliticalThreatCategory,
contribution: number,
trend: PoliticalRiskDriver['trend'] = 'stable'
): PoliticalRiskDriver {
return {
description,
category,
contribution: round2(clamp(contribution, 0, 100)),
trend,
};
}
|