diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dca44c0 --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index ea08118..181b43c 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/index.html b/index.html index eb8e6bf..1bcccb1 100644 --- a/index.html +++ b/index.html @@ -2620,6 +2620,8 @@

No analysis yet

let lastAnalysis = null; let rtDebounce = null; let aiConfig = { enabled: false, provider: "AI", model: "" }; + let aiConfigReady = false; + let apiBaseUrl = ""; const memoryStore = new Map(); // ===== STORAGE ===== @@ -2682,19 +2684,50 @@

No analysis yet

}; } + 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 ===== @@ -2867,6 +2900,30 @@

No analysis yet

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); } @@ -2886,16 +2943,33 @@

No analysis yet

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) { @@ -2908,10 +2982,28 @@

No analysis yet

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/, @@ -3536,18 +3628,12 @@

No analysis yet

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(), @@ -3604,7 +3690,7 @@

No analysis yet

} 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", @@ -3625,6 +3711,10 @@

No analysis yet

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(); @@ -3647,6 +3737,10 @@

No analysis yet

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(); @@ -3683,7 +3777,10 @@

No analysis yet

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"); @@ -3694,6 +3791,11 @@

No analysis yet

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) { @@ -3845,10 +3947,11 @@

No analysis yet

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(), @@ -3990,7 +4093,9 @@

No analysis yet

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(); } @@ -3998,4 +4103,4 @@

No analysis yet

