/ lib / validation.ts
validation.ts
   1  // ============================================================================
   2  // GapZero — Output Validation Layer
   3  // Runs after EVERY Claude response, before sending results to the user.
   4  // Catches hallucinations, schema violations, and obvious errors.
   5  // ============================================================================
   6  
   7  import type {
   8    AnalysisResult,
   9    FitScore,
  10    Strength,
  11    Gap,
  12    ActionPlan,
  13    SalaryAnalysis,
  14    RoleRecommendation,
  15    JobMatch,
  16    ValidationIssue,
  17    ValidationReport,
  18  } from './types';
  19  import type { SalaryLookupResult } from './salary-lookup';
  20  
  21  // ============================================================================
  22  // Trusted resource domains (for gap resource URL checking)
  23  // ============================================================================
  24  
  25  const TRUSTED_RESOURCE_DOMAINS = [
  26    'microsoft.com', 'learn.microsoft.com', 'coursera.org', 'udemy.com',
  27    'freecodecamp.org', 'github.com', 'youtube.com', 'edx.org',
  28    'pluralsight.com', 'linkedin.com', 'kaggle.com', 'aws.amazon.com',
  29    'cloud.google.com', 'developer.android.com', 'developer.apple.com',
  30    'reactjs.org', 'nodejs.org', 'python.org', 'rust-lang.org',
  31    'typescriptlang.org', 'docker.com', 'kubernetes.io',
  32    'fast.ai', 'deeplearning.ai', 'huggingface.co',
  33    'codecademy.com', 'khanacademy.org', 'w3schools.com',
  34    'mozilla.org', 'developer.mozilla.org', 'stackoverflow.com',
  35    'hashicorp.com', 'terraform.io', 'jenkins.io',
  36    'atlassian.com', 'jetbrains.com', 'oracle.com',
  37    'salesforce.com', 'trailhead.salesforce.com',
  38    'uipath.com', 'academy.uipath.com',
  39    'anthropic.com', 'openai.com', 'docs.anthropic.com',
  40  ];
  41  
  42  // ============================================================================
  43  // Main validation entry point
  44  // ============================================================================
  45  
  46  export function validateAnalysisResult(
  47    result: AnalysisResult,
  48    salaryLookupFn?: (role: string, country: string, level?: 'junior' | 'mid' | 'senior' | 'lead') => SalaryLookupResult | null
  49  ): ValidationReport {
  50    const issues: ValidationIssue[] = [];
  51  
  52    issues.push(...validateFitScore(result.fitScore));
  53    issues.push(...validateStrengths(result.strengths));
  54    issues.push(...validateGaps(result.gaps));
  55    issues.push(...validateActionPlan(result.actionPlan));
  56    issues.push(
  57      ...validateSalaryAnalysis(
  58        result.salaryAnalysis,
  59        result.metadata.country,
  60        result.metadata.targetRole,
  61        result.metadata.targetRole,
  62        salaryLookupFn
  63      )
  64    );
  65    issues.push(...validateRoleRecommendations(result.roleRecommendations));
  66  
  67    if (result.jobMatch) {
  68      issues.push(...validateJobMatch(result.jobMatch));
  69    }
  70  
  71    // Cross-check: strengths vs gaps overlap
  72    const strengthTitles = new Set(result.strengths.map(s => s.title.toLowerCase()));
  73    result.gaps.forEach((gap, i) => {
  74      if (strengthTitles.has(gap.skill.toLowerCase())) {
  75        issues.push({
  76          section: 'gaps',
  77          severity: 'warning',
  78          field: `gaps[${i}].skill`,
  79          message: `Skill "${gap.skill}" appears in both strengths and gaps. Review for consistency.`,
  80          autoFixable: false,
  81        });
  82      }
  83    });
  84  
  85    // Cross-check: fitScore ↔ gap coherence
  86    // Rubric: 9-10 = no critical gaps; 7-8 = minor gaps closable in weeks;
  87    //         5-6 = 1-2 critical gaps, months of effort; 3-4 = significant gaps
  88    const criticalGapCount = result.gaps.filter(g => g.severity === 'critical').length;
  89    const moderateGapCount = result.gaps.filter(g => g.severity === 'moderate').length;
  90    const totalGapCount = result.gaps.length;
  91  
  92    // Any critical gap means score cannot be 8+ ("Strong Fit" = minor gaps only)
  93    if (result.fitScore.score >= 8 && criticalGapCount >= 1) {
  94      const cap = criticalGapCount >= 3 ? 5 : criticalGapCount >= 2 ? 6 : 7;
  95      issues.push({
  96        section: 'fitScore',
  97        severity: 'warning',
  98        field: 'fitScore.score',
  99        message: `Fit score ${result.fitScore.score} is too optimistic with ${criticalGapCount} critical gap(s). Auto-capping at ${cap}.`,
 100        autoFixable: true,
 101        autoFixAction: `Cap fitScore at ${cap} due to critical gaps`,
 102      });
 103    }
 104    // Score 9-10 requires near-perfect fit — many moderate gaps disqualify
 105    else if (result.fitScore.score >= 9 && moderateGapCount >= 3) {
 106      issues.push({
 107        section: 'fitScore',
 108        severity: 'warning',
 109        field: 'fitScore.score',
 110        message: `Fit score ${result.fitScore.score} is too optimistic with ${moderateGapCount} moderate gaps. Auto-capping at 8.`,
 111        autoFixable: true,
 112        autoFixAction: 'Cap fitScore at 8 due to critical gaps',
 113      });
 114    }
 115    // Score 8+ with 4+ moderate gaps suggests overrating — months of work needed
 116    else if (result.fitScore.score >= 8 && moderateGapCount >= 4) {
 117      issues.push({
 118        section: 'fitScore',
 119        severity: 'warning',
 120        field: 'fitScore.score',
 121        message: `Fit score ${result.fitScore.score} seems high with ${moderateGapCount} moderate gaps requiring months of effort. Auto-capping at 7.`,
 122        autoFixable: true,
 123        autoFixAction: 'Cap fitScore at 7 due to critical gaps',
 124      });
 125    }
 126  
 127    // Pessimism check: low score but no real gaps
 128    if (result.fitScore.score <= 3 && criticalGapCount === 0 && totalGapCount <= 2) {
 129      issues.push({
 130        section: 'fitScore',
 131        severity: 'warning',
 132        field: 'fitScore.score',
 133        message: `Fit score ${result.fitScore.score} seems too pessimistic with 0 critical gaps and only ${totalGapCount} total gaps.`,
 134        autoFixable: false,
 135      });
 136    }
 137  
 138    // Build sections summary
 139    const sectionNames = ['fitScore', 'strengths', 'gaps', 'actionPlan', 'salaryAnalysis', 'roleRecommendations', 'jobMatch'];
 140    const sections: Record<string, { valid: boolean; issueCount: number }> = {};
 141    for (const name of sectionNames) {
 142      const sectionIssues = issues.filter(i => i.section === name);
 143      const hasErrors = sectionIssues.some(i => i.severity === 'error');
 144      sections[name] = {
 145        valid: !hasErrors,
 146        issueCount: sectionIssues.length,
 147      };
 148    }
 149  
 150    const hasErrors = issues.some(i => i.severity === 'error');
 151    const autoFixable = issues.filter(i => i.autoFixable).length;
 152  
 153    return {
 154      isValid: !hasErrors,
 155      issues,
 156      autoFixed: autoFixable,
 157      sections,
 158      autoFixDescriptions: [],
 159    };
 160  }
 161  
 162  // ============================================================================
 163  // FitScore validation
 164  // ============================================================================
 165  
 166  export function validateFitScore(fitScore: FitScore): ValidationIssue[] {
 167    const issues: ValidationIssue[] = [];
 168  
 169    // Score must be integer 1-10
 170    if (!Number.isInteger(fitScore.score) || fitScore.score < 1 || fitScore.score > 10) {
 171      issues.push({
 172        section: 'fitScore',
 173        severity: 'error',
 174        field: 'fitScore.score',
 175        message: `Fit score ${fitScore.score} is outside valid range 1-10.`,
 176        autoFixable: true,
 177        autoFixAction: 'Clamp to 1-10 range',
 178      });
 179    }
 180  
 181    // Label validation
 182    const validLabels: FitScore['label'][] = ['Strong Fit', 'Moderate Fit', 'Stretch', 'Significant Gap'];
 183    if (!validLabels.includes(fitScore.label)) {
 184      issues.push({
 185        section: 'fitScore',
 186        severity: 'warning',
 187        field: 'fitScore.label',
 188        message: `Fit score label "${fitScore.label}" is not a recognized value.`,
 189        autoFixable: true,
 190        autoFixAction: 'Derive label from score',
 191      });
 192    }
 193  
 194    // Summary validation
 195    if (!fitScore.summary || fitScore.summary.trim().length === 0) {
 196      issues.push({
 197        section: 'fitScore',
 198        severity: 'warning',
 199        field: 'fitScore.summary',
 200        message: 'Fit score summary is empty.',
 201        autoFixable: false,
 202      });
 203    } else if (fitScore.summary.length < 50) {
 204      issues.push({
 205        section: 'fitScore',
 206        severity: 'warning',
 207        field: 'fitScore.summary',
 208        message: `Fit score summary is very short (${fitScore.summary.length} chars, expected 50-500).`,
 209        autoFixable: false,
 210      });
 211    } else if (fitScore.summary.length > 500) {
 212      issues.push({
 213        section: 'fitScore',
 214        severity: 'warning',
 215        field: 'fitScore.summary',
 216        message: `Fit score summary is too long (${fitScore.summary.length} chars, max 500).`,
 217        autoFixable: true,
 218        autoFixAction: 'Truncate at sentence boundary',
 219      });
 220    }
 221  
 222    return issues;
 223  }
 224  
 225  // ============================================================================
 226  // Strengths validation
 227  // ============================================================================
 228  
 229  export function validateStrengths(strengths: Strength[]): ValidationIssue[] {
 230    const issues: ValidationIssue[] = [];
 231  
 232    if (!strengths || strengths.length === 0) {
 233      issues.push({
 234        section: 'strengths',
 235        severity: 'error',
 236        field: 'strengths',
 237        message: 'No strengths identified. At least 1 required.',
 238        autoFixable: false,
 239      });
 240      return issues;
 241    }
 242  
 243    if (strengths.length > 8) {
 244      issues.push({
 245        section: 'strengths',
 246        severity: 'warning',
 247        field: 'strengths',
 248        message: `Too many strengths (${strengths.length}, max 8).`,
 249        autoFixable: true,
 250        autoFixAction: 'Keep top 8 strengths',
 251      });
 252    }
 253  
 254    // Valid tiers
 255    const validTiers: Strength['tier'][] = ['differentiator', 'strong', 'supporting'];
 256    const seenTitles = new Set<string>();
 257  
 258    strengths.forEach((s, i) => {
 259      if (!validTiers.includes(s.tier)) {
 260        issues.push({
 261          section: 'strengths',
 262          severity: 'warning',
 263          field: `strengths[${i}].tier`,
 264          message: `Invalid tier "${s.tier}" for strength "${s.title}".`,
 265          autoFixable: true,
 266          autoFixAction: 'Default to "supporting"',
 267        });
 268      }
 269  
 270      // Duplicate check
 271      const titleLower = (s.title || '').toLowerCase();
 272      if (titleLower && seenTitles.has(titleLower)) {
 273        issues.push({
 274          section: 'strengths',
 275          severity: 'warning',
 276          field: `strengths[${i}].title`,
 277          message: `Duplicate strength title: "${s.title}".`,
 278          autoFixable: true,
 279          autoFixAction: 'Remove duplicate',
 280        });
 281      }
 282      seenTitles.add(titleLower);
 283  
 284      // Required fields
 285      if (!s.title || s.title.trim().length === 0) {
 286        issues.push({
 287          section: 'strengths',
 288          severity: 'error',
 289          field: `strengths[${i}].title`,
 290          message: `Strength at index ${i} has empty title.`,
 291          autoFixable: false,
 292        });
 293      }
 294      if (!s.description || s.description.trim().length === 0) {
 295        issues.push({
 296          section: 'strengths',
 297          severity: 'warning',
 298          field: `strengths[${i}].description`,
 299          message: `Strength "${s.title}" has empty description.`,
 300          autoFixable: false,
 301        });
 302      }
 303      if (!s.relevance || s.relevance.trim().length === 0) {
 304        issues.push({
 305          section: 'strengths',
 306          severity: 'warning',
 307          field: `strengths[${i}].relevance`,
 308          message: `Strength "${s.title}" has empty relevance.`,
 309          autoFixable: false,
 310        });
 311      }
 312    });
 313  
 314    return issues;
 315  }
 316  
 317  // ============================================================================
 318  // Gaps validation
 319  // ============================================================================
 320  
 321  export function validateGaps(gaps: Gap[]): ValidationIssue[] {
 322    const issues: ValidationIssue[] = [];
 323  
 324    if (!gaps || gaps.length === 0) {
 325      issues.push({
 326        section: 'gaps',
 327        severity: 'error',
 328        field: 'gaps',
 329        message: 'No gaps identified. At least 1 required.',
 330        autoFixable: false,
 331      });
 332      return issues;
 333    }
 334  
 335    if (gaps.length > 10) {
 336      issues.push({
 337        section: 'gaps',
 338        severity: 'warning',
 339        field: 'gaps',
 340        message: `Too many gaps (${gaps.length}, max 10).`,
 341        autoFixable: true,
 342        autoFixAction: 'Keep top 10 by severity',
 343      });
 344    }
 345  
 346    const validSeverities: Gap['severity'][] = ['critical', 'moderate', 'minor'];
 347  
 348    gaps.forEach((g, i) => {
 349      if (!validSeverities.includes(g.severity)) {
 350        issues.push({
 351          section: 'gaps',
 352          severity: 'warning',
 353          field: `gaps[${i}].severity`,
 354          message: `Invalid severity "${g.severity}" for gap "${g.skill}".`,
 355          autoFixable: true,
 356          autoFixAction: 'Default to "moderate"',
 357        });
 358      }
 359  
 360      // Required fields
 361      const requiredFields = ['skill', 'currentLevel', 'requiredLevel', 'impact', 'closingPlan', 'timeToClose'] as const;
 362      for (const field of requiredFields) {
 363        if (!g[field] || (typeof g[field] === 'string' && g[field].trim().length === 0)) {
 364          issues.push({
 365            section: 'gaps',
 366            severity: 'warning',
 367            field: `gaps[${i}].${field}`,
 368            message: `Gap "${g.skill || `index ${i}`}" has empty ${field}.`,
 369            autoFixable: false,
 370          });
 371        }
 372      }
 373  
 374      // timeToClose format check — must contain a time unit, reject vague values
 375      if (g.timeToClose && g.timeToClose.trim().length > 0) {
 376        const hasTimeUnit = /\b(day|week|month|year|hr|hour)\b/i.test(g.timeToClose);
 377        const isVague = /\b(soon|varies|tbd|depends|unknown)\b/i.test(g.timeToClose);
 378        if (!hasTimeUnit || isVague) {
 379          issues.push({
 380            section: 'gaps',
 381            severity: 'warning',
 382            field: `gaps[${i}].timeToClose`,
 383            message: `Gap "${g.skill}" has vague timeToClose: "${g.timeToClose}". Must include a specific time unit (days/weeks/months).`,
 384            autoFixable: false,
 385          });
 386        }
 387      }
 388  
 389      // Resources array
 390      if (!g.resources || g.resources.length === 0) {
 391        issues.push({
 392          section: 'gaps',
 393          severity: 'warning',
 394          field: `gaps[${i}].resources`,
 395          message: `Gap "${g.skill}" has no resources.`,
 396          autoFixable: false,
 397        });
 398      } else if (g.resources.length > 5) {
 399        issues.push({
 400          section: 'gaps',
 401          severity: 'info',
 402          field: `gaps[${i}].resources`,
 403          message: `Gap "${g.skill}" has ${g.resources.length} resources (max recommended: 5).`,
 404          autoFixable: true,
 405          autoFixAction: 'Keep first 5 resources',
 406        });
 407      }
 408  
 409      // Check resource URLs against trusted domains
 410      if (g.resources) {
 411        g.resources.forEach((resource, j) => {
 412          const urlMatch = resource.match(/https?:\/\/([^/\s]+)/);
 413          if (urlMatch) {
 414            const domain = urlMatch[1].toLowerCase();
 415            const isTrusted = TRUSTED_RESOURCE_DOMAINS.some(
 416              trusted => domain === trusted || domain.endsWith('.' + trusted)
 417            );
 418            if (!isTrusted) {
 419              issues.push({
 420                section: 'gaps',
 421                severity: 'info',
 422                field: `gaps[${i}].resources[${j}]`,
 423                message: `Resource URL domain "${domain}" is not in the trusted domains list. May still be legitimate.`,
 424                autoFixable: false,
 425              });
 426            }
 427          }
 428        });
 429      }
 430    });
 431  
 432    return issues;
 433  }
 434  
 435  // ============================================================================
 436  // ActionPlan validation
 437  // ============================================================================
 438  
 439  export function validateActionPlan(actionPlan: ActionPlan): ValidationIssue[] {
 440    const issues: ValidationIssue[] = [];
 441    const validPriorities: string[] = ['critical', 'high', 'medium'];
 442    const sections = ['thirtyDays', 'ninetyDays', 'twelveMonths'] as const;
 443  
 444    for (const section of sections) {
 445      const items = actionPlan[section];
 446      if (!items || items.length === 0) {
 447        issues.push({
 448          section: 'actionPlan',
 449          severity: 'error',
 450          field: `actionPlan.${section}`,
 451          message: `Action plan ${section} section is empty. At least 1 item required.`,
 452          autoFixable: false,
 453        });
 454        continue;
 455      }
 456  
 457      if (items.length > 7) {
 458        issues.push({
 459          section: 'actionPlan',
 460          severity: 'warning',
 461          field: `actionPlan.${section}`,
 462          message: `Action plan ${section} has ${items.length} items (max 7).`,
 463          autoFixable: true,
 464          autoFixAction: 'Keep first 7 items',
 465        });
 466      }
 467  
 468      items.forEach((item, i) => {
 469        // Required fields
 470        const requiredFields = ['action', 'timeEstimate', 'resource', 'expectedImpact'] as const;
 471        for (const field of requiredFields) {
 472          if (!item[field] || item[field].trim().length === 0) {
 473            issues.push({
 474              section: 'actionPlan',
 475              severity: 'warning',
 476              field: `actionPlan.${section}[${i}].${field}`,
 477              message: `Action plan item "${item.action || `index ${i}`}" in ${section} has empty ${field}.`,
 478              autoFixable: false,
 479            });
 480          }
 481        }
 482  
 483        // Priority validation
 484        if (!validPriorities.includes(item.priority)) {
 485          issues.push({
 486            section: 'actionPlan',
 487            severity: 'warning',
 488            field: `actionPlan.${section}[${i}].priority`,
 489            message: `Invalid priority "${item.priority}" in ${section}.`,
 490            autoFixable: true,
 491            autoFixAction: 'Default to "medium"',
 492          });
 493        }
 494  
 495        // thirtyDays items shouldn't have long time estimates
 496        if (section === 'thirtyDays' && item.timeEstimate) {
 497          const longPatterns = /\b(6\s*month|1\s*year|12\s*month|9\s*month)/i;
 498          if (longPatterns.test(item.timeEstimate)) {
 499            issues.push({
 500              section: 'actionPlan',
 501              severity: 'warning',
 502              field: `actionPlan.thirtyDays[${i}].timeEstimate`,
 503              message: `30-day action item has a long time estimate: "${item.timeEstimate}". Expected short-term items.`,
 504              autoFixable: false,
 505            });
 506          }
 507        }
 508      });
 509    }
 510  
 511    // Cross-check: adjacent time horizons should not duplicate
 512    const checkDuplication = (
 513      longItems: typeof actionPlan.thirtyDays,
 514      shortItems: typeof actionPlan.thirtyDays,
 515      longLabel: string,
 516      shortLabel: string
 517    ) => {
 518      for (const longItem of longItems) {
 519        for (const shortItem of shortItems) {
 520          const similarity = textSimilarity(longItem.action, shortItem.action);
 521          if (similarity > 0.8) {
 522            issues.push({
 523              section: 'actionPlan',
 524              severity: 'warning',
 525              field: `actionPlan.${longLabel}`,
 526              message: `${longLabel} item "${longItem.action.slice(0, 60)}..." is very similar to ${shortLabel} item "${shortItem.action.slice(0, 60)}...".`,
 527              autoFixable: false,
 528            });
 529          }
 530        }
 531      }
 532    };
 533  
 534    if (actionPlan.thirtyDays && actionPlan.ninetyDays) {
 535      checkDuplication(actionPlan.ninetyDays, actionPlan.thirtyDays, 'ninetyDays', 'thirtyDays');
 536    }
 537    if (actionPlan.ninetyDays && actionPlan.twelveMonths) {
 538      checkDuplication(actionPlan.twelveMonths, actionPlan.ninetyDays, 'twelveMonths', 'ninetyDays');
 539    }
 540    if (actionPlan.thirtyDays && actionPlan.twelveMonths) {
 541      checkDuplication(actionPlan.twelveMonths, actionPlan.thirtyDays, 'twelveMonths', 'thirtyDays');
 542    }
 543  
 544    return issues;
 545  }
 546  
 547  // ============================================================================
 548  // SalaryAnalysis validation
 549  // ============================================================================
 550  
 551  export function validateSalaryAnalysis(
 552    salary: SalaryAnalysis,
 553    country: string,
 554    currentRole: string,
 555    targetRole: string,
 556    salaryLookupFn?: (role: string, country: string, level?: 'junior' | 'mid' | 'senior' | 'lead') => SalaryLookupResult | null
 557  ): ValidationIssue[] {
 558    const issues: ValidationIssue[] = [];
 559  
 560    // Validate current role market
 561    issues.push(...validateSalaryRange(salary.currentRoleMarket, 'salaryAnalysis', 'currentRoleMarket'));
 562  
 563    // Validate target role market
 564    issues.push(...validateSalaryRange(salary.targetRoleMarket, 'salaryAnalysis', 'targetRoleMarket'));
 565  
 566    // growthPotential
 567    if (!salary.growthPotential || salary.growthPotential.trim().length === 0) {
 568      issues.push({
 569        section: 'salaryAnalysis',
 570        severity: 'warning',
 571        field: 'salaryAnalysis.growthPotential',
 572        message: 'Growth potential is empty.',
 573        autoFixable: false,
 574      });
 575    }
 576  
 577    // negotiationTips
 578    if (!salary.negotiationTips || salary.negotiationTips.length === 0) {
 579      issues.push({
 580        section: 'salaryAnalysis',
 581        severity: 'warning',
 582        field: 'salaryAnalysis.negotiationTips',
 583        message: 'No negotiation tips provided.',
 584        autoFixable: false,
 585      });
 586    } else if (salary.negotiationTips.length > 5) {
 587      issues.push({
 588        section: 'salaryAnalysis',
 589        severity: 'info',
 590        field: 'salaryAnalysis.negotiationTips',
 591        message: `Too many negotiation tips (${salary.negotiationTips.length}, max 5).`,
 592        autoFixable: true,
 593        autoFixAction: 'Keep first 5 tips',
 594      });
 595    }
 596  
 597    // Cross-reference with salary lookup data
 598    if (salaryLookupFn) {
 599      const targetLookup = salaryLookupFn(targetRole, country);
 600      if (targetLookup) {
 601        const claudeMid = salary.targetRoleMarket.mid;
 602        const dataMid = targetLookup.mid;
 603        if (dataMid > 0 && claudeMid > 0) {
 604          const diff = Math.abs(claudeMid - dataMid) / dataMid;
 605          if (diff > 0.4) {
 606            issues.push({
 607              section: 'salaryAnalysis',
 608              severity: 'warning',
 609              field: 'salaryAnalysis.targetRoleMarket.mid',
 610              message: `Salary estimate for ${targetRole} in ${country} differs significantly from ${targetLookup.sourceLabel} data. Data shows median of ${targetLookup.currency} ${dataMid.toLocaleString()}, Claude estimated ${salary.targetRoleMarket.currency} ${claudeMid.toLocaleString()}.`,
 611              autoFixable: false,
 612            });
 613          }
 614        }
 615      }
 616  
 617      const currentLookup = salaryLookupFn(currentRole, country);
 618      if (currentLookup) {
 619        const claudeMid = salary.currentRoleMarket.mid;
 620        const dataMid = currentLookup.mid;
 621        if (dataMid > 0 && claudeMid > 0) {
 622          const diff = Math.abs(claudeMid - dataMid) / dataMid;
 623          if (diff > 0.4) {
 624            issues.push({
 625              section: 'salaryAnalysis',
 626              severity: 'warning',
 627              field: 'salaryAnalysis.currentRoleMarket.mid',
 628              message: `Salary estimate for ${currentRole} in ${country} differs significantly from ${currentLookup.sourceLabel} data. Data shows median of ${currentLookup.currency} ${dataMid.toLocaleString()}, Claude estimated ${salary.currentRoleMarket.currency} ${claudeMid.toLocaleString()}.`,
 629              autoFixable: false,
 630            });
 631          }
 632        }
 633      }
 634    }
 635  
 636    return issues;
 637  }
 638  
 639  // Approximate conversion rates to USD for absurdity checking
 640  const USD_APPROX_RATES: Record<string, number> = {
 641    'USD': 1, 'EUR': 1.08, 'GBP': 1.27, 'CHF': 1.12,
 642    'CAD': 0.74, 'AUD': 0.65, 'SEK': 0.095, 'DKK': 0.145,
 643    'NOK': 0.092, 'PLN': 0.25, 'CZK': 0.043, 'HUF': 0.0027,
 644    'RON': 0.22, 'INR': 0.012, 'BRL': 0.19, 'SGD': 0.74,
 645    'JPY': 0.0067,
 646  };
 647  
 648  const SALARY_ABSURDITY_BOUNDS = {
 649    junior: { min: 5000, max: 250000 },
 650    mid: { min: 10000, max: 400000 },
 651    senior: { min: 15000, max: 600000 },
 652  };
 653  
 654  function validateSalaryRange(
 655    range: { low: number; mid: number; high: number; currency: string },
 656    section: string,
 657    fieldPrefix: string
 658  ): ValidationIssue[] {
 659    const issues: ValidationIssue[] = [];
 660  
 661    // All must be positive integers
 662    for (const key of ['low', 'mid', 'high'] as const) {
 663      const val = range[key];
 664      if (typeof val !== 'number' || val <= 0) {
 665        issues.push({
 666          section,
 667          severity: 'error',
 668          field: `${section}.${fieldPrefix}.${key}`,
 669          message: `Salary ${fieldPrefix}.${key} must be a positive number (got ${val}).`,
 670          autoFixable: false,
 671        });
 672      }
 673    }
 674  
 675    // low < mid < high
 676    if (range.low > 0 && range.mid > 0 && range.high > 0) {
 677      if (!(range.low <= range.mid && range.mid <= range.high)) {
 678        issues.push({
 679          section,
 680          severity: 'error',
 681          field: `${section}.${fieldPrefix}`,
 682          message: `Salary range is not ordered: low=${range.low}, mid=${range.mid}, high=${range.high}. Expected low ≤ mid ≤ high.`,
 683          autoFixable: true,
 684          autoFixAction: 'Sort ascending',
 685        });
 686      }
 687    }
 688  
 689    // Currency check
 690    if (!range.currency || range.currency.trim().length === 0) {
 691      issues.push({
 692        section,
 693        severity: 'error',
 694        field: `${section}.${fieldPrefix}.currency`,
 695        message: `Salary ${fieldPrefix} has empty currency.`,
 696        autoFixable: false,
 697      });
 698    }
 699  
 700    // Salary absurdity bounds — convert mid to USD-equivalent and check
 701    if (range.mid > 0 && range.currency) {
 702      const rate = USD_APPROX_RATES[range.currency] || 1;
 703      const midUSD = range.mid * rate;
 704      // Use "mid" tier bounds as a reasonable default
 705      const bounds = SALARY_ABSURDITY_BOUNDS.mid;
 706      if (midUSD < bounds.min) {
 707        issues.push({
 708          section,
 709          severity: 'warning',
 710          field: `${section}.${fieldPrefix}.mid`,
 711          message: `Salary mid ${range.currency} ${range.mid} (~$${Math.round(midUSD)} USD) seems unrealistically low for a professional role.`,
 712          autoFixable: false,
 713        });
 714      } else if (midUSD > bounds.max) {
 715        issues.push({
 716          section,
 717          severity: 'warning',
 718          field: `${section}.${fieldPrefix}.mid`,
 719          message: `Salary mid ${range.currency} ${range.mid} (~$${Math.round(midUSD)} USD) seems unrealistically high. Verify the currency and scale.`,
 720          autoFixable: false,
 721        });
 722      }
 723    }
 724  
 725    return issues;
 726  }
 727  
 728  // ============================================================================
 729  // RoleRecommendations validation
 730  // ============================================================================
 731  
 732  export function validateRoleRecommendations(roles: RoleRecommendation[]): ValidationIssue[] {
 733    const issues: ValidationIssue[] = [];
 734  
 735    if (!roles || roles.length === 0) {
 736      issues.push({
 737        section: 'roleRecommendations',
 738        severity: 'error',
 739        field: 'roleRecommendations',
 740        message: 'No role recommendations provided. At least 1 required.',
 741        autoFixable: false,
 742      });
 743      return issues;
 744    }
 745  
 746    if (roles.length > 5) {
 747      issues.push({
 748        section: 'roleRecommendations',
 749        severity: 'warning',
 750        field: 'roleRecommendations',
 751        message: `Too many role recommendations (${roles.length}, max 5).`,
 752        autoFixable: true,
 753        autoFixAction: 'Keep top 5 by fitScore',
 754      });
 755    }
 756  
 757    roles.forEach((role, i) => {
 758      // fitScore 1-10
 759      if (typeof role.fitScore !== 'number' || role.fitScore < 1 || role.fitScore > 10) {
 760        issues.push({
 761          section: 'roleRecommendations',
 762          severity: 'warning',
 763          field: `roleRecommendations[${i}].fitScore`,
 764          message: `Role "${role.title}" has invalid fitScore: ${role.fitScore}.`,
 765          autoFixable: true,
 766          autoFixAction: 'Clamp to 1-10',
 767        });
 768      }
 769  
 770      // Salary range validation
 771      if (role.salaryRange) {
 772        const sr = role.salaryRange;
 773        if (sr.low > 0 && sr.mid > 0 && sr.high > 0) {
 774          if (!(sr.low <= sr.mid && sr.mid <= sr.high)) {
 775            issues.push({
 776              section: 'roleRecommendations',
 777              severity: 'warning',
 778              field: `roleRecommendations[${i}].salaryRange`,
 779              message: `Role "${role.title}" salary range not ordered: ${sr.low}/${sr.mid}/${sr.high}.`,
 780              autoFixable: true,
 781              autoFixAction: 'Sort ascending',
 782            });
 783          }
 784        }
 785        for (const key of ['low', 'mid', 'high'] as const) {
 786          if (typeof sr[key] !== 'number' || sr[key] <= 0) {
 787            issues.push({
 788              section: 'roleRecommendations',
 789              severity: 'warning',
 790              field: `roleRecommendations[${i}].salaryRange.${key}`,
 791              message: `Role "${role.title}" salary ${key} must be positive.`,
 792              autoFixable: false,
 793            });
 794          }
 795        }
 796      }
 797  
 798      // Required fields
 799      if (!role.title || role.title.trim().length === 0) {
 800        issues.push({
 801          section: 'roleRecommendations',
 802          severity: 'error',
 803          field: `roleRecommendations[${i}].title`,
 804          message: `Role recommendation at index ${i} has empty title.`,
 805          autoFixable: false,
 806        });
 807      }
 808      if (!role.reasoning || role.reasoning.trim().length === 0) {
 809        issues.push({
 810          section: 'roleRecommendations',
 811          severity: 'warning',
 812          field: `roleRecommendations[${i}].reasoning`,
 813          message: `Role "${role.title}" has empty reasoning.`,
 814          autoFixable: false,
 815        });
 816      }
 817      if (!role.timeToReady || role.timeToReady.trim().length === 0) {
 818        issues.push({
 819          section: 'roleRecommendations',
 820          severity: 'warning',
 821          field: `roleRecommendations[${i}].timeToReady`,
 822          message: `Role "${role.title}" has empty timeToReady.`,
 823          autoFixable: false,
 824        });
 825      }
 826  
 827      // exampleCompanies
 828      if (role.exampleCompanies && role.exampleCompanies.length > 10) {
 829        issues.push({
 830          section: 'roleRecommendations',
 831          severity: 'info',
 832          field: `roleRecommendations[${i}].exampleCompanies`,
 833          message: `Role "${role.title}" has ${role.exampleCompanies.length} example companies (max 10).`,
 834          autoFixable: true,
 835          autoFixAction: 'Keep first 10 companies',
 836        });
 837      }
 838    });
 839  
 840    return issues;
 841  }
 842  
 843  // ============================================================================
 844  // JobMatch validation
 845  // ============================================================================
 846  
 847  export function validateJobMatch(jobMatch: JobMatch): ValidationIssue[] {
 848    const issues: ValidationIssue[] = [];
 849  
 850    // matchScore 0-100
 851    if (typeof jobMatch.matchScore !== 'number' || jobMatch.matchScore < 0 || jobMatch.matchScore > 100) {
 852      issues.push({
 853        section: 'jobMatch',
 854        severity: 'warning',
 855        field: 'jobMatch.matchScore',
 856        message: `Match score ${jobMatch.matchScore} is outside 0-100 range.`,
 857        autoFixable: true,
 858        autoFixAction: 'Clamp to 0-100',
 859      });
 860    }
 861  
 862    // matchingSkills and missingSkills should not overlap
 863    if (jobMatch.matchingSkills && jobMatch.missingSkills) {
 864      const matchingSet = new Set(jobMatch.matchingSkills.map(s => s.toLowerCase()));
 865      jobMatch.missingSkills.forEach((skill, i) => {
 866        if (matchingSet.has(skill.skill.toLowerCase())) {
 867          issues.push({
 868            section: 'jobMatch',
 869            severity: 'error',
 870            field: `jobMatch.missingSkills[${i}]`,
 871            message: `Skill "${skill.skill}" appears in both matchingSkills and missingSkills.`,
 872            autoFixable: true,
 873            autoFixAction: 'Remove from matchingSkills',
 874          });
 875        }
 876      });
 877    }
 878  
 879    // CV suggestions validation
 880    if (jobMatch.cvSuggestions) {
 881      jobMatch.cvSuggestions.forEach((sug, i) => {
 882        const requiredFields = ['section', 'current', 'suggested', 'reasoning'] as const;
 883        for (const field of requiredFields) {
 884          if (!sug[field] || sug[field].trim().length === 0) {
 885            issues.push({
 886              section: 'jobMatch',
 887              severity: 'warning',
 888              field: `jobMatch.cvSuggestions[${i}].${field}`,
 889              message: `CV suggestion at index ${i} has empty ${field}.`,
 890              autoFixable: false,
 891            });
 892          }
 893        }
 894      });
 895    }
 896  
 897    return issues;
 898  }
 899  
 900  // ============================================================================
 901  // Auto-fix function
 902  // ============================================================================
 903  
 904  export function autoFixResult(result: AnalysisResult, issues: ValidationIssue[]): { result: AnalysisResult; descriptions: string[] } {
 905    // Deep clone
 906    const fixed: AnalysisResult = JSON.parse(JSON.stringify(result));
 907    const descriptions: string[] = [];
 908  
 909    for (const issue of issues) {
 910      if (!issue.autoFixable) continue;
 911  
 912      // FitScore fixes
 913      if (issue.field === 'fitScore.score' && issue.autoFixAction?.startsWith('Cap fitScore at')) {
 914        const capMatch = issue.autoFixAction.match(/Cap fitScore at (\d+)/);
 915        if (capMatch) {
 916          const cap = parseInt(capMatch[1]);
 917          if (fixed.fitScore.score > cap) {
 918            descriptions.push(`Capped fitScore from ${fixed.fitScore.score} to ${cap} due to critical gaps`);
 919            fixed.fitScore.score = cap;
 920            fixed.fitScore.label = deriveFitLabel(cap);
 921          }
 922        }
 923      } else if (issue.field === 'fitScore.score') {
 924        descriptions.push(`Clamped fitScore from ${fixed.fitScore.score} to 1-10 range`);
 925        fixed.fitScore.score = Math.max(1, Math.min(10, Math.round(fixed.fitScore.score)));
 926      }
 927      if (issue.field === 'fitScore.label') {
 928        fixed.fitScore.label = deriveFitLabel(fixed.fitScore.score);
 929      }
 930      if (issue.field === 'fitScore.summary' && issue.autoFixAction === 'Truncate at sentence boundary') {
 931        fixed.fitScore.summary = truncateAtSentence(fixed.fitScore.summary, 500);
 932      }
 933  
 934      // Strengths fixes
 935      if (issue.field === 'strengths' && issue.autoFixAction === 'Keep top 8 strengths') {
 936        fixed.strengths = fixed.strengths.slice(0, 8);
 937      }
 938      if (issue.field?.match(/^strengths\[\d+\]\.tier$/)) {
 939        const idx = parseInt(issue.field.match(/\[(\d+)\]/)![1]);
 940        if (fixed.strengths[idx]) {
 941          fixed.strengths[idx].tier = 'supporting';
 942        }
 943      }
 944      if (issue.field?.match(/^strengths\[\d+\]\.title$/) && issue.autoFixAction === 'Remove duplicate') {
 945        // Remove duplicates by keeping first occurrence
 946        const seen = new Set<string>();
 947        fixed.strengths = fixed.strengths.filter(s => {
 948          const key = s.title.toLowerCase();
 949          if (seen.has(key)) return false;
 950          seen.add(key);
 951          return true;
 952        });
 953      }
 954  
 955      // Gaps fixes
 956      if (issue.field === 'gaps' && issue.autoFixAction === 'Keep top 10 by severity') {
 957        const severityOrder = { critical: 0, moderate: 1, minor: 2 };
 958        fixed.gaps.sort((a, b) => (severityOrder[a.severity] || 1) - (severityOrder[b.severity] || 1));
 959        fixed.gaps = fixed.gaps.slice(0, 10);
 960      }
 961      if (issue.field?.match(/^gaps\[\d+\]\.severity$/)) {
 962        const idx = parseInt(issue.field.match(/\[(\d+)\]/)![1]);
 963        if (fixed.gaps[idx]) {
 964          fixed.gaps[idx].severity = 'moderate';
 965        }
 966      }
 967      if (issue.field?.match(/^gaps\[\d+\]\.resources$/) && issue.autoFixAction === 'Keep first 5 resources') {
 968        const idx = parseInt(issue.field.match(/\[(\d+)\]/)![1]);
 969        if (fixed.gaps[idx]) {
 970          fixed.gaps[idx].resources = fixed.gaps[idx].resources.slice(0, 5);
 971        }
 972      }
 973  
 974      // ActionPlan fixes
 975      if (issue.field?.match(/^actionPlan\.\w+$/) && issue.autoFixAction === 'Keep first 7 items') {
 976        const section = issue.field.split('.')[1] as keyof typeof fixed.actionPlan;
 977        if (fixed.actionPlan[section]) {
 978          (fixed.actionPlan[section] as typeof fixed.actionPlan.thirtyDays) =
 979            fixed.actionPlan[section].slice(0, 7);
 980        }
 981      }
 982      if (issue.field?.match(/^actionPlan\.\w+\[\d+\]\.priority$/)) {
 983        const match = issue.field.match(/actionPlan\.(\w+)\[(\d+)\]/);
 984        if (match) {
 985          const section = match[1] as keyof typeof fixed.actionPlan;
 986          const idx = parseInt(match[2]);
 987          if (fixed.actionPlan[section]?.[idx]) {
 988            fixed.actionPlan[section][idx].priority = 'medium';
 989          }
 990        }
 991      }
 992  
 993      // Salary fixes
 994      if (issue.field?.includes('.currentRoleMarket') && issue.autoFixAction === 'Sort ascending') {
 995        const vals = [fixed.salaryAnalysis.currentRoleMarket.low, fixed.salaryAnalysis.currentRoleMarket.mid, fixed.salaryAnalysis.currentRoleMarket.high].sort((a, b) => a - b);
 996        fixed.salaryAnalysis.currentRoleMarket.low = vals[0];
 997        fixed.salaryAnalysis.currentRoleMarket.mid = vals[1];
 998        fixed.salaryAnalysis.currentRoleMarket.high = vals[2];
 999      }
1000      if (issue.field?.includes('.targetRoleMarket') && issue.autoFixAction === 'Sort ascending') {
1001        const vals = [fixed.salaryAnalysis.targetRoleMarket.low, fixed.salaryAnalysis.targetRoleMarket.mid, fixed.salaryAnalysis.targetRoleMarket.high].sort((a, b) => a - b);
1002        fixed.salaryAnalysis.targetRoleMarket.low = vals[0];
1003        fixed.salaryAnalysis.targetRoleMarket.mid = vals[1];
1004        fixed.salaryAnalysis.targetRoleMarket.high = vals[2];
1005      }
1006      if (issue.field === 'salaryAnalysis.negotiationTips' && issue.autoFixAction === 'Keep first 5 tips') {
1007        fixed.salaryAnalysis.negotiationTips = fixed.salaryAnalysis.negotiationTips.slice(0, 5);
1008      }
1009  
1010      // RoleRecommendations fixes
1011      if (issue.field === 'roleRecommendations' && issue.autoFixAction === 'Keep top 5 by fitScore') {
1012        fixed.roleRecommendations.sort((a, b) => b.fitScore - a.fitScore);
1013        fixed.roleRecommendations = fixed.roleRecommendations.slice(0, 5);
1014      }
1015      if (issue.field?.match(/^roleRecommendations\[\d+\]\.fitScore$/)) {
1016        const idx = parseInt(issue.field.match(/\[(\d+)\]/)![1]);
1017        if (fixed.roleRecommendations[idx]) {
1018          fixed.roleRecommendations[idx].fitScore = Math.max(1, Math.min(10, Math.round(fixed.roleRecommendations[idx].fitScore)));
1019        }
1020      }
1021      if (issue.field?.match(/^roleRecommendations\[\d+\]\.salaryRange$/) && issue.autoFixAction === 'Sort ascending') {
1022        const idx = parseInt(issue.field.match(/\[(\d+)\]/)![1]);
1023        if (fixed.roleRecommendations[idx]?.salaryRange) {
1024          const sr = fixed.roleRecommendations[idx].salaryRange;
1025          const vals = [sr.low, sr.mid, sr.high].sort((a, b) => a - b);
1026          sr.low = vals[0];
1027          sr.mid = vals[1];
1028          sr.high = vals[2];
1029        }
1030      }
1031      if (issue.field?.match(/^roleRecommendations\[\d+\]\.exampleCompanies$/) && issue.autoFixAction === 'Keep first 10 companies') {
1032        const idx = parseInt(issue.field.match(/\[(\d+)\]/)![1]);
1033        if (fixed.roleRecommendations[idx]) {
1034          fixed.roleRecommendations[idx].exampleCompanies = fixed.roleRecommendations[idx].exampleCompanies.slice(0, 10);
1035        }
1036      }
1037  
1038      // JobMatch fixes
1039      if (issue.field === 'jobMatch.matchScore') {
1040        if (fixed.jobMatch) {
1041          fixed.jobMatch.matchScore = Math.max(0, Math.min(100, Math.round(fixed.jobMatch.matchScore)));
1042        }
1043      }
1044      if (issue.field?.match(/^jobMatch\.missingSkills/) && issue.autoFixAction === 'Remove from matchingSkills') {
1045        if (fixed.jobMatch) {
1046          const missingSet = new Set(fixed.jobMatch.missingSkills.map(s => s.skill.toLowerCase()));
1047          fixed.jobMatch.matchingSkills = fixed.jobMatch.matchingSkills.filter(
1048            s => !missingSet.has(s.toLowerCase())
1049          );
1050        }
1051      }
1052    }
1053  
1054    return { result: fixed, descriptions };
1055  }
1056  
1057  // ============================================================================
1058  // Translation validation
1059  // ============================================================================
1060  
1061  export function validateTranslation(
1062    original: AnalysisResult,
1063    translated: AnalysisResult
1064  ): { valid: boolean; mismatches: string[] } {
1065    const mismatches: string[] = [];
1066  
1067    // fitScore must be preserved
1068    if (translated.fitScore?.score !== original.fitScore.score) {
1069      mismatches.push(
1070        `fitScore.score changed: ${original.fitScore.score} → ${translated.fitScore?.score}`
1071      );
1072    }
1073  
1074    // Array lengths must match
1075    if ((translated.strengths?.length ?? 0) !== original.strengths.length) {
1076      mismatches.push(
1077        `strengths count changed: ${original.strengths.length} → ${translated.strengths?.length ?? 0}`
1078      );
1079    }
1080    if ((translated.gaps?.length ?? 0) !== original.gaps.length) {
1081      mismatches.push(
1082        `gaps count changed: ${original.gaps.length} → ${translated.gaps?.length ?? 0}`
1083      );
1084    }
1085    if ((translated.roleRecommendations?.length ?? 0) !== original.roleRecommendations.length) {
1086      mismatches.push(
1087        `roleRecommendations count changed: ${original.roleRecommendations.length} → ${translated.roleRecommendations?.length ?? 0}`
1088      );
1089    }
1090  
1091    // Action plan section lengths
1092    const planSections = ['thirtyDays', 'ninetyDays', 'twelveMonths'] as const;
1093    for (const section of planSections) {
1094      const origLen = original.actionPlan?.[section]?.length ?? 0;
1095      const transLen = translated.actionPlan?.[section]?.length ?? 0;
1096      if (transLen !== origLen) {
1097        mismatches.push(
1098          `actionPlan.${section} count changed: ${origLen} → ${transLen}`
1099        );
1100      }
1101    }
1102  
1103    // Salary mid values must be preserved
1104    if (translated.salaryAnalysis?.currentRoleMarket?.mid !== original.salaryAnalysis.currentRoleMarket.mid) {
1105      mismatches.push(
1106        `salaryAnalysis.currentRoleMarket.mid changed: ${original.salaryAnalysis.currentRoleMarket.mid} → ${translated.salaryAnalysis?.currentRoleMarket?.mid}`
1107      );
1108    }
1109    if (translated.salaryAnalysis?.targetRoleMarket?.mid !== original.salaryAnalysis.targetRoleMarket.mid) {
1110      mismatches.push(
1111        `salaryAnalysis.targetRoleMarket.mid changed: ${original.salaryAnalysis.targetRoleMarket.mid} → ${translated.salaryAnalysis?.targetRoleMarket?.mid}`
1112      );
1113    }
1114  
1115    return { valid: mismatches.length === 0, mismatches };
1116  }
1117  
1118  // ============================================================================
1119  // Helpers
1120  // ============================================================================
1121  
1122  function deriveFitLabel(score: number): FitScore['label'] {
1123    if (score >= 8) return 'Strong Fit';
1124    if (score >= 6) return 'Moderate Fit';
1125    if (score >= 4) return 'Stretch';
1126    return 'Significant Gap';
1127  }
1128  
1129  function truncateAtSentence(text: string, maxLen: number): string {
1130    if (text.length <= maxLen) return text;
1131    const truncated = text.slice(0, maxLen);
1132    const lastPeriod = truncated.lastIndexOf('.');
1133    if (lastPeriod > maxLen * 0.5) {
1134      return truncated.slice(0, lastPeriod + 1);
1135    }
1136    return truncated.trim() + '...';
1137  }
1138  
1139  /** Simple word-based Jaccard similarity */
1140  function textSimilarity(a: string, b: string): number {
1141    const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 2));
1142    const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 2));
1143    if (wordsA.size === 0 || wordsB.size === 0) return 0;
1144    let intersection = 0;
1145    for (const w of Array.from(wordsA)) {
1146      if (wordsB.has(w)) intersection++;
1147    }
1148    const union = wordsA.size + wordsB.size - intersection;
1149    return union > 0 ? intersection / union : 0;
1150  }
1151