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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
AI_API_KEY=your_api_key_here
AI_BASE_URL=https://api.openai.com/v1
AI_MODEL=gpt-4.1-mini
AI_PROVIDER=OpenAI

# Legacy aliases are still supported:
# OPENAI_API_KEY=
# OPENAI_BASE_URL=
# OPENAI_MODEL=
# GEMINI_API_KEY=
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
# GEMINI_MODEL=gemini-2.5-flash-lite
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,20 @@ cp .env.example .env

2. Set these values:

- `GEMINI_API_KEY`
- `GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/`
- `GEMINI_MODEL=gemini-2.5-flash-lite`
- `AI_API_KEY`
- `AI_BASE_URL`
- `AI_MODEL`
- `AI_PROVIDER` (optional, for display only)

OpenAI example:

- `AI_BASE_URL=https://api.openai.com/v1`
- `AI_MODEL=gpt-4.1-mini`

Gemini OpenAI-compatible example:

- `AI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/`
- `AI_MODEL=gemini-2.5-flash-lite`

3. Start the app:

Expand All @@ -216,7 +227,18 @@ npm start

Open `http://localhost:3000`

If the Gemini env vars are not set, the app still runs and falls back to local heuristic analysis.
Legacy `OPENAI_*` and `GEMINI_*` env vars are still supported, but `AI_*` is the recommended format for local use and Vercel.

If no AI env vars are set, the app still runs and falls back to local heuristic analysis.

### Vercel Environment Variables

Set the same `AI_*` variables in your Vercel project settings:

- `AI_API_KEY`
- `AI_BASE_URL`
- `AI_MODEL`
- `AI_PROVIDER` (optional)

### Run with Docker

Expand Down
177 changes: 141 additions & 36 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2620,6 +2620,8 @@ <h4>No analysis yet</h4>
let lastAnalysis = null;
let rtDebounce = null;
let aiConfig = { enabled: false, provider: "AI", model: "" };
let aiConfigReady = false;
let apiBaseUrl = "";
const memoryStore = new Map();

// ===== STORAGE =====
Expand Down Expand Up @@ -2682,19 +2684,50 @@ <h4>No analysis yet</h4>
};
}

function buildApiCandidates() {
const candidates = new Set();
if (window.location.protocol !== "file:" && window.location.origin) {
candidates.add(window.location.origin);
}

const localHostCandidates = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:8080",
"http://127.0.0.1:8080"
];

localHostCandidates.forEach(origin => candidates.add(origin));
return [...candidates];
}

function apiUrl(path) {
return `${apiBaseUrl}${path}`;
}

async function fetchAiConfig() {
try {
const res = await fetch("/api/config", { headers: { "Accept": "application/json" } });
if (!res.ok) throw new Error("Config unavailable");
const data = await res.json();
aiConfig = {
enabled: Boolean(data.enabled),
provider: data.provider || "AI",
model: data.model || ""
};
} catch {
aiConfig = { enabled: false, provider: "AI", model: "" };
const candidates = buildApiCandidates();
for (const origin of candidates) {
try {
const res = await fetch(`${origin}/api/config`, { headers: { "Accept": "application/json" } });
if (!res.ok) continue;
const data = await res.json();
apiBaseUrl = origin;
aiConfig = {
enabled: Boolean(data.enabled),
provider: data.provider || "AI",
model: data.model || ""
};
aiConfigReady = true;
return;
} catch {
// Try the next candidate origin.
}
}

apiBaseUrl = "";
aiConfig = { enabled: false, provider: "AI", model: "" };
aiConfigReady = true;
}

// ===== TEXT PROCESSING =====
Expand Down Expand Up @@ -2867,6 +2900,30 @@ <h4>No analysis yet</h4>
return line.replace(/\s+/g, " ").replace(/^[\W_]+|[\W_]+$/g, "").trim();
}

const jobTitleKeywordPattern = /(engineer|developer|analyst|manager|designer|specialist|consultant|administrator|architect|lead|intern|director|officer|coordinator|scientist|translator|writer|copywriter|marketer|recruiter|assistant|executive|strategist|producer|editor|accountant|bookkeeper|teacher|instructor|nurse|tester|qa|product owner|product manager|scrum master|support|sales|operations|seo|devops|sre|technician)/i;

