import crypto from "crypto"; import { manufacturingPack } from "../domain/manufacturingPack"; import { includesAlias, lineSpans, normalizeText, overlapRatio, sentenceSpans, slugify, tokenize, unique, } from "./normalization"; import { lintJobDescription } from "./jdLinter"; import { detectTextLanguage, type TextLanguage } from "./language"; import type { AnalysisMeta, CandidateRecommendations, DomainDetection, Evidence, EvidenceMapRow, FitAnalysis, JDQualityWarning, MatchState, RecruiterRecommendations, Requirement, RequirementMatch, RequirementType, ScoringBreakdown, SupportLevel, } from "./types"; const GENERIC_TOKENS = new Set([ "required", "minimum", "least", "looking", "seeking", "company", "position", "must", "plus", "high", "strong", "individual_contributor", "role_scope", "לפחות", "נדרשת", "מחפשת", "מובילה", "יצרנית", "מרכזיים", "נרחב", "חובה", "ברמה", "כמהנדס", "הבנה" ]); const MUST_HAVE_PATTERNS = [ /\bmust\b/i, /\brequired\b/i, /\bmandatory\b/i, /\bminimum\b/i, /חובה/u, /נדרש/u, /נדרשת/u, /ניסיון של/u ]; const NICE_TO_HAVE_PATTERNS = [/\bpreferred\b/i, /\badvantage\b/i, /\bnice to have\b/i, /יתרון/u]; const LEADERSHIP_SCOPE_PATTERNS = /lead|head|manager|director|management|מנהל|הובלה|סמנכ/iu; const YEARS_PATTERNS = /\d+\s*(years|year|שנים|שנה)/i; const REQUIRED_CONTEXT_PATTERNS = /must|required|mandatory|minimum|experience with|knowledge of|hands[- ]on|חובה|נדרש|נדרשת|ידע ב|ידע וניסיון|ניסיון ב|ניסיון עם/u; const ROLE_HIRING_MARKERS = /looking|seeking|hiring|required|role|position|job|דרוש|דרושה|דרוש\.ה|מחפש|מחפשת|משרה|תפקיד/iu; const ROLE_LEADERSHIP_MARKERS = /lead|head|manager|director|owner|מנהל|מנהלת|מוביל|מובילה|הובלת|ראש|אחריות/iu; const ROLE_TECHNICAL_MARKERS = /technical|engineering|engineer|system|project|program|manufacturing|process|mechanical|electrical|software|hardware|טכני|הנדסי|מהנדס|מערכת|פרויקט|תהליך|ייצור|מכאנ|אלקטרו|תוכנה|חומרה/iu; function stableHash(...parts: string[]): string { return crypto.createHash("sha256").update(parts.join("|")).digest("hex"); } function requirementTypeLabel(type: RequirementType): string { switch (type) { case "role_title": return "Role"; case "seniority": return "Seniority"; case "education_background": case "degree": return "Education"; case "hard_skill": case "methodology": return "Hard Skills"; case "tool_system": return "Tools / Systems"; case "leadership_responsibility": return "Leadership"; case "manufacturing_domain": case "industry_context": return "Manufacturing / Domain"; case "must_have_requirement": return "Must-Haves"; case "language_requirement": return "Language"; case "years_experience": return "Experience"; default: return "Other"; } } function localizedRequirementLabel(label: string, language: TextLanguage): string { if (language !== "he" && language !== "mixed") return label; const normalized = normalizeText(label); const dictionary: Record = { "technical manager": "מנהל טכני", "factory engineering lead": "מוביל הנדסת מפעל", "leadership": "הובלה ניהולית", "manufacturing": "ייצור תעשייתי", "english": "אנגלית", "electronics engineering": "הנדסת אלקטרוניקה", "aeronautical engineering": "הנדסת אווירונאוטיקה", "software engineering": "הנדסת תוכנה", "systems architecture": "ארכיטקטורת מערכות", "requirements documentation": "כתיבת מסמכי דרישות" }; return dictionary[normalized] || label; } function localizedDomainLabel(value: string, language: TextLanguage): string { if (language !== "he" && language !== "mixed") return value.replace(/_/g, " "); const normalized = normalizeText(value); const dictionary: Record = { manufacturing: "ייצור תעשייתי", industrial: "תעשייה", engineering_leadership: "הובלה הנדסית", manufacturing_engineering: "הנדסת ייצור", operations: "תפעול" }; return dictionary[normalized] || value.replace(/_/g, " "); } function he(value: string): string { return value.replace(/\\u([0-9a-fA-F]{4})/g, (_, code) => String.fromCharCode(parseInt(code, 16))); } export function detectDomain(jobDescription: string): DomainDetection { const text = normalizeText(jobDescription); const titleHits = manufacturingPack.titlesCatalog.filter((entry) => entry.aliases.some((alias) => includesAlias(text, alias))); const toolHits = manufacturingPack.toolsCatalog.filter((entry) => entry.aliases.some((alias) => includesAlias(text, alias))); const domainHits = manufacturingPack.domainConcepts.filter((entry) => entry.aliases.some((alias) => includesAlias(text, alias))); const leadershipHits = manufacturingPack.leadershipSignals.filter((entry) => entry.aliases.some((alias) => includesAlias(text, alias))); const primaryDomain = domainHits.length ? "manufacturing" : "industrial"; const secondaryDomain = leadershipHits.length ? "operations" : domainHits.find((entry) => entry.canonical !== "Manufacturing")?.canonical || null; const roleFamily = titleHits[0]?.roleFamily || (leadershipHits.length ? "engineering_leadership" : "manufacturing_engineering"); const seniority = titleHits.find((entry) => entry.seniorityHint)?.seniorityHint || (titleHits.some((entry) => /manager|head|director|leadership/i.test(entry.roleFamily || "")) ? "leadership" : "individual_contributor"); const signalCount = titleHits.length + toolHits.length + domainHits.length + leadershipHits.length; return { primaryDomain, secondaryDomain, roleFamily, seniority, confidence: Math.max(0.58, Math.min(0.96, 0.58 + signalCount * 0.05)), signals: unique([ ...titleHits.map((entry) => entry.canonical), ...toolHits.map((entry) => entry.canonical), ...domainHits.map((entry) => entry.canonical), ...leadershipHits.map((entry) => entry.canonical) ]).slice(0, 12) }; } function lineImportance(line: string, mustHave: boolean): number { const base = mustHave ? 0.88 : 0.62; if (/lead|head|manager|director|מנהל|הובלה|מחלקת/u.test(line)) return Math.min(1, base + 0.08); if (/safety|quality|בטיחות|איכות/i.test(line)) return Math.min(1, base + 0.05); return base; } function strengthForLine(line: string, mustHave: boolean): number { let strength = mustHave ? 0.86 : 0.58; if (/must|required|mandatory|minimum|חובה|נדרש|נדרשת/u.test(line)) strength += 0.08; if (YEARS_PATTERNS.test(line)) strength += 0.05; return Math.min(1, strength); } function shouldEmitSeniorityRequirement(domain: DomainDetection, titleText: string, jobDescription: string): boolean { if (domain.seniority === "leadership") return true; return /senior|principal|staff|lead engineer|בכיר|בכירה/u.test(`${titleText} ${jobDescription}`); } function resolveRequirementMustHave( type: RequirementType, line: string, mustHaveHint: boolean, niceToHaveHint: boolean, domain: DomainDetection ): boolean { if (type === "role_title" || type === "years_experience") return true; if (niceToHaveHint && !mustHaveHint) return false; const leadershipScope = LEADERSHIP_SCOPE_PATTERNS.test(line); const hasRequiredContext = REQUIRED_CONTEXT_PATTERNS.test(line); switch (type) { case "seniority": return domain.seniority === "leadership" && leadershipScope; case "education_background": case "degree": case "tool_system": case "language_requirement": return mustHaveHint || hasRequiredContext; case "leadership_responsibility": return (mustHaveHint || hasRequiredContext) && leadershipScope; case "manufacturing_domain": case "methodology": case "hard_skill": case "industry_context": return mustHaveHint && hasRequiredContext; default: return mustHaveHint; } } function resolveRequirementConfidence(line: string, mustHave: boolean, niceToHave: boolean): number { if (mustHave) return 0.9; if (niceToHave) return 0.66; if (YEARS_PATTERNS.test(line)) return 0.84; if (REQUIRED_CONTEXT_PATTERNS.test(line)) return 0.8; return 0.74; } function addRequirement( requirements: Requirement[], { label, normalizedValue, type, subtype, sourceText, sourceSpanStart, sourceSpanEnd, mustHave, confidence, senioritySignal, domain }: Omit ) { const key = `${type}:${normalizeText(normalizedValue)}`; if (requirements.some((item) => `${item.type}:${normalizeText(item.normalizedValue)}` === key)) { return; } requirements.push({ id: slugify(`${type}-${normalizedValue}-${sourceSpanStart}`), label, normalizedValue, type, subtype, sourceText, sourceSpanStart, sourceSpanEnd, domain, importance: lineImportance(sourceText, mustHave), requirementStrength: strengthForLine(sourceText, mustHave), senioritySignal, mustHave, confidence }); } function pickBestAlias(text: string, aliases: string[]): string | null { const matches = aliases.filter((alias) => includesAlias(text, alias)); if (!matches.length) return null; return matches.sort((left, right) => right.length - left.length)[0]; } function scoreRoleTitleCandidate(text: string, alias: string | null): number { const tokenCount = tokenize(text).length; const aliasTokenCount = alias ? tokenize(alias).length : 0; let score = alias ? aliasTokenCount * 6 : 0; if (ROLE_HIRING_MARKERS.test(text)) score += 3; if (ROLE_LEADERSHIP_MARKERS.test(text)) score += 2; if (ROLE_TECHNICAL_MARKERS.test(text)) score += 2; if (tokenCount <= 10) score += 4; else if (tokenCount <= 18) score += 1; else score -= 4; if (text.length <= 96) score += 2; if (/[,:;]\s/.test(text) && tokenCount > 12) score -= 2; return score; } function extractRoleTitleRequirement(jobDescription: string, domain: DomainDetection) { const rawCandidates = [ ...lineSpans(jobDescription).slice(0, 10), ...sentenceSpans(jobDescription).slice(0, 6), ]; const candidates = rawCandidates.filter( (span, index, array) => array.findIndex((item) => item.start === span.start && item.end === span.end) === index, ); let best: | { span: { text: string; start: number; end: number }; entry: (typeof manufacturingPack.titlesCatalog)[number]; alias: string; score: number; } | null = null; for (const span of candidates) { for (const entry of manufacturingPack.titlesCatalog) { const alias = pickBestAlias(span.text, entry.aliases); if (!alias) continue; const score = scoreRoleTitleCandidate(span.text, alias); if (!best || score > best.score) { best = { span, entry, alias, score }; } } } if (best) { return { label: best.entry.canonical, normalizedValue: best.entry.canonical, type: "role_title" as const, subtype: best.entry.roleFamily || domain.roleFamily, sourceText: best.alias, sourceSpanStart: best.span.start, sourceSpanEnd: best.span.end, mustHave: true, confidence: best.score >= 10 ? 0.93 : 0.82, senioritySignal: best.entry.seniorityHint || domain.seniority, domain: domain.primaryDomain, }; } const fallback = lineSpans(jobDescription)[0]; if (!fallback) return null; return { label: domain.roleFamily.replace(/_/g, " "), normalizedValue: domain.roleFamily.replace(/_/g, " "), type: "role_title" as const, subtype: domain.roleFamily, sourceText: fallback.text.slice(0, 96), sourceSpanStart: fallback.start, sourceSpanEnd: fallback.end, mustHave: true, confidence: 0.62, senioritySignal: domain.seniority, domain: domain.primaryDomain, }; } export function extractRequirements(jobDescription: string, domain: DomainDetection): Requirement[] { const requirements: Requirement[] = []; const spans = lineSpans(jobDescription); const titleRequirement = extractRoleTitleRequirement(jobDescription, domain); const titleLine = titleRequirement ? { text: titleRequirement.sourceText, start: titleRequirement.sourceSpanStart, end: titleRequirement.sourceSpanEnd } : spans[0]; if (titleRequirement) { addRequirement(requirements, titleRequirement); } if (shouldEmitSeniorityRequirement(domain, titleLine?.text || "", jobDescription)) { addRequirement(requirements, { label: domain.seniority, normalizedValue: domain.seniority, type: "seniority", subtype: "role_scope", sourceText: titleLine?.text || jobDescription.slice(0, 80), sourceSpanStart: titleLine?.start || 0, sourceSpanEnd: titleLine?.end || Math.min(80, jobDescription.length), mustHave: true, confidence: domain.confidence, senioritySignal: domain.seniority, domain: domain.primaryDomain }); } for (const span of spans) { const mustHaveHint = MUST_HAVE_PATTERNS.some((pattern) => pattern.test(span.text)); const niceToHaveHint = NICE_TO_HAVE_PATTERNS.some((pattern) => pattern.test(span.text)); for (const entry of manufacturingPack.educationCatalog) { if (entry.aliases.some((alias) => includesAlias(span.text, alias))) { const mustHave = resolveRequirementMustHave("education_background", span.text, mustHaveHint, niceToHaveHint, domain); addRequirement(requirements, { label: entry.canonical, normalizedValue: entry.canonical, type: "education_background", subtype: entry.subtype || "degree", sourceText: span.text, sourceSpanStart: span.start, sourceSpanEnd: span.end, mustHave, confidence: resolveRequirementConfidence(span.text, mustHave, niceToHaveHint), senioritySignal: null, domain: domain.primaryDomain }); } } for (const entry of manufacturingPack.toolsCatalog) { if (entry.aliases.some((alias) => includesAlias(span.text, alias))) { const mustHave = resolveRequirementMustHave("tool_system", span.text, mustHaveHint, niceToHaveHint, domain); addRequirement(requirements, { label: entry.canonical, normalizedValue: entry.canonical, type: "tool_system", subtype: entry.subtype || "tool", sourceText: span.text, sourceSpanStart: span.start, sourceSpanEnd: span.end, mustHave, confidence: resolveRequirementConfidence(span.text, mustHave, niceToHaveHint), senioritySignal: null, domain: domain.primaryDomain }); } } for (const entry of manufacturingPack.methodsCatalog) { if (entry.aliases.some((alias) => includesAlias(span.text, alias))) { const mustHave = resolveRequirementMustHave("methodology", span.text, mustHaveHint, niceToHaveHint, domain); addRequirement(requirements, { label: entry.canonical, normalizedValue: entry.canonical, type: "methodology", subtype: entry.subtype || "method", sourceText: span.text, sourceSpanStart: span.start, sourceSpanEnd: span.end, mustHave, confidence: resolveRequirementConfidence(span.text, mustHave, niceToHaveHint), senioritySignal: null, domain: domain.primaryDomain }); } } for (const entry of manufacturingPack.domainConcepts) { if (entry.aliases.some((alias) => includesAlias(span.text, alias))) { const mustHave = resolveRequirementMustHave("manufacturing_domain", span.text, mustHaveHint, niceToHaveHint, domain); addRequirement(requirements, { label: entry.canonical, normalizedValue: entry.canonical, type: "manufacturing_domain", subtype: entry.subtype || "domain", sourceText: span.text, sourceSpanStart: span.start, sourceSpanEnd: span.end, mustHave, confidence: resolveRequirementConfidence(span.text, mustHave, niceToHaveHint), senioritySignal: null, domain: domain.primaryDomain }); } } for (const entry of manufacturingPack.leadershipSignals) { if (entry.aliases.some((alias) => includesAlias(span.text, alias))) { const mustHave = resolveRequirementMustHave("leadership_responsibility", span.text, mustHaveHint, niceToHaveHint, domain); addRequirement(requirements, { label: entry.canonical, normalizedValue: entry.canonical, type: "leadership_responsibility", subtype: entry.subtype || "leadership", sourceText: span.text, sourceSpanStart: span.start, sourceSpanEnd: span.end, mustHave, confidence: resolveRequirementConfidence(span.text, mustHave, niceToHaveHint), senioritySignal: domain.seniority, domain: domain.primaryDomain }); } } for (const entry of manufacturingPack.languageCatalog) { if (entry.aliases.some((alias) => includesAlias(span.text, alias))) { const mustHave = resolveRequirementMustHave("language_requirement", span.text, mustHaveHint, niceToHaveHint, domain); addRequirement(requirements, { label: entry.canonical, normalizedValue: entry.canonical, type: "language_requirement", subtype: "language", sourceText: span.text, sourceSpanStart: span.start, sourceSpanEnd: span.end, mustHave, confidence: resolveRequirementConfidence(span.text, mustHave, niceToHaveHint), senioritySignal: null, domain: domain.primaryDomain }); } } const yearsMatch = span.text.match(/(\d+)\s*(years|year|שנים|שנה)/i); if (yearsMatch) { addRequirement(requirements, { label: `${yearsMatch[1]} years experience`, normalizedValue: yearsMatch[1], type: "years_experience", subtype: "experience", sourceText: span.text, sourceSpanStart: span.start, sourceSpanEnd: span.end, mustHave: true, confidence: 0.94, senioritySignal: null, domain: domain.primaryDomain }); } } return requirements; } function aliasUniverse(requirement: Requirement): string[] { const collections = [ ...manufacturingPack.titlesCatalog, ...manufacturingPack.toolsCatalog, ...manufacturingPack.methodsCatalog, ...manufacturingPack.domainConcepts, ...manufacturingPack.leadershipSignals, ...manufacturingPack.educationCatalog, ...manufacturingPack.languageCatalog ]; const matched = collections.find((entry) => entry.canonical === requirement.normalizedValue || entry.canonical === requirement.label); return unique([requirement.label, requirement.normalizedValue, ...(matched?.aliases || [])]).filter(Boolean); } function scoreSupportLevel(level: SupportLevel): number { switch (level) { case "explicit": return 1; case "strong_partial": return 0.75; case "weak_partial": return 0.45; case "implied": return 0.3; case "unclear": return 0.18; default: return 0; } } function findEvidenceForRequirement(resumeText: string, requirement: Requirement): Evidence[] { const spans = sentenceSpans(resumeText); const aliases = aliasUniverse(requirement); const evidences: Evidence[] = []; for (const span of spans) { const normalizedSpan = normalizeText(span.text); const exactAlias = aliases.find((alias) => includesAlias(normalizedSpan, alias)); const overlap = Math.max(...aliases.map((alias) => overlapRatio(span.text, alias)), 0); const quantifiedImpactPresent = /\b\d+[%x]?\b/.test(span.text) || /(reduced|improved|increase|improvement|הפחתה|שיפור|חסכון|ייעול)/i.test(span.text); const yearsMatch = span.text.match(/(\d+)\s*(years|year|שנים|שנה)/i); let supportLevel: SupportLevel = "missing"; let evidenceType: Evidence["evidenceType"] = "semantic_equivalent"; let rationale = ""; if (exactAlias) { supportLevel = "explicit"; evidenceType = requirement.type === "tool_system" ? "tool_usage" : requirement.type === "education_background" ? "education_signal" : requirement.type === "role_title" ? "title_signal" : "exact_term"; rationale = `The CV explicitly mentions ${exactAlias}.`; } else if (overlap >= 0.7) { supportLevel = "strong_partial"; evidenceType = requirement.type === "leadership_responsibility" ? "responsibility_evidence" : "normalized_alias"; rationale = "The CV uses closely related wording that strongly supports this requirement."; } else if (overlap >= 0.45) { supportLevel = "weak_partial"; evidenceType = requirement.type === "manufacturing_domain" ? "domain_context" : "semantic_equivalent"; rationale = "The CV suggests adjacent evidence, but not a clean direct proof."; } else if ( requirement.type === "leadership_responsibility" && /(ניהול|lead|manager|supervis|operators|technicians|engineers)/i.test(span.text) ) { supportLevel = "implied"; evidenceType = "responsibility_evidence"; rationale = "The CV implies leadership scope, but without a precise manufacturing leadership proof."; } else if ( requirement.type === "manufacturing_domain" && /(manufacturing|production|factory|plant|ייצור|מפעל|תהליכי ייצור|תעשייתי)/i.test(span.text) ) { supportLevel = "implied"; evidenceType = "domain_context"; rationale = "The CV shows relevant manufacturing context, but not the exact JD wording."; } else if ( supportLevel === "missing" && requirement.type === "seniority" && ROLE_LEADERSHIP_MARKERS.test(span.text) ) { supportLevel = ROLE_TECHNICAL_MARKERS.test(span.text) ? "strong_partial" : "implied"; evidenceType = "title_signal"; rationale = "The CV shows leadership scope in a prominent title or responsibility line."; } if ( supportLevel === "missing" && requirement.type === "role_title" && ROLE_LEADERSHIP_MARKERS.test(span.text) && ROLE_TECHNICAL_MARKERS.test(span.text) ) { supportLevel = requirement.senioritySignal === "leadership" ? "strong_partial" : "weak_partial"; evidenceType = "title_signal"; rationale = "The CV shows leadership plus technical or engineering scope that is adjacent to the requested title."; } if (supportLevel !== "missing") { evidences.push({ requirementId: requirement.id, supportLevel, confidence: Math.min(0.98, 0.44 + scoreSupportLevel(supportLevel) * 0.5 + (quantifiedImpactPresent ? 0.06 : 0)), cvSourceText: span.text, cvSpanStart: span.start, cvSpanEnd: span.end, evidenceType, rationale, matchedAlias: exactAlias || null, recencySignal: span.start < resumeText.length * 0.45 ? "recent_or_prominent" : null, strengthSignal: quantifiedImpactPresent ? "quantified_impact" : null, yearsInferred: yearsMatch ? Number(yearsMatch[1]) : null, quantifiedImpactPresent, ambiguityFlag: supportLevel === "weak_partial" || supportLevel === "implied" }); } } if (!evidences.length) { return [{ requirementId: requirement.id, supportLevel: "missing", confidence: 0.82, cvSourceText: "", cvSpanStart: -1, cvSpanEnd: -1, evidenceType: "semantic_equivalent", rationale: "No direct CV evidence was found for this requirement.", matchedAlias: null, recencySignal: null, strengthSignal: null, yearsInferred: null, quantifiedImpactPresent: false, ambiguityFlag: false }]; } return evidences.sort((left, right) => scoreSupportLevel(right.supportLevel) - scoreSupportLevel(left.supportLevel) || right.confidence - left.confidence); } function stateFromEvidence(requirement: Requirement, evidence: Evidence[]): MatchState { const top = evidence[0]; if (!top || top.supportLevel === "missing") return "missing"; if (top.supportLevel === "explicit") return "matched"; if (top.supportLevel === "strong_partial") return "partially_matched"; if (top.supportLevel === "weak_partial") return "weakly_supported"; if (requirement.mustHave) return "uncertain"; return "weakly_supported"; } function rationaleForMatch(match: RequirementMatch): string { if (!match.topEvidence || match.topEvidence.supportLevel === "missing") { return `No CV evidence was found for ${match.requirement.label}.`; } return match.topEvidence.rationale; } export function buildMatches(resumeText: string, requirements: Requirement[]): RequirementMatch[] { return requirements.map((requirement) => { const evidence = findEvidenceForRequirement(resumeText, requirement); const topEvidence = evidence[0] || null; const state = stateFromEvidence(requirement, evidence); return { requirement, evidence, state, topEvidence, confidence: topEvidence?.confidence || 0.25, rationale: rationaleForMatch({ requirement, evidence, topEvidence, state, confidence: topEvidence?.confidence || 0.25, rationale: "" }) }; }); } function categoryScore(matches: RequirementMatch[], types: RequirementType[]): number { const filtered = matches.filter((match) => types.includes(match.requirement.type)); if (!filtered.length) return 70; const totalWeight = filtered.reduce((sum, match) => sum + match.requirement.importance, 0); const earned = filtered.reduce((sum, match) => sum + match.requirement.importance * scoreSupportLevel(match.topEvidence?.supportLevel || "missing"), 0); return Math.round((earned / Math.max(totalWeight, 0.001)) * 100); } function computeScoring(matches: RequirementMatch[]): ScoringBreakdown { const roleFitScore = categoryScore(matches, ["role_title", "seniority"]); const domainFitScore = categoryScore(matches, ["manufacturing_domain", "industry_context"]); const hardSkillsFitScore = categoryScore(matches, ["hard_skill", "methodology", "education_background", "degree"]); const toolsFitScore = categoryScore(matches, ["tool_system"]); const leadershipFitScore = categoryScore(matches, ["leadership_responsibility"]); const mustHave = matches.filter((match) => match.requirement.mustHave); const mustHaveCoverage = mustHave.length ? Math.round( mustHave.reduce((sum, match) => sum + scoreSupportLevel(match.topEvidence?.supportLevel || "missing"), 0) / mustHave.length * 100 ) : 100; const evidenceStrengthScore = matches.length ? Math.round(matches.reduce((sum, match) => sum + (match.topEvidence?.quantifiedImpactPresent ? 1 : 0), 0) / matches.length * 100) : 50; const confidenceScore = matches.length ? Math.round(matches.reduce((sum, match) => sum + match.confidence, 0) / matches.length * 100) : 50; const uncertaintyPenalty = Math.round( matches.filter((match) => match.state === "uncertain" || match.state === "weakly_supported").length / Math.max(matches.length, 1) * 28 ); const weights = manufacturingPack.requirementsWeighting; const finalScore = Math.round( roleFitScore * weights.role + domainFitScore * weights.domain + hardSkillsFitScore * weights.hardSkills + toolsFitScore * weights.tools + leadershipFitScore * weights.leadership + mustHaveCoverage * weights.mustHave + evidenceStrengthScore * weights.evidenceStrength - uncertaintyPenalty * weights.uncertaintyPenalty ); return { roleFitScore, domainFitScore, hardSkillsFitScore, toolsFitScore, leadershipFitScore, mustHaveCoverage, evidenceStrengthScore, confidenceScore, uncertaintyPenalty, finalScore: Math.max(18, Math.min(96, finalScore)) }; } function groupRequirementsByType(requirements: Requirement[]): Record { return requirements.reduce>((groups, requirement) => { const key = requirementTypeLabel(requirement.type); groups[key] = groups[key] || []; groups[key].push(requirement); return groups; }, {}); } function groupMatches(matches: RequirementMatch[], filter: (match: RequirementMatch) => boolean): Record { return matches.filter(filter).reduce>((groups, match) => { const key = requirementTypeLabel(match.requirement.type); groups[key] = groups[key] || []; groups[key].push(match); return groups; }, {}); } function buildEvidenceMap(matches: RequirementMatch[]): EvidenceMapRow[] { return matches.map((match) => ({ requirementId: match.requirement.id, requirement: match.requirement.label, category: requirementTypeLabel(match.requirement.type), importance: match.requirement.importance, supportLevel: match.topEvidence?.supportLevel || "missing", matchState: match.state, confidence: match.confidence, jdEvidence: match.requirement.sourceText, cvEvidence: match.topEvidence?.cvSourceText || "No direct CV evidence found.", rationale: match.rationale, mustHave: match.requirement.mustHave })); } function uncertaintyFlags(matches: RequirementMatch[], warnings: JDQualityWarning[]): string[] { const flags: string[] = []; if (matches.some((match) => match.state === "uncertain")) { flags.push("Some must-have requirements remain uncertain and need manual review."); } if (matches.some((match) => match.state === "weakly_supported")) { flags.push("Several requirements have only weak contextual support rather than explicit proof."); } if (warnings.some((warning) => warning.severity === "high_risk")) { flags.push("The JD itself appears broad or over-constrained, which may inflate mismatch risk."); } return flags; } function buildCandidateRecommendations(matches: RequirementMatch[], domain: DomainDetection, language: TextLanguage): CandidateRecommendations { const missing = matches.filter((match) => match.state === "missing").slice(0, 4); const weak = matches.filter((match) => match.state === "weakly_supported" || match.state === "uncertain").slice(0, 3); if (language === "he" || language === "mixed") { return { wordingFixes: weak.map((match) => `${he("\u05d7\u05d6\u05e7 \u05d0\u05ea \u05d4\u05d4\u05d5\u05db\u05d7\u05d4 \u05e1\u05d1\u05d9\u05d1")} ${localizedRequirementLabel(match.requirement.label, language)} ${he("\u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d4\u05d9\u05e7\u05e3 \u05d0\u05d7\u05e8\u05d9\u05d5\u05ea, \u05db\u05dc\u05d9 \u05e2\u05d1\u05d5\u05d3\u05d4 \u05d0\u05d5 \u05ea\u05d5\u05e6\u05d0\u05d4 \u05de\u05d3\u05d9\u05d3\u05d4 \u05e9\u05db\u05d1\u05e8 \u05e7\u05d9\u05d9\u05de\u05d9\u05dd \u05d1\u05e0\u05d9\u05e1\u05d9\u05d5\u05df \u05e9\u05dc\u05da.")}`), proofGaps: missing.map((match) => `${he("\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d4 \u05e8\u05d0\u05d9\u05d4 \u05d1\u05e8\u05d5\u05e8\u05d4 \u05dc-")}${localizedRequirementLabel(match.requirement.label, language)}. ${he("\u05dc\u05dc\u05d0 \u05e0\u05d9\u05e1\u05d9\u05d5\u05df \u05de\u05d5\u05db\u05d7, \u05d6\u05d4 \u05e0\u05e9\u05d0\u05e8 \u05e4\u05e2\u05e8 \u05d0\u05de\u05d9\u05ea\u05d9 \u05d5\u05dc\u05d0 \u05e8\u05e7 \u05e4\u05e2\u05e8 \u05e0\u05d9\u05e1\u05d5\u05d7.")}`), likelyInterviewQuestions: manufacturingPack.interviewQuestionTemplates .slice(0, 2) .concat(weak.map((match) => `${he("\u05d4\u05db\u05df \u05d3\u05d5\u05d2\u05de\u05d4 \u05d1\u05e8\u05d5\u05e8\u05d4 \u05e9\u05de\u05e8\u05d0\u05d4 \u05d0\u05d9\u05e4\u05d4 \u05e7\u05d5\u05e8\u05d5\u05ea \u05d4\u05d7\u05d9\u05d9\u05dd \u05e9\u05dc\u05da \u05de\u05d5\u05db\u05d9\u05d7\u05d9\u05dd \u05d0\u05ea")} ${localizedRequirementLabel(match.requirement.label, language)}.`)) .slice(0, 4), titleAlignmentSuggestions: [ `${he("\u05db\u05d5\u05d5\u05df \u05d0\u05ea \u05d4\u05db\u05d5\u05ea\u05e8\u05ea \u05d4\u05de\u05e7\u05e6\u05d5\u05e2\u05d9\u05ea \u05e9\u05dc\u05da \u05dc\u05de\u05e9\u05e4\u05d7\u05ea \u05d4\u05ea\u05e4\u05e7\u05d9\u05d3:")} ${localizedDomainLabel(domain.roleFamily, language)}.`, he("\u05d0\u05dd \u05d4\u05ea\u05e4\u05e7\u05d9\u05d3 \u05d3\u05d5\u05e8\u05e9 \u05d4\u05d5\u05d1\u05dc\u05d4 \u05d4\u05e0\u05d3\u05e1\u05d9\u05ea \u05d0\u05d5 \u05d9\u05d9\u05e6\u05d5\u05e8\u05d9\u05ea, \u05d5\u05d3\u05d0 \u05e9\u05d4\u05d9\u05e7\u05e3 \u05d4\u05d4\u05d5\u05d1\u05dc\u05d4 \u05d4\u05d0\u05d7\u05e8\u05d5\u05df \u05e9\u05dc\u05da \u05de\u05d5\u05e4\u05d9\u05e2 \u05d1\u05d7\u05dc\u05e7 \u05d4\u05e2\u05dc\u05d9\u05d5\u05df \u05e9\u05dc \u05e7\u05d5\u05e8\u05d5\u05ea \u05d4\u05d7\u05d9\u05d9\u05dd.") ] }; } return { wordingFixes: weak.map((match) => `Strengthen the proof around ${match.requirement.label} with concrete scope, methods, or measurable outcomes already backed by your experience.`), proofGaps: missing.map((match) => `There is no clear evidence for ${match.requirement.label}. This is not fixable by wording unless you can cite real experience.`), likelyInterviewQuestions: manufacturingPack.interviewQuestionTemplates .slice(0, 2) .concat(weak.map((match) => `Be ready to explain where your CV proves ${match.requirement.label}.`)) .slice(0, 4), titleAlignmentSuggestions: [ `Align your headline with the JD's role family: ${domain.roleFamily.replace(/_/g, " ")}.`, "If the role expects plant or manufacturing leadership, make recent plant scope visible near the top of the CV.", ] }; } function buildRecruiterRecommendations(matches: RequirementMatch[]): RecruiterRecommendations { const weak = matches.filter((match) => match.state === "weakly_supported" || match.state === "uncertain").slice(0, 4); const missingMustHaves = matches.filter((match) => match.requirement.mustHave && match.state === "missing").slice(0, 4); return { verifyManually: missingMustHaves.map((match) => `Verify whether the candidate truly has ${match.requirement.label}; the CV does not currently prove it.`), weakEvidenceZones: weak.map((match) => `${match.requirement.label}: ${match.rationale}`), interviewProbes: weak.map((match) => `Ask for a concrete example that proves ${match.requirement.label}.`), possibleFalseNegatives: matches .filter((match) => match.state === "missing" && /(leadership|management|engineering)/i.test(match.requirement.label)) .slice(0, 2) .map((match) => `This may be a wording-driven miss if the candidate has adjacent experience for ${match.requirement.label}.`) }; } function buildSummary(matches: RequirementMatch[], scoring: ScoringBreakdown, domain: DomainDetection, language: TextLanguage): { profileSummary: string; tailoredBio: string; strengths: string[]; weaknesses: string[]; recommendations: string[] } { const strengths = matches .filter((match) => match.state === "matched" || match.state === "partially_matched") .sort((left, right) => right.requirement.importance - left.requirement.importance) .slice(0, 5) .map((match) => localizedRequirementLabel(match.requirement.label, language)); const weaknesses = matches .filter((match) => match.state === "missing" || match.state === "uncertain") .sort((left, right) => Number(right.requirement.mustHave) - Number(left.requirement.mustHave) || right.requirement.importance - left.requirement.importance) .slice(0, 5) .map((match) => localizedRequirementLabel(match.requirement.label, language)); const recommendations = language === "he" || language === "mixed" ? weaknesses.map((item) => `${he("\u05d4\u05d1\u05d4\u05e8 \u05d0\u05d5 \u05d4\u05d5\u05db\u05d7 \u05d0\u05ea")} ${item} ${he("\u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e8\u05d0\u05d9\u05d4 \u05d9\u05e9\u05d9\u05e8\u05d4, \u05e2\u05d3\u05db\u05e0\u05d9\u05ea \u05d5\u05de\u05d2\u05d5\u05d1\u05d4.")}`) : weaknesses.map((item) => `Clarify or prove ${item} with direct, recent, evidence-backed content.`); const profileSummary = language === "he" || language === "mixed" ? `${he("\u05d4\u05ea\u05e4\u05e7\u05d9\u05d3 \u05d9\u05d5\u05e9\u05d1 \u05d1\u05d3\u05d5\u05de\u05d9\u05d9\u05df")} ${localizedDomainLabel(domain.primaryDomain, language)} / ${localizedDomainLabel(domain.roleFamily, language)}. ${he("\u05e0\u05de\u05e6\u05d0\u05d5")} ${strengths.length} ${he("\u05d4\u05ea\u05d0\u05de\u05d5\u05ea \u05d7\u05d6\u05e7\u05d5\u05ea \u05de\u05d2\u05d5\u05d1\u05d5\u05ea \u05e8\u05d0\u05d9\u05d4 \u05d5-")} ${weaknesses.length} ${he("\u05e4\u05e2\u05e8\u05d9\u05dd \u05d0\u05d5 \u05d0\u05d6\u05d5\u05e8\u05d9 \u05d0\u05d9-\u05d5\u05d3\u05d0\u05d5\u05ea. \u05d7\u05e9\u05d5\u05d1 \u05dc\u05e7\u05e8\u05d5\u05d0 \u05d0\u05ea \u05d4\u05d4\u05ea\u05d0\u05de\u05d4 \u05d9\u05d7\u05d3 \u05e2\u05dd \u05d7\u05d5\u05d6\u05e7 \u05d4\u05e8\u05d0\u05d9\u05d5\u05ea \u05d5\u05db\u05d9\u05e1\u05d5\u05d9 \u05d3\u05e8\u05d9\u05e9\u05d5\u05ea \u05d4\u05d7\u05d5\u05d1\u05d4, \u05d5\u05dc\u05d0 \u05dc\u05e4\u05d9 \u05d4\u05e6\u05d9\u05d5\u05df \u05d1\u05dc\u05d1\u05d3.")}` : `The role sits in ${localizedDomainLabel(domain.primaryDomain, language)} / ${localizedDomainLabel(domain.roleFamily, language)}. The analysis found ${strengths.length} strong evidence-backed matches and ${weaknesses.length} real gaps or uncertain zones. Final fit should be read together with evidence strength and must-have coverage, not score alone.`; const tailoredBio = language === "he" || language === "mixed" ? he("\u05de\u05e0\u05d4\u05dc \u05d4\u05e0\u05d3\u05e1\u05d9 \u05d1\u05db\u05d9\u05e8 \u05e2\u05dd \u05e0\u05d9\u05e1\u05d9\u05d5\u05df \u05de\u05d5\u05db\u05d7 \u05d1\u05e1\u05d1\u05d9\u05d1\u05d5\u05ea \u05d9\u05d9\u05e6\u05d5\u05e8 \u05d5\u05ea\u05e2\u05e9\u05d9\u05d9\u05d4, \u05d4\u05de\u05e9\u05dc\u05d1 \u05d4\u05d5\u05d1\u05dc\u05ea \u05e4\u05e8\u05d5\u05d9\u05e7\u05d8\u05d9\u05dd, \u05d0\u05d7\u05e8\u05d9\u05d5\u05ea \u05ea\u05d4\u05dc\u05d9\u05db\u05d9\u05ea, \u05db\u05dc\u05d9\u05dd \u05d4\u05e0\u05d3\u05e1\u05d9\u05d9\u05dd, \u05e9\u05d9\u05e4\u05d5\u05e8 \u05d1\u05d9\u05e6\u05d5\u05e2\u05d9\u05dd \u05d5\u05d4\u05d5\u05d1\u05dc\u05ea \u05de\u05de\u05e9\u05e7\u05d9\u05dd \u05db\u05e4\u05d9 \u05e9\u05e2\u05d5\u05dc\u05d4 \u05de\u05e7\u05d5\u05e8\u05d5\u05ea \u05d4\u05d7\u05d9\u05d9\u05dd.") : `Manufacturing and industrial engineering professional with proven scope in ${localizedDomainLabel(domain.roleFamily, language)} environments, combining process ownership, engineering tools, operational improvement, and leadership signals that are supported by the CV.`; return { profileSummary, tailoredBio, strengths, weaknesses, recommendations }; } function jobFitDecision(finalScore: number): "High" | "Medium" | "Low" { if (finalScore >= 78) return "High"; if (finalScore >= 55) return "Medium"; return "Low"; } function buildBulletOptimization(matches: RequirementMatch[], language: TextLanguage): FitAnalysis["bulletPointOptimization"] { return matches .filter((match) => match.state === "weakly_supported" || match.state === "uncertain") .slice(0, 3) .map((match) => ({ original: match.topEvidence?.cvSourceText || (language === "he" || language === "mixed" ? `${he("\u05d0\u05d9\u05df \u05e8\u05d0\u05d9\u05d4 \u05d9\u05e9\u05d9\u05e8\u05d4 \u05dc-")}${localizedRequirementLabel(match.requirement.label, language)}.` : `No direct proof for ${match.requirement.label}.`), optimized: language === "he" || language === "mixed" ? `${localizedRequirementLabel(match.requirement.label, language)} ${he("\u05d1\u05d0\u05d9\u05dd \u05dc\u05d9\u05d3\u05d9 \u05d1\u05d9\u05d8\u05d5\u05d9 \u05d3\u05e8\u05da \u05d4\u05d9\u05e7\u05e3 \u05d0\u05d7\u05e8\u05d9\u05d5\u05ea \u05d1\u05e8\u05d5\u05e8, \u05db\u05dc\u05d9 \u05e2\u05d1\u05d5\u05d3\u05d4 \u05e8\u05dc\u05d5\u05d5\u05e0\u05d8\u05d9\u05d9\u05dd \u05d5\u05ea\u05d5\u05e6\u05d0\u05d4 \u05de\u05d3\u05d9\u05d3\u05d4.")}` : `${match.requirement.label} is demonstrated through clear ownership scope, relevant tools or methods, and measurable outcomes.`, rationale: language === "he" || language === "mixed" ? he("\u05d3\u05d5\u05d2\u05de\u05ea \u05e0\u05d9\u05e1\u05d5\u05d7 \u05de\u05d2\u05d5\u05d9\u05e1\u05ea \u05d4\u05de\u05d1\u05d5\u05e1\u05e1\u05ea \u05e2\u05dc \u05d4\u05e8\u05d0\u05d9\u05d5\u05ea \u05e9\u05db\u05d1\u05e8 \u05d6\u05d5\u05d4\u05d5, \u05dc\u05dc\u05d0 \u05d4\u05d5\u05e1\u05e4\u05ea \u05e0\u05d9\u05e1\u05d9\u05d5\u05df \u05d7\u05d3\u05e9.") : match.rationale })); } export function buildEvidenceBasedAnalysis(resumeText: string, jobDescription: string): FitAnalysis { const resumeLanguage = detectTextLanguage(resumeText); const domain = detectDomain(jobDescription); const requirements = extractRequirements(jobDescription, domain); const matches = buildMatches(resumeText, requirements); const scoring = computeScoring(matches); const jdWarnings = lintJobDescription(jobDescription, requirements); const uncertainty = uncertaintyFlags(matches, jdWarnings); const summary = buildSummary(matches, scoring, domain, resumeLanguage.language); const evidenceMap = buildEvidenceMap(matches); const inputHash = stableHash(resumeText, jobDescription); const matchedKeywords = matches .filter((match) => match.state === "matched" || match.state === "partially_matched") .map((match) => localizedRequirementLabel(match.requirement.label, resumeLanguage.language)) .filter((value) => !GENERIC_TOKENS.has(normalizeText(value))) .slice(0, 10); const missingKeywords = matches .filter((match) => match.state === "missing" || match.state === "uncertain") .map((match) => localizedRequirementLabel(match.requirement.label, resumeLanguage.language)) .filter((value) => !GENERIC_TOKENS.has(normalizeText(value))) .slice(0, 10); return { domainDetection: domain, jdRequirementsByType: groupRequirementsByType(requirements), matchedEvidenceByType: groupMatches(matches, (match) => match.state === "matched" || match.state === "partially_matched"), missingRequirementsByType: groupMatches(matches, (match) => match.state === "missing" || match.state === "uncertain" || match.state === "weakly_supported"), uncertaintyFlags: uncertainty, scoringBreakdown: scoring, finalScore: scoring.finalScore, confidenceScore: scoring.confidenceScore, jdQualityWarnings: jdWarnings, candidateRecommendations: buildCandidateRecommendations(matches, domain, resumeLanguage.language), recruiterRecommendations: buildRecruiterRecommendations(matches), evidenceMap, analysisMeta: { version: "evidence-beta-v1", analysisMode: "evidence_based_hiring_intelligence", vertical: "manufacturing", generatedAt: new Date().toISOString(), inputHash, resumeLanguage: resumeLanguage.language, resumeLanguageConfidence: resumeLanguage.confidence } satisfies AnalysisMeta, profileSummary: summary.profileSummary, tailoredBio: summary.tailoredBio, bulletPointOptimization: buildBulletOptimization(matches, resumeLanguage.language), matchedKeywords, missingKeywords, matchScore: scoring.finalScore, atsVisibilityScore: Math.round((scoring.roleFitScore + scoring.domainFitScore + scoring.mustHaveCoverage) / 3), jobFitDecision: jobFitDecision(scoring.finalScore), strengths: summary.strengths, weaknesses: summary.weaknesses, recommendations: summary.recommendations, scoringMode: "evidence-based-deterministic", scoreLocked: true, candidateViewMode: "free" }; } export function previewJobDescription(jobDescription: string) { const domainDetection = detectDomain(jobDescription); const requirements = extractRequirements(jobDescription, domainDetection); const jdQualityWarnings = lintJobDescription(jobDescription, requirements); return { domainDetection, jdRequirementsByType: groupRequirementsByType(requirements), jdQualityWarnings, analysisMeta: { version: "evidence-beta-v1", analysisMode: "jd_parse_preview", vertical: "manufacturing", generatedAt: new Date().toISOString(), inputHash: stableHash(jobDescription) } satisfies AnalysisMeta }; } export function previewResumeText(resumeText: string) { const spans = lineSpans(resumeText); const signals = unique( [ ...manufacturingPack.toolsCatalog.filter((entry) => entry.aliases.some((alias) => includesAlias(resumeText, alias))).map((entry) => entry.canonical), ...manufacturingPack.methodsCatalog.filter((entry) => entry.aliases.some((alias) => includesAlias(resumeText, alias))).map((entry) => entry.canonical), ...manufacturingPack.domainConcepts.filter((entry) => entry.aliases.some((alias) => includesAlias(resumeText, alias))).map((entry) => entry.canonical), ...manufacturingPack.leadershipSignals.filter((entry) => entry.aliases.some((alias) => includesAlias(resumeText, alias))).map((entry) => entry.canonical), ...manufacturingPack.educationCatalog.filter((entry) => entry.aliases.some((alias) => includesAlias(resumeText, alias))).map((entry) => entry.canonical) ] ); return { parsedSections: { topLines: spans.slice(0, 8).map((span) => span.text), detectedSignals: signals }, analysisMeta: { version: "evidence-beta-v1", analysisMode: "cv_parse_preview", vertical: "manufacturing", generatedAt: new Date().toISOString(), inputHash: stableHash(resumeText) } satisfies AnalysisMeta }; }