All files / src/utils doceoMepAggregator.ts

85.05% Statements 74/87
80.23% Branches 69/86
100% Functions 14/14
89.55% Lines 60/67

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                                                              5x     5x                                                                                               5x     5x                 43x       43x 43x 3x       3x       36x         36x                       40x             26x 26x 25x 24x                           36x                             11x 11x 11x 11x           19x 19x 5x 1x               11x 11x 11x 11x                         11x   11x   1x 1x                     23x 23x 23x 19x     19x 11x       36x     36x     36x                                                                                                                               43x 43x 43x 43x   40x       43x 43x 40x                             36x 36x 26x 24x 23x     36x           36x 36x   4x         4x      
/**
 * @fileoverview DOCEO per-MEP voting aggregator.
 *
 * Aggregates per-MEP roll-call voting statistics from the EP DOCEO XML
 * source. Designed to be shared by OSINT tools (`assess_mep_influence`,
 * `detect_voting_anomalies`, `comparative_intelligence`) that need real
 * RCV-derived voting activity instead of the placeholder zeros returned
 * by `MEPDetails.votingStatistics` when the EP API has no per-MEP data.
 *
 * Behaviour mirrors `computeCoalitionCohesionFromDoceo` in
 * `src/tools/analyzeCoalitionDynamics.ts`:
 * - bounded by `withTimeoutAndAbort` (default 2 s)
 * - results cached for 5 minutes per `${mepId}|${dateFrom}|${dateTo}|${politicalGroup}|${limit}`
 * - empty/failed results are also cached briefly to avoid storms
 *
 * Source: `doceoClient.getLatestVotes()` (EP DOCEO XML).
 *
 * ISMS Policy: SC-002 (Input Validation), AC-003 (Least Privilege),
 *   AU-002 (Audit Logging). GDPR Article 5(1)(d) — accuracy principle.
 *
 * @module utils/doceoMepAggregator
 * @since 1.4.0
 */
 
import { doceoClient } from '../clients/ep/doceoClient.js';
import { withTimeoutAndAbort } from './timeout.js';
import { auditLogger, toErrorMessage } from './auditLogger.js';
import { normalizePoliticalGroup } from './politicalGroupNormalization.js';
import type { LatestVoteRecord } from '../clients/ep/doceoXmlParser.js';
 
/** Default per-call DOCEO timeout (ms). Matches the coalition cohesion guard. */
export const DOCEO_MEP_AGGREGATOR_TIMEOUT_MS = 2_000;
 
/** Cache TTL for per-MEP aggregates (ms). Mirrors DOCEO cohesion cache. */
export const DOCEO_MEP_AGGREGATOR_CACHE_TTL_MS = 5 * 60_000;
 
/**
 * Aggregated per-MEP voting statistics derived from DOCEO RCV data.
 */
export interface MepVotingAggregateStats {
  /** Number of RCV votes considered (where the MEP appeared on the roll). */
  totalVotes: number;
  /** RCV votes the MEP cast as FOR. */
  votesFor: number;
  /** RCV votes the MEP cast as AGAINST. */
  votesAgainst: number;
  /** RCV votes the MEP cast as ABSTAIN. */
  abstentions: number;
  /**
   * Participation rate as `totalVotes / rcvVotesInspected * 100`. When no DOCEO
   * RCV votes were inspected, returns 0.
   */
  attendanceRate: number;
  /**
   * % of decisive votes (FOR/AGAINST) where the MEP voted with the
   * majority of their political group. `null` when the group cannot
   * be resolved from the MEP details or no DOCEO data was available.
   */
  loyaltyScore: number | null;
  /** Total number of RCV votes inspected from DOCEO for the period. */
  rcvVotesInspected: number;
}
 
/**
 * Result returned by {@link computeMepVotingActivityFromDoceo}.
 */
export interface DoceoMepAggregateResult {
  /** Aggregated voting statistics. */
  stats: MepVotingAggregateStats;
  /** Always `'DOCEO'`. */
  dataSource: 'DOCEO';
  /** `true` when this result was served from the in-memory cache. */
  cacheHit: boolean;
  /** Number of DOCEO RCV votes inspected (mirrors `stats.rcvVotesInspected`). */
  rcvVotesInspected: number;
}
 
interface CacheEntry {
  expiresAt: number;
  value: DoceoMepAggregateResult;
}
 
const aggregateCache = new Map<string, CacheEntry>();
 
/** Cache size cap to bound memory usage. */
const MAX_CACHE_ENTRIES = 1000;
 
function makeCacheKey(
  mepId: string,
  dateFrom: string | undefined,
  dateTo: string | undefined,
  politicalGroup: string | undefined,
  limit: number | undefined
): string {
  return `${mepId}|${dateFrom ?? ''}|${dateTo ?? ''}|${politicalGroup ?? ''}|${String(limit ?? '')}`;
}
 
