All files / scripts government-role-validator.ts

93.02% Statements 80/86
64.38% Branches 47/73
100% Functions 16/16
94.73% Lines 72/76

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                                    1x 1x                                                 1x             7500x 7500x 7500x 7500x 1039995x 1039995x 1320x     1320x 30x   1290x   1038675x 30x 1038645x 120000x 120000x   918645x     7500x 7500x         7530x 15x   15x 7500x 7500x                                 8074x             1x     18x 16x 18x 18x 18x 18x 18x   1x 1x 1x       1x 1x           16x                       16x 16x 16x   16x   8000x 48x 48x   60x                     5x   5x 5x                               7x 7x 7x 7x   7x 1x                 6x 7x     7x 7x     7x         42x             7x 6x   6x 6x                               7x                                       2x 2x 1x    
/**
 * @module government-role-validator
 * @description Validates government role attributions against the CIA platform's
 * authoritative government role member data (view_riksdagen_goverment_role_member_sample.csv).
 *
 * ROOT CAUSE PREVENTION: Agentic workflows previously hallucinated government titles
 * (e.g. calling Lotta Edholm "Deputy Prime Minister" when she is gymnasie-, högskole-
 * och forskningsminister, and the actual Vice statsminister is Ebba Busch (KD)).
 * This module provides a validation layer that cross-references names against
 * known government roles from the CIA data export.
 *
 * The CSV is downloaded from:
 * https://raw.githubusercontent.com/Hack23/cia/refs/heads/master/service.data.impl/sample-data/view_riksdagen_goverment_role_member_sample.csv
 *
 * @author Hack23 AB
 * @license Apache-2.0
 */
 
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
 
/** A single government role record from the CIA data export. */
export interface GovernmentRoleMember {
  readonly roleId: string;
  readonly department: string;
  readonly roleCode: string;
  readonly firstName: string;
  readonly lastName: string;
  readonly fromDate: string;
  readonly toDate: string;
  readonly personId: string;
  readonly party: string;
  readonly active: boolean;
}
 
/** Result of validating a name + claimed role against known data. */
export interface RoleValidationResult {
  readonly valid: boolean;
  readonly name: string;
  readonly claimedRole: string;
  readonly actualRoles: readonly GovernmentRoleMember[];
  readonly suggestion: string;
}
 
const CSV_RELATIVE_PATH = 'cia-data/view_riksdagen_goverment_role_member_sample.csv';
 
/**
 * Parse a single CSV line respecting quoted fields (RFC 4180).
 * Handles commas inside double-quoted values (e.g. "Gymnasie-, högskole-…").
 */
function parseCSVLine(line: string): string[] {
  const fields: string[] = [];
  let current = '';
  let inQuotes = false;
  for (let i = 0; i < line.length; i++) {
    const ch = line[i];
    if (inQuotes) {
      Iif (ch === '"' && line[i + 1] === '"') {
        current += '"';
        i++; // skip escaped quote
      } else if (ch === '"') {
        inQuotes = false;
      } else {
        current += ch;
      }
    } else if (ch === '"') {
      inQuotes = true;
    } else if (ch === ',') {
      fields.push(current);
      current = '';
    } else {
      current += ch;
    }
  }
  fields.push(current);
  return fields;
}
 
/** Parse the CSV into GovernmentRoleMember records. */
function parseCSV(csvText: string): GovernmentRoleMember[] {
  const lines = csvText.split('\n').filter(l => l.trim().length > 0);
  Iif (lines.length < 2) return [];
  // Skip header
  return lines.slice(1).map(line => {
    const cols = parseCSVLine(line);
    return {
      roleId: cols[0] ?? '',
      department: cols[1] ?? '',
      roleCode: cols[2] ?? '',
      firstName: cols[3] ?? '',
      lastName: cols[4] ?? '',
      fromDate: cols[5] ?? '',
      toDate: cols[6] ?? '',
      personId: cols[7] ?? '',
      party: cols[8] ?? '',
      active: cols[10] === 't',
    };
  });
}
 
/** Normalise a name for fuzzy comparison (lowercase, trim, collapse whitespace). */
function normaliseName(name: string): string {
  return name.toLowerCase().replace(/\s+/g, ' ').trim();
}
 
/**
 * Load and cache the government role member data.
 * Uses the local CSV in cia-data/ as the authoritative source.
 */
let cachedMembers: GovernmentRoleMember[] | null = null;
 
export function loadGovernmentRoleMembers(repoRoot?: string): GovernmentRoleMember[] {
  if (cachedMembers) return cachedMembers;
  const root = repoRoot ?? resolve(import.meta.dirname ?? '.', '..');
  const csvPath = resolve(root, CSV_RELATIVE_PATH);
  try {
    const csvText = readFileSync(csvPath, 'utf-8');
    cachedMembers = parseCSV(csvText);
    return cachedMembers;
  } catch (err: unknown) {
    const code = (err as NodeJS.ErrnoException)?.code;
    if (code === 'ENOENT') {
      console.warn(`[government-role-validator] CSV not found at ${csvPath}; role validation disabled.`);
    } else E{
      console.warn(`[government-role-validator] Error loading ${csvPath}: ${err}; role validation disabled.`);
    }
    cachedMembers = [];
    return cachedMembers;
  }
}
 