function cleanJobTitleCandidate(value) {
let candidate = normalizeLine(value || "");
if (!candidate) return "";
candidate = candidate
.replace(/^(job title|title|role|position)\s*:\s*/i, "")
.replace(/^(we are|we're|company is)\s+(actively\s+)?(hiring|seeking|looking for)\s+(an?\s+)?/i, "")
.replace(/^(hiring|seeking|looking for)\s+(an?\s+)?/i, "")
.replace(/^(open position|job opening)\s*:\s*/i, "")
.replace(/\s+(to join|to support|who will|who can|responsible for|for our|for the|with experience in)\b.*$/i, "")
.replace(/\s{2,}/g, " ")
.replace(/[|•]+/g, " ")
.replace(/\s{2,}/g, " ")
.trim()
.replace(/[,:;.-]+$/g, "")
.trim();

if (!candidate || candidate.length < 3 || candidate.length > 90) return "";
if (/^(responsibilities|requirements|about (the role|us|company)|overview|location|salary|benefits)$/i.test(candidate)) return "";
if (!jobTitleKeywordPattern.test(candidate)) return "";
return candidate;
}

function isResumeSectionHeading(line) {
return /^(professional summary|summary|profile|experience highlights|experience|employment|work history|projects|project highlights|skills|additional skills|technical skills|core skills|education|academic|certifications|certificates)$/i.test(line);
}
Expand All @@ -2886,16 +2943,33 @@ <h4>No analysis yet</h4>

function extractJobTitle(jd) {
const lines = jd.replace(/\r/g, "").split("\n").map(normalizeLine).filter(Boolean);
for (const line of lines.slice(0, 12)) {
if (line.length > 90) continue;
const candidates = [];

for (const [index, line] of lines.slice(0, 18).entries()) {
if (line.length > 140) continue;
if (/responsibilities|requirements|about the role|about us|company overview|what you('|’)ll do|what we're looking for|preferred qualifications/i.test(line)) continue;

const explicit = /^(job title|title|role|position)\s*:\s*(.+)$/i.exec(line);
if (explicit?.[2]) return explicit[2].trim();
if (/responsibilities|requirements|about the role|about us|company overview/i.test(line)) continue;
if (/engineer|developer|analyst|manager|designer|specialist|consultant|administrator|architect|lead|intern|director|officer|coordinator|scientist|translator|writer|copywriter|marketer|recruiter|freelancer|assistant/i.test(line)) {
return line;
}
const directCandidate = cleanJobTitleCandidate(explicit?.[2] || line);
if (!directCandidate) continue;

let score = 100 - index * 3;
if (explicit?.[2]) score += 40;
if (line.length <= 70) score += 12;
if (!/[.!?]$/.test(line)) score += 6;
if (/senior|junior|lead|principal|staff|head|associate|intern/i.test(directCandidate)) score += 8;
if (/ at | with | for | who | and | responsible /i.test(directCandidate)) score -= 18;

candidates.push({ title: directCandidate, score });
}
return "";

if (candidates.length) {
candidates.sort((a, b) => b.score - a.score || a.title.length - b.title.length);
return candidates[0].title;
}

const sentenceMatch = jd.match(new RegExp(`(?:hiring|seeking|looking for|position(?:\\s+is)?|role(?:\\s+is)?)\\s+(?:an?\\s+)?([A-Za-z][A-Za-z/&,()\\- ]{2,80}?\\b${jobTitleKeywordPattern.source}\\b[A-Za-z/&,()\\- ]{0,30})`, "i"));
return cleanJobTitleCandidate(sentenceMatch?.[1] || "");
}

function buildSessionMeta(jd, resume) {
Expand All @@ -2908,10 +2982,28 @@ <h4>No analysis yet</h4>
return {
label,
roleFamily,
resumeIdentity,
roleTitle: roleTitle || roleFamily
};
}

function resolveSessionDetails(meta, analysis) {
const aiRoleTitle = normalizeLine(analysis?.roleTitle || "");
const aiRoleFamily = normalizeLine(analysis?.roleFamily || "");
const hasUsefulAiRole = aiRoleTitle && !/^resume match analysis$/i.test(aiRoleTitle);
const roleTitle = hasUsefulAiRole ? aiRoleTitle : meta.roleTitle;
const roleFamily = aiRoleFamily || meta.roleFamily;
const label = roleTitle
? (meta.resumeIdentity ? `${meta.resumeIdentity} - ${roleTitle}` : roleTitle)
: meta.label;

return {
label,
roleFamily,
roleTitle
};
}

function getRoleAlignmentPattern(roleFamily) {
const patterns = {
"Translation / Localization": /translate|translation|translator|arabic|english|dialect|localiz|linguistic|proofread|bilingual/,
Expand Down Expand Up @@ -3536,18 +3628,12 @@ <h4>No analysis yet</h4>
function saveSession(analysis) {
const history = getHistory();
const meta = buildSessionMeta(el.jd.value, el.resume.value);

// Prioritize AI-detected roles for a more accurate label
const sessionRole = analysis.roleTitle || meta.roleTitle;
const sessionFamily = analysis.roleFamily || meta.roleFamily;
const sessionLabel = analysis.roleTitle
? (meta.resumeIdentity ? `${meta.resumeIdentity} - ${analysis.roleTitle}` : analysis.roleTitle)
: meta.label;
const session = resolveSessionDetails(meta, analysis);

const item = {
label: sessionLabel,
roleFamily: sessionFamily,
roleTitle: sessionRole,
label: session.label,
roleFamily: session.roleFamily,
roleTitle: session.roleTitle,
score: analysis.score,
source: analysis.source || (aiConfig.enabled ? "ai" : "local"),
savedAt: new Date().toLocaleString(),
Expand Down Expand Up @@ -3604,7 +3690,7 @@ <h4>No analysis yet</h4>
}

async function analyzeWithAi(jd, resume) {
const response = await fetch("/api/analyze", {
const response = await fetch(apiUrl("/api/analyze"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand All @@ -3625,6 +3711,10 @@ <h4>No analysis yet</h4>
function runAnalysis() {
const jd = el.jd.value.trim();
const resume = el.resume.value.trim();
if (!aiConfigReady) {
setStatus("Preparing analysis...");
return;
}
if (!jd || !resume) {
lastAnalysis = null;
updateActionStates();
Expand All @@ -3647,6 +3737,10 @@ <h4>No analysis yet</h4>
async function runAnalysisWithLoader() {
const jd = el.jd.value.trim();
const resume = el.resume.value.trim();
if (!aiConfigReady) {
setStatus("Preparing AI...");
return;
}
if (!jd || !resume) {
lastAnalysis = null;
updateActionStates();
Expand Down Expand Up @@ -3683,7 +3777,10 @@ <h4>No analysis yet</h4>
lastAnalysis = null;
updateActionStates();
showEmptyState();
setStatus(error.message || "Analysis failed.");
const backendHint = window.location.protocol === "file:" || /localhost|127\.0\.0\.1/.test(window.location.hostname)
? " AI backend not found. Run `npm start` and open http://localhost:3000."
: "";
setStatus((error.message || "Analysis failed.") + backendHint);
}
} finally {
el.loader.classList.remove("visible");
Expand All @@ -3694,6 +3791,11 @@ <h4>No analysis yet</h4>
function scheduleRealTimeAnalysis() {
clearTimeout(rtDebounce);
updateCounts();
if (!aiConfigReady) {
setStatus("Preparing AI...");
showEmptyState();
return;
}
const jd = el.jd.value.trim();
const resume = el.resume.value.trim();
if (!jd || !resume) {
Expand Down Expand Up @@ -3845,10 +3947,11 @@ <h4>No analysis yet</h4>
if (!lastAnalysis) { setStatus("Run analysis first."); return; }
const history = getHistory();
const meta = buildSessionMeta(el.jd.value, el.resume.value);
const session = resolveSessionDetails(meta, lastAnalysis);
const item = {
label: meta.label,
roleFamily: meta.roleFamily,
roleTitle: meta.roleTitle,
label: session.label,
roleFamily: session.roleFamily,
roleTitle: session.roleTitle,
score: lastAnalysis.score,
source: lastAnalysis.source || (aiConfig.enabled ? "ai" : "local"),
savedAt: new Date().toLocaleString(),
Expand Down Expand Up @@ -3990,12 +4093,14 @@ <h4>No analysis yet</h4>
updateCounts();
updateActionStates();
showEmptyState();
setStatus(aiConfig.enabled ? "Ready for AI analysis." : "Ready when you are.");
setStatus(aiConfig.enabled
? "Ready for AI analysis."
: "AI backend not detected. Run `npm start` and open http://localhost:3000.");
observeReveals();
}

initApp();
</script>
</body>

</html>
</html>
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"start": "node server.mjs",
"dev": "node server.mjs",
"static": "serve -s . -p 3000",
"static": "node server.mjs",
"build": "node ./scripts/validate-project.mjs",
"lint": "node ./scripts/validate-project.mjs",
"test": "node ./scripts/smoke-static-site.mjs",
Expand Down
Loading
Loading