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