/** Clear the cache (useful for testing). */
export function clearCache(): void {
  cachedMembers = null;
}
 
/**
 * Find all government roles for a person by last name (and optionally first name).
 * Returns roles sorted by most recent first.
 */
export function findRolesForPerson(
  lastName: string,
  firstName?: string,
  repoRoot?: string,
): GovernmentRoleMember[] {
  const members = loadGovernmentRoleMembers(repoRoot);
  const normLast = normaliseName(lastName);
  const normFirst = firstName ? normaliseName(firstName) : undefined;
 
  return members
    .filter(m => {
      if (normaliseName(m.lastName) !== normLast) return false;
      Iif (normFirst && normaliseName(m.firstName) !== normFirst) return false;
      return true;
    })
    .sort((a, b) => b.fromDate.localeCompare(a.fromDate));
}
 
/**
 * Get the current (most recent active, or most recent) role for a person.
 */
export function getCurrentRole(
  lastName: string,
  firstName?: string,
  repoRoot?: string,
): GovernmentRoleMember | undefined {
  const roles = findRolesForPerson(lastName, firstName, repoRoot);
  // Prefer active roles
  const active = roles.find(r => r.active);
  return active ?? roles[0];
}
 
/**
 * Validate whether a claimed government role title is correct for a person.
 * Returns a validation result with the actual role and a correction suggestion.
 *
 * @param fullName - Full name of the person (e.g. "Lotta Edholm")
 * @param claimedRole - The role attributed in the article (e.g. "Deputy Prime Minister")
 * @param repoRoot - Optional repository root path
 */
export function validateGovernmentRole(
  fullName: string,
  claimedRole: string,
  repoRoot?: string,
): RoleValidationResult {
  const parts = fullName.trim().split(/\s+/);
  const lastName = parts.pop() ?? '';
  const firstName = parts.join(' ') || undefined;
  const roles = findRolesForPerson(lastName, firstName, repoRoot);
 
  if (roles.length === 0) {
    return {
      valid: false,
      name: fullName,
      claimedRole,
      actualRoles: [],
      suggestion: `No government role records found for "${fullName}" in CIA data. Verify the name and role manually.`,
    };
  }
 
  const currentRole = roles.find(r => r.active) ?? roles[0];
  const claimedLower = claimedRole.toLowerCase();
 
  // Check if claimed role matches the actual role code or department
  const roleCodeLower = currentRole.roleCode.toLowerCase();
  const departmentLower = currentRole.department.toLowerCase();
 
  // Known title mappings for Deputy PM across supported languages
  const deputyPMTerms = ['deputy prime minister', 'vice statsminister', 'vice premier',
    'vicepremier', 'viceministerpräsident', 'vice-première ministre', 'viceprimera ministra',
    'varapääministeri', 'visestatsminister', 'vicestatsminister',
    'نائبة رئيس الوزراء', 'סגנית ראש הממשלה', '副首相', '부총리'];
 
  const isClaimingDeputyPM = deputyPMTerms.some(term => claimedLower.includes(term));
 
  // Deputy PM (Vice statsminister) is a constitutional designation given to one minister.
  // The CIA CSV does not have an explicit "Vice statsminister" role_code — the Deputy PM
  // has their regular ministerial role_code. To validate, we check that the person's
  // department is Statsrådsberedningen (PM's office) or role_code is Statsminister.
  // For any other person, claiming Deputy PM is invalid.
  if (isClaimingDeputyPM) {
    const isPMRole = roleCodeLower === 'statsminister' ||
      departmentLower === 'statsrådsberedningen';
    Eif (!isPMRole) {
      return {
        valid: false,
        name: fullName,
        claimedRole,
        actualRoles: roles,
        suggestion: `"${fullName}" is ${currentRole.roleCode} at ${currentRole.department} (${currentRole.party}), NOT Deputy Prime Minister. ` +
          `Check if the actual Vice statsminister should be cited instead.`,
      };
    }
  }
 
  // Check if claimed role roughly matches known role
  const matchesRole = claimedLower.includes(roleCodeLower) ||
    roleCodeLower.includes(claimedLower) ||
    claimedLower.includes(departmentLower);
 
  return {
    valid: matchesRole || !isClaimingDeputyPM,
    name: fullName,
    claimedRole,
    actualRoles: roles,
    suggestion: matchesRole
      ? `Role verified: "${fullName}" is ${currentRole.roleCode} at ${currentRole.department} (${currentRole.party}).`
      : `"${fullName}" is recorded as ${currentRole.roleCode} at ${currentRole.department} (${currentRole.party}). Claimed role "${claimedRole}" may need verification.`,
  };
}
 
/**
 * Get a formatted role description for a person suitable for article text.
 * Returns the most current role in "RoleCode FirstName LastName (Party)" format.
 */
export function getFormattedRole(
  lastName: string,
  firstName?: string,
  repoRoot?: string,
): string | undefined {
  const role = getCurrentRole(lastName, firstName, repoRoot);
  if (!role) return undefined;
  return `${role.roleCode} ${role.firstName} ${role.lastName} (${role.party})`;
}