Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions extension-ready/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -864,6 +867,11 @@
<div class="missing-skill-help">Tick any skills you genuinely have — they'll be included in the tailored CV.</div>
<div class="match-chips" id="match-missing-chips"></div>
</div>
<div class="match-group" id="match-domain" hidden>
<div class="match-group-label match-label-purple">Also common in this domain</div>
<div class="missing-skill-help">Tools widely used in this type of role that aren't in your CV or the JD. Tick any you genuinely have.</div>
<div class="match-chips" id="match-domain-chips"></div>
</div>
</div>

<div id="tailor-warnings-box" class="tailor-warning-box" hidden></div>
Expand Down
39 changes: 34 additions & 5 deletions extension-ready/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand All @@ -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' +
Expand Down Expand Up @@ -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) {
Expand All @@ -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 ────────────────────────────────────────────────────────
Expand Down
102 changes: 99 additions & 3 deletions render-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};

Expand All @@ -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);
Expand Down
107 changes: 107 additions & 0 deletions shared/cv-tailor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading
Loading