diff --git a/extension-ready/popup.html b/extension-ready/popup.html index 28c0561..c24baa4 100644 --- a/extension-ready/popup.html +++ b/extension-ready/popup.html @@ -488,6 +488,9 @@ .match-chip-strong { background: #dcfce7; color: #166534; } .match-chip-confirmed { background: #dbeafe; color: #1e40af; } .match-chip-missing { background: #f1f5f9; color: #64748b; } + .match-chip-domain { background: #f5f3ff; color: #6d28d9; } + + .match-label-purple { color: #7c3aed; } .missing-skill-check { display: inline-flex; @@ -864,6 +867,11 @@
Tick any skills you genuinely have — they'll be included in the tailored CV.
+ diff --git a/extension-ready/popup.js b/extension-ready/popup.js index ab917ce..ea4a56a 100644 --- a/extension-ready/popup.js +++ b/extension-ready/popup.js @@ -48,6 +48,8 @@ document.addEventListener('DOMContentLoaded', async () => { matchAllClear: document.getElementById('match-all-clear'), matchMissing: document.getElementById('match-missing'), matchMissingChips: document.getElementById('match-missing-chips'), + matchDomain: document.getElementById('match-domain'), + matchDomainChips: document.getElementById('match-domain-chips'), tailorWarningsBox: document.getElementById('tailor-warnings-box'), tailorOutputWrap: document.getElementById('tailor-output-wrap'), tailorOutput: document.getElementById('tailor-output'), @@ -436,7 +438,7 @@ document.addEventListener('DOMContentLoaded', async () => { return; } - displayMatchReport(result.matchReport, { reviewMode: true }); + displayMatchReport(result.matchReport, { reviewMode: true, domainSuggestions: result.domainSuggestions || [] }); await saveTailorDraft(); elements.tailorAnalyzeBtn.hidden = true; elements.tailorReanalyzeBtn.style.display = 'block'; @@ -504,7 +506,7 @@ document.addEventListener('DOMContentLoaded', async () => { function displayTailorResults(result) { const { tailoredCvText, matchReport, warnings } = result; - displayMatchReport(matchReport, { reviewMode: false }); + displayMatchReport(matchReport, { reviewMode: false, domainSuggestions: [] }); if (warnings?.length > 0) { elements.tailorWarningsBox.textContent = warnings.map(w => `⚠ ${w}`).join('\n'); @@ -523,7 +525,7 @@ document.addEventListener('DOMContentLoaded', async () => { setStep('export'); } - function displayMatchReport(matchReport, { reviewMode } = {}) { + function displayMatchReport(matchReport, { reviewMode, domainSuggestions = [] } = {}) { const score = matchReport?.score ?? null; elements.matchScore.textContent = score != null ? `${score}%` : '–'; elements.matchScore.className = 'match-score-badge' + @@ -565,6 +567,29 @@ document.addEventListener('DOMContentLoaded', async () => { // Show "all clear" only in review mode (before generation) elements.matchAllClear.hidden = !reviewMode; } + + // Domain suggestions — only shown in review mode with checkboxes + if (reviewMode && domainSuggestions.length > 0) { + elements.matchDomainChips.textContent = ''; + for (const tool of domainSuggestions) { + const label = document.createElement('label'); + label.className = 'missing-skill-check'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = tool; + checkbox.dataset.domainSkill = 'true'; + + const text = document.createElement('span'); + text.textContent = tool; + + label.append(checkbox, text); + elements.matchDomainChips.append(label); + } + elements.matchDomain.hidden = false; + } else { + elements.matchDomain.hidden = true; + } } function renderMissingSkillChecks(missing) { @@ -587,9 +612,13 @@ document.addEventListener('DOMContentLoaded', async () => { } function getConfirmedMissingSkills() { - return Array.from( + const fromMissing = Array.from( elements.matchMissingChips.querySelectorAll('input[data-missing-skill="true"]:checked') - ).map(input => input.value).filter(Boolean); + ); + const fromDomain = Array.from( + elements.matchDomainChips.querySelectorAll('input[data-domain-skill="true"]:checked') + ); + return [...fromMissing, ...fromDomain].map(input => input.value).filter(Boolean); } // ── Step indicator ──────────────────────────────────────────────────────── diff --git a/render-proxy/server.js b/render-proxy/server.js index 7c5bdd7..0324e63 100644 --- a/render-proxy/server.js +++ b/render-proxy/server.js @@ -443,6 +443,69 @@ app.post('/api/cv/upload', authRequired, generateLimiter, upload.single('cv'), a } }); +/** + * Ask the LLM to suggest tools commonly used in a given role that are not + * already present in the JD. Designed to be fired as a non-blocking Promise + * that runs in parallel with the sync match analysis. + * + * Returns an array of tool name strings, or null on any failure. + */ +async function fetchLLMDomainSuggestions(jobTitle, jdTools) { + if (!jobTitle || !GROQ_API_KEY) return null; + + const toolList = (jdTools || []).slice(0, 25).join(', ') || 'none specified'; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + + try { + const response = await fetch('https://api.groq.com/openai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${GROQ_API_KEY}`, + }, + signal: controller.signal, + body: JSON.stringify({ + model: GROQ_MODEL, + temperature: 0.2, + max_tokens: 200, + messages: [ + { + role: 'system', + content: 'You are a technical hiring expert. Respond only with valid JSON — no markdown, no explanation.', + }, + { + role: 'user', + content: `Job title: ${jobTitle}\nTechnologies already in the job description: ${toolList}\n\nList up to 10 additional tools, frameworks, or technologies that are commonly expected or genuinely valued for this exact role type but are NOT in the list above. Focus on what a hiring manager for this role would realistically look for.\n\nOutput a JSON array of strings only, e.g.: ["Tool1", "Tool2"]`, + }, + ], + }), + }); + + if (!response.ok) return null; + + const data = await response.json(); + const text = (data?.choices?.[0]?.message?.content || '').trim(); + if (!text) return null; + + // Extract JSON array even if the model wraps it in backticks + const match = text.match(/\[[\s\S]*?\]/); + if (!match) return null; + + const parsed = JSON.parse(match[0]); + if (!Array.isArray(parsed)) return null; + + return parsed + .filter(t => typeof t === 'string' && t.trim().length > 0) + .map(t => t.trim()) + .slice(0, 10); + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + app.post('/api/cv/analyze', authRequired, generateLimiter, async (req, res) => { const { cvText, jobTitle = '', company = '', jobDescription, confirmedSkills = [] } = req.body || {}; @@ -456,13 +519,46 @@ app.post('/api/cv/analyze', authRequired, generateLimiter, async (req, res) => { try { const cvData = new CVParser().parse(cvText); const jdData = new JDParser().parse(jobDescription, jobTitle, company); - const tailor = new CVTailor(); - const matchMap = tailor.buildMatchMap(cvData, jdData, confirmedSkills); + const tailor = new CVTailor(); + + // Fire LLM domain call immediately so it runs in parallel with sync work below + const llmSuggestionsPromise = fetchLLMDomainSuggestions(jdData.jobTitle, jdData.tools); + + // Sync work (fast — no LLM involved) + const matchMap = tailor.buildMatchMap(cvData, jdData, confirmedSkills); + const staticFallback = tailor.suggestDomainSkills(jdData, cvData); + + // Await the LLM domain call (already running in parallel) + const llmRaw = await llmSuggestionsPromise.catch(() => null); + + // Filter LLM suggestions against what's already in the JD or CV + let domainSuggestions = staticFallback; + if (llmRaw?.length > 0) { + const inJd = new Set([ + ...(jdData.tools || []).map(t => t.toLowerCase()), + ...(jdData.requiredSkills || []).map(s => s.toLowerCase()), + ...(jdData.preferredSkills || []).map(s => s.toLowerCase()), + ]); + const cvLower = cvText.toLowerCase(); + + const filtered = llmRaw.filter(tool => { + const low = tool.toLowerCase(); + // Skip if tool (or any 4+ char word of it) already appears in the JD + if (inJd.has(low)) return false; + if (low.split(/\s+/).some(w => w.length >= 4 && inJd.has(w))) return false; + // Skip if already in the candidate's CV + if (cvLower.includes(low)) return false; + return true; + }); + + if (filtered.length > 0) domainSuggestions = filtered; + } return res.json({ matchReport: tailor.buildMatchSummary(matchMap), jobTitle: jdData.jobTitle, - company: jdData.company, + company: jdData.company, + domainSuggestions, }); } catch (e) { console.error('[DraftApply] Analyze error:', e.message); diff --git a/shared/cv-tailor.js b/shared/cv-tailor.js index 25a470b..1876781 100644 --- a/shared/cv-tailor.js +++ b/shared/cv-tailor.js @@ -422,6 +422,113 @@ Output the complete tailored CV text with no preamble, no commentary, and no mar return [...new Set(evidence)].slice(0, 5); } + /** + * Suggest domain-common tools not already present in the JD or CV. + * @returns {string[]} up to 12 tool names + */ + suggestDomainSkills(jdData, cvData) { + const domain = this._detectDomain(jdData); + if (!domain) return []; + + const domainTools = this._getDomainTools(domain); + + // Build a set of everything already mentioned in the JD (case-insensitive) + const inJd = new Set([ + ...(jdData.tools || []).map(t => t.toLowerCase()), + ...(jdData.requiredSkills || []).map(s => s.toLowerCase()), + ...(jdData.preferredSkills || []).map(s => s.toLowerCase()), + ]); + + const cvLower = (cvData?.rawText || '').toLowerCase(); + + return domainTools.filter(tool => { + const low = tool.toLowerCase(); + if (inJd.has(low)) return false; + // Partial match: if any word of the tool name appears in the JD set, skip + if (low.split(/\s+/).some(w => w.length >= 4 && inJd.has(w))) return false; + if (cvLower.includes(low)) return false; + return true; + }).slice(0, 12); + } + + _detectDomain(jdData) { + const title = (jdData.jobTitle || '').toLowerCase(); + const tools = (jdData.tools || []).map(t => t.toLowerCase()).join(' '); + const req = (jdData.requiredSkills || []).map(s => s.toLowerCase()).join(' '); + const combined = `${title} ${tools} ${req}`; + + if (/\b(mlops|ml\s+platform|ml\s+engineer|machine\s+learning\s+engineer|ml\s+infrastructure|ai\s+platform)\b/.test(combined) || + /\b(kubeflow|kfp|mlflow|seldon|bentoml|triton|skypi|dcgm|zenml|metaflow|clearml)\b/.test(combined)) + return 'mlops'; + + if (/\b(data\s+engineer|data\s+platform|etl|elt|data\s+pipeline|analytics\s+engineer)\b/.test(combined) || + /\b(dbt|airflow|dagster|prefect|flink|iceberg|delta\s+lake)\b/.test(combined)) + return 'data_engineering'; + + if (/\b(devops|platform\s+engineer|sre|site\s+reliability|infrastructure\s+engineer|cloud\s+engineer)\b/.test(combined) || + /\b(terraform|ansible|argocd|crossplane|pulumi|karpenter|keda|fluxcd)\b/.test(combined)) + return 'devops'; + + if (/\b(data\s+scientist|machine\s+learning|deep\s+learning|ml\s+researcher|ai\s+scientist|research\s+engineer)\b/.test(combined) || + /\b(pytorch|tensorflow|wandb|optuna|hugging\s*face|transformers)\b/.test(combined)) + return 'ml_scientist'; + + if (/\b(frontend|front-end|ui\s+engineer|web\s+developer)\b/.test(combined) || + /\b(react|nextjs|vue|angular|svelte)\b/.test(combined)) + return 'frontend'; + + if (/\b(backend|back-end|api\s+engineer|server[- ]?side|microservices)\b/.test(combined)) + return 'backend'; + + if (/\b(cloud\s+architect|solutions\s+architect|aws\s+architect|gcp\s+architect)\b/.test(combined)) + return 'cloud'; + + return null; + } + + _getDomainTools(domain) { + const MAP = { + mlops: [ + 'MLflow', 'DVC', 'Kubeflow', 'KFP', 'Seldon', 'BentoML', 'Triton', + 'Ray', 'SkyPilot', 'DCGM', 'Feast', 'ZenML', 'Metaflow', 'ClearML', + 'Argo Workflows', 'ArgoCD', 'WandB', 'Evidently', 'Prefect', 'Dagster', + 'AWS CDK', 'Vertex AI', 'SageMaker', 'Azure ML', 'ONNX', 'vLLM', 'Karpenter', + ], + data_engineering: [ + 'Apache Spark', 'Apache Kafka', 'Apache Flink', 'dbt', 'Airflow', 'Prefect', + 'Dagster', 'Delta Lake', 'Apache Iceberg', 'Great Expectations', 'dlt', + 'Fivetran', 'Airbyte', 'Snowflake', 'BigQuery', 'Redshift', 'DuckDB', + 'Polars', 'OpenLineage', 'Trino', + ], + devops: [ + 'Terraform', 'Ansible', 'Helm', 'ArgoCD', 'FluxCD', 'Crossplane', + 'AWS CDK', 'Pulumi', 'Datadog', 'Prometheus', 'Grafana', 'OpenTelemetry', + 'GitHub Actions', 'Vault', 'Consul', 'Istio', 'Linkerd', 'Karpenter', 'KEDA', + ], + ml_scientist: [ + 'MLflow', 'WandB', 'DVC', 'ONNX', 'TensorFlow', 'PyTorch', 'Hugging Face', + 'scikit-learn', 'Optuna', 'Ray Tune', 'Dask', 'Polars', 'LangChain', 'LlamaIndex', + 'Feast', 'Evidently', 'BentoML', 'Triton', 'vLLM', + ], + frontend: [ + 'TypeScript', 'React', 'Next.js', 'Tailwind CSS', 'Vite', 'Storybook', + 'Cypress', 'Playwright', 'Redux', 'Zustand', 'React Query', 'GraphQL', + 'Turborepo', 'Nx', 'Radix UI', 'shadcn/ui', + ], + backend: [ + 'Node.js', 'NestJS', 'PostgreSQL', 'Redis', 'Docker', 'Kubernetes', + 'GraphQL', 'gRPC', 'Kafka', 'RabbitMQ', 'Elasticsearch', 'OpenTelemetry', + 'Terraform', 'Helm', 'GitHub Actions', + ], + cloud: [ + 'AWS CDK', 'Terraform', 'Pulumi', 'CloudFormation', 'Ansible', + 'Datadog', 'Prometheus', 'Grafana', 'OpenTelemetry', 'Istio', + 'ArgoCD', 'Helm', 'Karpenter', 'KEDA', 'Vault', 'Crossplane', + ], + }; + return MAP[domain] || []; + } + /** Detect adjacent/related tech as a signal for partial_match. */ _hasAdjacentTech(requirement, cvLower) { const adjacency = { diff --git a/shared/jd-parser.js b/shared/jd-parser.js index c42ed6c..1d50940 100644 --- a/shared/jd-parser.js +++ b/shared/jd-parser.js @@ -94,7 +94,7 @@ export class JDParser { } // Also scan the full text for common tech keywords - const techRe = /\b(React(?:\.js)?|Vue(?:\.js)?|Angular|Next\.js|Nuxt(?:\.js)?|Svelte(?:Kit)?|Remix|Astro|Node(?:\.js)?|Express(?:\.js)?|NestJS|Fastify|Django|Flask|FastAPI|Spring(?:\s*Boot)?|Laravel|Rails|Ruby\s+on\s+Rails|Python|TypeScript|JavaScript|Go(?:lang)?|Rust|Java|C#|\.NET|PHP|Swift|Kotlin|Scala|Elixir|GraphQL|REST(?:ful)?|gRPC|tRPC|SQL|PostgreSQL|MySQL|MongoDB|Redis|Elasticsearch|Kafka|RabbitMQ|AWS|GCP|Azure|Docker|Kubernetes|Terraform|CI\/CD|Git(?:Hub|Lab)?|Jira|Linux|Bash|Shell)\b/gi; + const techRe = /\b(React(?:\.js)?|Vue(?:\.js)?|Angular|Next\.js|Nuxt(?:\.js)?|Svelte(?:Kit)?|Remix|Astro|Node(?:\.js)?|Express(?:\.js)?|NestJS|Fastify|Django|Flask|FastAPI|Spring(?:\s*Boot)?|Laravel|Rails|Ruby\s+on\s+Rails|Python|TypeScript|JavaScript|Go(?:lang)?|Rust|Java|C#|\.NET|PHP|Swift|Kotlin|Scala|Elixir|GraphQL|REST(?:ful)?|gRPC|tRPC|SQL|PostgreSQL|MySQL|MongoDB|Redis|Elasticsearch|Kafka|RabbitMQ|AWS|GCP|Azure|Docker|Kubernetes|Terraform|CI\/CD|Git(?:Hub|Lab)?|Jira|Linux|Bash|Shell|Kubeflow|KFP|KubeRay|Ray(?:\.io|\.Serve|\.Train)?|SkyPilot|DCGM|MLflow|DVC|Seldon|BentoML|Triton(?:\s+Inference\s+Server)?|Feast|WandB|Weights\s*&\s*Biases|ZenML|Metaflow|ClearML|Argo\s*(?:Workflows?|CD|Rollouts?)?|ArgoCD|Vertex\s*AI|SageMaker|Azure\s*ML|ONNX|Dask|Polars|Evidently(?:\s*AI)?|Neptune|vLLM|Prefect|Dagster|Delta\s*Lake|Apache\s*(?:Spark|Kafka|Flink|Airflow|Iceberg)|Great\s*Expectations|AWS\s*CDK|Pulumi|Crossplane|OpenTelemetry|Prometheus|Grafana|Datadog|Istio|Helm|Vault|Consul|Flux(?:CD)?|Karpenter|KEDA|Kuberay|LangChain|LangGraph|LlamaIndex|Pinecone|Weaviate|Qdrant|ChromaDB|OpenAI|Anthropic|Bedrock|Vertex)\b/gi; let m; while ((m = techRe.exec(text)) !== null) { skills.add(m[0]); @@ -123,7 +123,7 @@ export class JDParser { } extractTools(text) { - const toolRe = /\b(React(?:\.js)?|Vue(?:\.js)?|Angular|Next\.js|Nuxt(?:\.js)?|Svelte(?:Kit)?|Remix|Astro|Node(?:\.js)?|Express(?:\.js)?|NestJS|Fastify|Django|Flask|FastAPI|Spring(?:\s*Boot)?|Laravel|Rails|Python|TypeScript|JavaScript|Go(?:lang)?|Rust|Java|C#|\.NET|PHP|Swift|Kotlin|Scala|Elixir|GraphQL|gRPC|tRPC|PostgreSQL|MySQL|MariaDB|MongoDB|Redis|DynamoDB|Cassandra|Elasticsearch|Kafka|RabbitMQ|SQS|SNS|AWS|GCP|Azure|Docker|Kubernetes|Helm|Terraform|Ansible|Jenkins|GitHub\s*Actions|GitLab\s*CI|CircleCI|Travis\s*CI|Datadog|Prometheus|Grafana|Splunk|Sentry|Figma|Jira|Confluence|Slack|Linear|Notion|dbt|Airflow|Spark|Hadoop|Snowflake|BigQuery|Redshift|Tableau|Power\s*BI|Looker|pandas|NumPy|scikit[- ]learn|TensorFlow|PyTorch|Keras|OpenAI|LangChain|Pinecone|Weaviate|Qdrant|ChromaDB|Celery|Prisma|Drizzle|Stripe|Twilio|SendGrid|Firebase|Supabase|PlanetScale|Neon|Vercel|Netlify|Fly\.io|Cloudflare|Heroku|Turborepo|Nx|Vite|Webpack|Rollup|Pydantic|Linux|Bash|Shell|Git)\b/gi; + const toolRe = /\b(React(?:\.js)?|Vue(?:\.js)?|Angular|Next\.js|Nuxt(?:\.js)?|Svelte(?:Kit)?|Remix|Astro|Node(?:\.js)?|Express(?:\.js)?|NestJS|Fastify|Django|Flask|FastAPI|Spring(?:\s*Boot)?|Laravel|Rails|Python|TypeScript|JavaScript|Go(?:lang)?|Rust|Java|C#|\.NET|PHP|Swift|Kotlin|Scala|Elixir|GraphQL|gRPC|tRPC|PostgreSQL|MySQL|MariaDB|MongoDB|Redis|DynamoDB|Cassandra|Elasticsearch|Kafka|RabbitMQ|SQS|SNS|AWS|GCP|Azure|Docker|Kubernetes|Helm|Terraform|Ansible|Jenkins|GitHub\s*Actions|GitLab\s*CI|CircleCI|Travis\s*CI|Datadog|Prometheus|Grafana|Splunk|Sentry|Figma|Jira|Confluence|Slack|Linear|Notion|dbt|Airflow|Spark|Hadoop|Snowflake|BigQuery|Redshift|Tableau|Power\s*BI|Looker|pandas|NumPy|scikit[- ]learn|TensorFlow|PyTorch|Keras|OpenAI|LangChain|LangGraph|LlamaIndex|Pinecone|Weaviate|Qdrant|ChromaDB|Celery|Prisma|Drizzle|Stripe|Twilio|SendGrid|Firebase|Supabase|PlanetScale|Neon|Vercel|Netlify|Fly\.io|Cloudflare|Heroku|Turborepo|Nx|Vite|Webpack|Rollup|Pydantic|Linux|Bash|Shell|Git|Kubeflow|KFP|KubeRay|Ray(?:\.io|\.Serve|\.Train)?|SkyPilot|DCGM|MLflow|DVC|Seldon|BentoML|Triton(?:\s+Inference\s+Server)?|Feast|WandB|Weights\s*&\s*Biases|ZenML|Metaflow|ClearML|Argo\s*(?:Workflows?|CD|Rollouts?)?|ArgoCD|Vertex\s*AI|SageMaker|Azure\s*ML|ONNX|Dask|Polars|Evidently(?:\s*AI)?|Neptune|vLLM|Prefect|Dagster|Delta\s*Lake|Apache\s*(?:Flink|Iceberg)|Great\s*Expectations|AWS\s*CDK|Pulumi|Crossplane|OpenTelemetry|Istio|Vault|Consul|Flux(?:CD)?|Karpenter|KEDA|Anthropic|Bedrock)\b/gi; const found = new Set(); let m; while ((m = toolRe.exec(text)) !== null) { @@ -236,17 +236,39 @@ export class JDParser { _extractBulletsAndSentences(block) { const items = []; - // Bulleted / numbered lines - for (const line of block.split('\n')) { - const t = line.trim(); + for (const rawLine of block.split('\n')) { + const t = rawLine.trim(); if (!t) continue; + + // Bulleted / numbered lines if (/^[\-•*●\d.]/.test(t)) { const cleaned = t.replace(/^[\-•*●\d.]+\s*/, '').trim(); - if (cleaned.length > 2 && cleaned.length < 200) items.push(cleaned); + // Some bullets use "/" as sub-item separator: split and add each + if (cleaned.length > 2 && cleaned.length < 200) { + for (const part of cleaned.split(/\s*\/\s*/)) { + const p = part.trim(); + if (p.length > 2 && p.length < 200) items.push(p); + } + } + continue; + } + + // Plain-text lines that look like requirements (not section headers) + // Accept lines that start with a capital letter or common requirement verb + if ( + t.length > 10 && t.length < 250 && + /^[A-Z]/.test(t) && + !/^(requirements?|preferred|qualifications?|responsibilities?|benefits?|about\s|what\s+you|who\s+you|we\s+(are|offer))/i.test(t) + ) { + // Also split on "/" in plain-text lines (e.g. "Experience of X / Y / Z") + for (const part of t.split(/\s*\/\s*/)) { + const p = part.trim(); + if (p.length > 10 && p.length < 200) items.push(p); + } } } - // Plain-sentence fallback when bullets yielded nothing + // Plain-sentence fallback when nothing was collected at all if (items.length === 0) { block.split(/[.;]\s+/).forEach(sent => { const s = sent.trim();