function readCache(key: string): DoceoMepAggregateResult | undefined {
  const entry = aggregateCache.get(key);
  if (entry === undefined) return undefined;
  Iif (entry.expiresAt <= Date.now()) {
    aggregateCache.delete(key);
    return undefined;
  }
  return { ...entry.value, cacheHit: true };
}
 
function writeCache(key: string, value: DoceoMepAggregateResult): void {
  Iif (aggregateCache.size >= MAX_CACHE_ENTRIES) {
    // Evict oldest entry (insertion order in Map).
    const firstKey = aggregateCache.keys().next().value;
    if (firstKey !== undefined) aggregateCache.delete(firstKey);
  }
  aggregateCache.set(key, {
    expiresAt: Date.now() + DOCEO_MEP_AGGREGATOR_CACHE_TTL_MS,
    value: { ...value, cacheHit: false },
  });
}
 
/**
 * Reset the bounded in-memory aggregate cache.
 *
 * @internal Test hook — production code should rely on TTL expiry.
 */
export function clearDoceoMepAggregatorCache(): void {
  aggregateCache.clear();
}
 
/**
 * Determine whether a DOCEO record falls inside the optional date range.
 */
function isWithinRange(record: LatestVoteRecord, dateFrom?: string, dateTo?: string): boolean {
  const date = record.sittingDate ?? record.date;
  if (dateFrom !== undefined && dateFrom !== '' && date < dateFrom) return false;
  if (dateTo !== undefined && dateTo !== '' && date > dateTo) return false;
  return true;
}
 
interface InternalAggregate {
  totalVotes: number;
  votesFor: number;
  votesAgainst: number;
  abstentions: number;
  rcvVotesInspected: number;
  loyaltyAgreements: number;
  loyaltyDecisiveVotes: number;
}
 
function emptyAggregate(): InternalAggregate {
  return {
    totalVotes: 0,
    votesFor: 0,
    votesAgainst: 0,
    abstentions: 0,
    rcvVotesInspected: 0,
    loyaltyAgreements: 0,
    loyaltyDecisiveVotes: 0,
  };
}
 
/** Determine the majority position of a political group from a DOCEO group breakdown row. */
function groupMajorityPosition(
  row: { for: number; against: number; abstain: number } | undefined
): 'FOR' | 'AGAINST' | 'ABSTAIN' | null {
  Iif (row === undefined) return null;
  const { for: f, against: a, abstain: ab } = row;
  Iif (f === 0 && a === 0 && ab === 0) return null;
  Eif (f >= a && f >= ab) return 'FOR';
  if (a >= f && a >= ab) return 'AGAINST';
  return 'ABSTAIN';
}
 
function recordVotePosition(position: 'FOR' | 'AGAINST' | 'ABSTAIN', agg: InternalAggregate): void {
  agg.totalVotes += 1;
  if (position === 'FOR') agg.votesFor += 1;
  else if (position === 'AGAINST') agg.votesAgainst += 1;
  else agg.abstentions += 1;
}
 
function updateLoyalty(
  position: 'FOR' | 'AGAINST',
  groupRow: { for: number; against: number; abstain: number } | undefined,
  agg: InternalAggregate
): void {
  const majority = groupMajorityPosition(groupRow);
  Iif (majority === null || majority === 'ABSTAIN') return;
  agg.loyaltyDecisiveVotes += 1;
  if (majority === position) agg.loyaltyAgreements += 1;
}
 
/**
 * Resolve the breakdown row for a normalized group label, handling the case
 * where DOCEO XML uses raw short labels (e.g. `RE`, `Verts/ALE`) while
 * `normalizedGroup` is the canonical EP code (`Renew`, `Greens/EFA`).
 * Mirrors the normalizeGroupBreakdown pattern used in analyzeCoalitionDynamics.
 */
function resolveGroupBreakdownRow(
  breakdown: Record<string, { for: number; against: number; abstain: number }> | undefined,
  normalizedGroup: string
): { for: number; against: number; abstain: number } | undefined {
  Iif (breakdown === undefined) return undefined;
  // Fast path: direct key match (canonical key already in breakdown).
  if (normalizedGroup in breakdown) return breakdown[normalizedGroup];
  // Slow path: normalize each DOCEO raw key and compare.
  for (const [rawKey, row] of Object.entries(breakdown)) {
    Eif (normalizePoliticalGroup(rawKey) === normalizedGroup) return row;
  }
  return undefined;
}
 
function tallyVote(
  vote: LatestVoteRecord,
  mepId: string,
  normalizedGroup: string | null,
  agg: InternalAggregate
): void {
  agg.rcvVotesInspected += 1;
  const position = vote.mepVotes?.[mepId];
  if (position === undefined) return;
  recordVotePosition(position, agg);
 
  // Loyalty: agreement of MEP's decisive vote with their group's majority.
  if (normalizedGroup === null || position === 'ABSTAIN') return;
  updateLoyalty(position, resolveGroupBreakdownRow(vote.groupBreakdown, normalizedGroup), agg);
}
 