- \ No newline at end of file + diff --git a/package.json b/package.json index 11eb029..9c4d158 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server.mjs b/server.mjs index 0d59820..9f7f58b 100644 --- a/server.mjs +++ b/server.mjs @@ -27,11 +27,54 @@ loadEnvFile(); const port = Number(process.env.PORT || 3000); +function firstEnv(...keys) { + for (const key of keys) { + const value = process.env[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +} + +function inferProviderName(baseUrl, explicitProvider) { + const declared = String(explicitProvider || "").trim(); + if (declared) return declared; + + const normalizedUrl = String(baseUrl || "").toLowerCase(); + if (normalizedUrl.includes("googleapis.com") || normalizedUrl.includes("/openai")) return "Gemini"; + if (normalizedUrl.includes("api.openai.com")) return "OpenAI"; + if (normalizedUrl.includes("openrouter.ai")) return "OpenRouter"; + if (normalizedUrl.includes("groq.com")) return "Groq"; + if (normalizedUrl.includes("together.xyz")) return "Together AI"; + return "AI"; +} + +function resolveBaseUrl() { + const configuredBaseUrl = firstEnv("GEMINI_BASE_URL", "AI_BASE_URL", "OPENAI_BASE_URL"); + if (configuredBaseUrl) return configuredBaseUrl; + + if (firstEnv("GEMINI_API_KEY")) { + return "https://generativelanguage.googleapis.com/v1beta/openai/"; + } + + if (firstEnv("OPENAI_API_KEY", "AI_API_KEY")) { + return "https://api.openai.com/v1"; + } + + return ""; +} + +function resolveDefaultModel() { + return firstEnv("GEMINI_MODEL", "AI_MODEL", "OPENAI_MODEL") + || (firstEnv("GEMINI_API_KEY") ? "gemini-2.5-flash-lite" : "gpt-4.1-mini"); +} + +const resolvedBaseUrl = resolveBaseUrl(); + const aiConfig = { - provider: "AI", - model: process.env.GEMINI_MODEL || "gemini-2.5-flash-lite", - apiKey: process.env.GEMINI_API_KEY || "", - baseUrl: process.env.GEMINI_BASE_URL || "https://generativelanguage.googleapis.com/v1beta/openai/" + provider: inferProviderName(resolvedBaseUrl, firstEnv("GEMINI_PROVIDER", "AI_PROVIDER", "OPENAI_PROVIDER")) || "Gemini", + model: resolveDefaultModel(), + apiKey: firstEnv("GEMINI_API_KEY", "AI_API_KEY", "OPENAI_API_KEY"), + baseUrl: resolvedBaseUrl }; const activeProvider = aiConfig; @@ -58,8 +101,9 @@ function getChatCompletionsUrl(rawBaseUrl, providerName = "") { const trimmed = String(rawBaseUrl || "").trim().replace(/\/+$/, ""); if (!trimmed) return ""; if (/\/chat\/completions$/i.test(trimmed)) return trimmed; + if (/\/responses$/i.test(trimmed)) return trimmed.replace(/\/responses$/i, "/chat/completions"); if (/\/openai$/i.test(trimmed)) return `${trimmed}/chat/completions`; - if (providerName === "AI") return `${trimmed}/chat/completions`; + if (/gemini/i.test(providerName) || providerName === "AI") return `${trimmed}/chat/completions`; if (/\/v1$/i.test(trimmed)) return `${trimmed}/chat/completions`; return `${trimmed}/v1/chat/completions`; } @@ -83,6 +127,81 @@ function normalizeLine(line) { return String(line || "").replace(/\s+/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|with)\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 extractJobTitle(jd) { + const lines = String(jd || "").replace(/\r/g, "").split("\n").map(normalizeLine).filter(Boolean); + 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); + 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 }); + } + + if (candidates.length) { + candidates.sort((a, b) => b.score - a.score || a.title.length - b.title.length); + return candidates[0].title; + } + + const sentenceMatch = String(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 detectRoleFamily(text) { + const s = String(text || "").toLowerCase(); + if (/translation|translator|arabic|english|localization|localisation/.test(s)) return "Translation / Localization"; + if (/content writer|copywriter|copy writing|content creation|blog/.test(s)) return "Content / Copywriting"; + if (/customer support|customer service|support specialist|help desk/.test(s)) return "Customer Support"; + if (/recruiter|recruitment|talent acquisition|human resources|hr /.test(s)) return "People / HR"; + if (/sales|business development|account executive|lead generation/.test(s)) return "Sales / Business Development"; + if (/operations|coordinator|administrator|administrative|virtual assistant/.test(s)) return "Operations / Administration"; + if (/designer|ux|ui|figma|product design/.test(s)) return "Design"; + if (/teacher|instructor|curriculum|education|tutor/.test(s)) return "Education"; + if (/marketing|seo|campaign|brand|growth/.test(s)) return "Marketing"; + if (/devops|infrastructure|platform|cloud/.test(s)) return "DevOps / Platform"; + if (/frontend|front-end|react|ui/.test(s)) return "Frontend Engineering"; + if (/backend|api|microservice|server/.test(s)) return "Backend Engineering"; + if (/full-stack|full stack/.test(s)) return "Full-Stack Engineering"; + if (/data analyst|analytics|bi|dashboard|sql/.test(s)) return "Data / Analytics"; + if (/product manager|roadmap|stakeholder/.test(s)) return "Product / Delivery"; + return "General Professional"; +} + function parseResumeSections(text) { const lines = String(text || "").replace(/\r/g, "").split("\n"); const sections = []; @@ -206,8 +325,26 @@ function ensureArray(value) { return Array.isArray(value) ? value : []; } +function sumWeights(items, multiplier = 1) { + return ensureArray(items).reduce((total, item) => total + clamp(item?.weight || 0, 0, 100) * multiplier, 0); +} + +function calibrateScore(rawScore, highMatch, partialMatch, missing) { + const matchedWeight = sumWeights(highMatch) + sumWeights(partialMatch, 0.55); + const missingWeight = sumWeights(missing); + const evidenceTotal = matchedWeight + missingWeight; + const evidenceScore = evidenceTotal ? (matchedWeight / evidenceTotal) * 100 : rawScore; + + let calibrated = rawScore * 0.58 + evidenceScore * 0.42; + + if (missing.some(item => item.weight >= 18)) calibrated = Math.min(calibrated, 84); + if (missing.length >= 6) calibrated = Math.min(calibrated, 78); + if (highMatch.length < 3) calibrated = Math.min(calibrated, 74); + + return clamp(Math.round(calibrated * 10) / 10, 0, 100); +} + function normalizeAnalysisShape(raw, jd, resume) { - const score = clamp(raw?.score, 0, 100); const missing = ensureArray(raw?.missing).map(item => ({ term: normalizeLine(item?.term), weight: clamp(item?.weight || 12, 1, 100), @@ -270,12 +407,15 @@ function normalizeAnalysisShape(raw, jd, resume) { reason: normalizeLine(item?.reason) || "Add a concrete result tied to this requirement." })).filter(item => item.term).slice(0, 8); + const score = calibrateScore(clamp(raw?.score, 0, 100), highMatch, partialMatch, missing); + const confidence = ["High Match", "Medium Match", "Low Match"].includes(raw?.confidence) ? raw.confidence : score >= 78 ? "High Match" : score >= 52 ? "Medium Match" : "Low Match"; - const roleTitle = normalizeLine(raw?.roleTitle) || "Resume Match Analysis"; - const roleFamily = normalizeLine(raw?.roleFamily) || "General Professional"; + const extractedRoleTitle = extractJobTitle(jd); + const roleFamily = normalizeLine(raw?.roleFamily) || detectRoleFamily(jd); + const roleTitle = extractedRoleTitle || normalizeLine(raw?.roleTitle) || roleFamily; const summary = normalizeLine(raw?.summary) ? String(raw.summary) @@ -325,6 +465,35 @@ function extractJson(text) { } } +function extractTextFromContentParts(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + + return content.map(part => { + if (typeof part === "string") return part; + if (typeof part?.text === "string") return part.text; + if (typeof part?.output_text === "string") return part.output_text; + return ""; + }).join("\n").trim(); +} + +function extractResponseText(parsedResponse) { + const messageContent = parsedResponse?.choices?.[0]?.message?.content; + const messageText = extractTextFromContentParts(messageContent); + if (messageText) return messageText; + + if (typeof parsedResponse?.output_text === "string" && parsedResponse.output_text.trim()) { + return parsedResponse.output_text.trim(); + } + + return ensureArray(parsedResponse?.output) + .flatMap(item => ensureArray(item?.content)) + .map(part => part?.text || part?.output_text || "") + .filter(Boolean) + .join("\n") + .trim(); +} + async function requestAiAnalysis(jd, resume) { const endpoint = getChatCompletionsUrl(activeProvider.baseUrl, activeProvider.provider); if (!activeProvider.apiKey || !endpoint) { @@ -385,7 +554,7 @@ async function requestAiAnalysis(jd, resume) { throw new Error(`AI analyze failed: ${detail}`); } - const content = parsedResponse?.choices?.[0]?.message?.content; + const content = extractResponseText(parsedResponse); if (!content) { throw new Error("The AI response was empty."); } @@ -459,6 +628,7 @@ export default async function handler(req, res) { json(res, 404, { error: "Not found." }); } catch (error) { + console.error("[resume-matcher]", error); json(res, 500, { error: error instanceof Error ? error.message : "Unexpected server error." }); } } diff --git a/vercel.json b/vercel.json index f94f748..d053587 100644 --- a/vercel.json +++ b/vercel.json @@ -5,10 +5,12 @@ "public": true, "builds": [ { "src": "server.mjs", "use": "@vercel/node" }, - { "src": "**", "use": "@vercel/static" } + { "src": "index.html", "use": "@vercel/static" }, + { "src": "health.html", "use": "@vercel/static" } ], "rewrites": [ - { "source": "/api/(.*)", "destination": "/server.mjs" }, + { "source": "/api/config", "destination": "/server.mjs" }, + { "source": "/api/analyze", "destination": "/server.mjs" }, { "source": "/health", "destination": "/health.html" }, { "source": "/(.*)", "destination": "/index.html" } ],