function buildStats(agg: InternalAggregate): MepVotingAggregateStats {
  const attendanceRate = agg.rcvVotesInspected > 0
    ? Math.round((agg.totalVotes / agg.rcvVotesInspected) * 100 * 100) / 100
    : 0;
  const loyaltyScore = agg.loyaltyDecisiveVotes > 0
    ? Math.round((agg.loyaltyAgreements / agg.loyaltyDecisiveVotes) * 100 * 100) / 100
    : null;
  return {
    totalVotes: agg.totalVotes,
    votesFor: agg.votesFor,
    votesAgainst: agg.votesAgainst,
    abstentions: agg.abstentions,
    attendanceRate,
    loyaltyScore,
    rcvVotesInspected: agg.rcvVotesInspected,
  };
}
 
/**
 * Options accepted by {@link computeMepVotingActivityFromDoceo}.
 */
export interface ComputeMepVotingActivityOptions {
  /** Inclusive lower bound for the sitting date (`YYYY-MM-DD`). */
  dateFrom?: string | undefined;
  /** Inclusive upper bound for the sitting date (`YYYY-MM-DD`). */
  dateTo?: string | undefined;
  /**
   * The MEP's canonical political-group code (e.g. `EPP`, `S&D`). When provided
   * the aggregator computes a real `loyaltyScore` from DOCEO group breakdowns.
   */
  politicalGroup?: string | undefined;
  /** Override the default 2 s DOCEO timeout (ms). */
  timeoutMs?: number | undefined;
  /**
   * Maximum number of DOCEO vote records to fetch (defaults to 100, the same
   * page size used by `analyzeCoalitionDynamics`).
   */
  limit?: number | undefined;
}
 
/**
 * Compute per-MEP voting activity stats from the EP DOCEO XML source.
 *
 * Fetches the latest plenary-week RCV records via `doceoClient.getLatestVotes`
 * and aggregates the MEP's individual vote positions. Returns `null` when the
 * DOCEO call fails (timeout, network, parse error) so the caller can fall back
 * to placeholder data and emit a `dataQualityWarning`.
 *
 * @param mepId - EP MEP identifier (e.g. `'197558'`).
 * @param options - Optional date range, political group, timeout and limit overrides.
 * @returns Aggregate result or `null` if DOCEO data is unavailable.
 *
 * @security Errors are audit-logged via `auditLogger.logError(
 *   'doceo_mep_aggregator.fetch', ...)`. Only `mepId` is logged (no PII).
 *
 * @example
 * ```typescript
 * const result = await computeMepVotingActivityFromDoceo('197558', {
 *   dateFrom: '2026-01-01',
 *   dateTo: '2026-12-31',
 *   politicalGroup: 'EPP',
 * });
 * if (result !== null) {
 *   console.log(result.stats.totalVotes, result.stats.loyaltyScore);
 * }
 * ```
 */
export async function computeMepVotingActivityFromDoceo(
  mepId: string,
  options: ComputeMepVotingActivityOptions = {}
): Promise<DoceoMepAggregateResult | null> {
  const { dateFrom, dateTo, politicalGroup, timeoutMs, limit } = options;
  const key = makeCacheKey(mepId, dateFrom, dateTo, politicalGroup, limit);
  const cached = readCache(key);
  if (cached !== undefined) return cached;
 
  const normalizedGroup = politicalGroup !== undefined && politicalGroup !== ''
    ? normalizePoliticalGroup(politicalGroup)
    : null;
 
  try {
    const response = await withTimeoutAndAbort(
      (signal) => doceoClient.getLatestVotes({
        includeIndividualVotes: true,
        limit: limit ?? 100,
        // Pass weekStart derived from the requested analysis period so that
        // DOCEO fetches the plenary week containing the end (or start) of the
        // range rather than always defaulting to the current week. Without this,
        // historical dateFrom/dateTo ranges never match the fetched data and the
        // tool silently falls back to EP_API placeholders for every historical query.
        weekStart: dateTo ?? dateFrom,
        abortSignal: signal,
      }),
      timeoutMs ?? DOCEO_MEP_AGGREGATOR_TIMEOUT_MS,
      'DOCEO MEP voting aggregator timed out'
    );
 
    const agg = emptyAggregate();
    for (const vote of response.data) {
      if (!isWithinRange(vote, dateFrom, dateTo)) continue;
      if (vote.dataSource !== 'RCV') continue;
      tallyVote(vote, mepId, normalizedGroup, agg);
    }
 
    const result: DoceoMepAggregateResult = {
      stats: buildStats(agg),
      dataSource: 'DOCEO',
      cacheHit: false,
      rcvVotesInspected: agg.rcvVotesInspected,
    };
    writeCache(key, result);
    return result;
  } catch (error: unknown) {
    auditLogger.logError(
      'doceo_mep_aggregator.fetch',
      { mepId },
      toErrorMessage(error)
    );
    return null;
  }
}