Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
719934c
feat: add interview AI coaching with real OpenAI scoring
zackKonfs Mar 26, 2026
c32fbf3
feat: add voice to text for interview mic button
zackKonfs Mar 26, 2026
e698c36
Merge pull request #4 from Lightningwave/release
Lightningwave Mar 27, 2026
cbeb3d7
new update
anrulazrudinwee-stack Mar 28, 2026
3f32321
Merge pull request #9 from Lightningwave/anrulv2
Lightningwave Mar 28, 2026
d54b886
updated extracr url job matching
anrulazrudinwee-stack Mar 28, 2026
4c1da1e
Merge pull request #11 from Lightningwave/release
Lightningwave Mar 28, 2026
ec007e5
Merge pull request #12 from Lightningwave/anrulv2
Lightningwave Mar 28, 2026
176f85c
checkpoint: before resume review page redesign
zackKonfs Mar 28, 2026
b3a5b3f
feat: resume review redesign, template picker, photo export, score fr…
zackKonfs Mar 28, 2026
21cc745
fix: remove duplicate micState declarations breaking Vercel build
zackKonfs Mar 29, 2026
dc47701
feat: multilingual support across upload, review, builder and voice p…
zackKonfs Mar 29, 2026
c8ae2ca
chore: remove .claude folder, add to gitignore
zackKonfs Mar 29, 2026
eb6b6b3
Merge branch 'release' into zack/step2-improvements
Lightningwave Mar 30, 2026
8130f02
Merge pull request #13 from Lightningwave/zack/step2-improvements
Lightningwave Mar 30, 2026
6b2abbe
resolve merge corruption in resume, voice-build, interview, jobs reco…
Lightningwave Mar 30, 2026
b6bc2ca
Delete CLAUDE.md
Lightningwave Mar 30, 2026
62fd497
Merge pull request #15 from Lightningwave/zack/step2-improvements
Lightningwave Mar 30, 2026
c24bf3d
Merge pull request #16 from Lightningwave/release
Lightningwave Mar 30, 2026
3f0d0e3
Implement Google sign in
Lightningwave Mar 30, 2026
0f4b863
Implement interview and fix a fix ui issues on contract page
Lightningwave Mar 30, 2026
5c1b8c5
Merge pull request #17 from Lightningwave/van
Lightningwave Mar 30, 2026
a72c712
Merge branch 'an/job-recommendation' into testing
Lightningwave Mar 30, 2026
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
Binary file modified .gitignore
Binary file not shown.
151 changes: 151 additions & 0 deletions app/api/interviews/session/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { NextRequest, NextResponse } from "next/server";
import { getAuthenticatedUser, listResumes, getResume } from "@/lib/services/db";

export async function GET(req: NextRequest) {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
}

const { searchParams } = new URL(req.url);
const resumeId = searchParams.get("resume_id");
const interviewer = searchParams.get("interviewer") || "alex";

let resume;
if (resumeId) {
resume = await getResume(resumeId, user.id);
} else {
const resumes = await listResumes(user.id);
resume = resumes[0]; // Latest
}

if (!resume) {
return NextResponse.json({ detail: "No resume found. Please upload a resume first." }, { status: 404 });
}

const profile = resume.parsed_profile;
if (!profile) {
return NextResponse.json({ detail: "Resume is not yet profiled. Please wait for profiling to complete." }, { status: 400 });
}

// Construct the context string
const context = [
`Name: ${resume.file_name?.replace(/\.[^/.]+$/, "") || "Candidate"}`,
`Headline: ${profile.headline || "N/A"}`,
`Summary: ${profile.summary || "N/A"}`,
`Skills: ${profile.skills?.join(", ") || "N/A"}`,
`Experience:`,
...(profile.experiences || []).map(exp => `- ${exp.title} at ${exp.company} (${exp.start_date} - ${exp.end_date || 'Present'}): ${exp.description}`),
].join("\n");

const isSophia = interviewer.toLowerCase() === "sophia";
const sophiaAgentId = process.env.ELEVENLABS_SOPHIA_AGENT_ID?.trim();

if (isSophia && !sophiaAgentId) {
console.error("Missing ELEVENLABS_SOPHIA_AGENT_ID for Sophia interviewer.");
return NextResponse.json(
{
detail:
"Sophia interviewer is not configured. Set ELEVENLABS_SOPHIA_AGENT_ID in your environment (same as in .env.local for local dev).",
},
{ status: 500 },
);
}

/** Optional TTS override; Sophia's agent usually uses the voice configured in ElevenLabs for that agent. */
const defaultSophiaVoiceId = "SDNKIYEpTz0h56jQX8rA";
const sophiaVoiceId = process.env.ELEVENLABS_SOPHIA_VOICE_ID ?? defaultSophiaVoiceId;
const useVoiceOverride = isSophia && !!process.env.ELEVENLABS_SOPHIA_VOICE_ID;

const persona = isSophia ? {
name: "Sophia",
role: "Senior Executive Recruiter & Career Strategist",
personality: [
"Warm, poised, and highly professional — you sound like a trusted advisor, not an interrogator.",
"You care deeply about how candidates lead, collaborate, and grow: leadership presence, influence without authority, stakeholder communication, and resilience.",
"You probe cultural fit and motivation: why this role, why now, and how they would show up for a team in Singapore's fast-moving hiring landscape.",
"You listen for specifics: stories with context, trade-offs they made, and what they learned — not buzzwords.",
"You speak with a refined, encouraging tone; you give brief, genuine acknowledgement before moving to the next question.",
].join(" "),
voiceId: sophiaVoiceId,
agentId: sophiaAgentId!,
} : {
name: "Alex",
role: "Seasoned Hiring Manager",
personality: "Direct, practical, and fair. You focus on technical skills, problem-solving abilities, and concrete results. You value brisk, data-driven answers.",
voiceId: undefined,
agentId: process.env.ELEVENLABS_AGENT_ID
};

const interviewStyleExtra = isSophia
? `
## Interview style — Sophia (executive recruiter)
- **Focus**: Leadership stories, collaboration, stakeholder influence, motivation, and cultural fit. Avoid deep stack trivia unless it clearly matches their target role.
- **Opening energy**: Confident and welcoming; show you’ve read their profile (headline, key experiences).
- **Question style**: Behavioural and situational (“Tell me about a time…”, “How did you navigate…”) grounded in their CV.
- **Follow-ups**: At most one per question — ask for concrete context (trade-offs, who was involved, what changed) when answers stay generic.
- **Avoid**: Sounding robotic or cold; inventing employers, titles, or skills.
- **Tone**: Refined, encouraging, calm pacing; short sentences. Singapore: professional warmth with clarity.
`
: `
## Interview style — Alex (hiring manager)
- **Focus**: Execution, problem-solving, and proof — scope, tools, outcomes, and thinking under constraint. Tie questions to skills and experience bullets.
- **Opening energy**: Direct and respectful of time; signal you care about substance.
- **Question style**: Lean technical or operational (“Walk me through…”, “What was the hardest part…”, “How did you measure success…”). Ask for metrics, timeline, ownership, validation.
- **Follow-ups**: At most one per question — clarify what *they* did vs the team, and how they’d adapt next time.
- **Avoid**: Hostile tone; purely motivational fluff without linking to delivery; inventing facts about the candidate.
- **Tone**: Brisk, clear, minimal filler. Singapore: competence and clarity expected.
`;

const instructions = `
# Identity & Persona
- **Name**: ${persona.name}
- **Role**: ${persona.role}
- **Personality**: ${persona.personality}
${interviewStyleExtra}

# Sound human (not scripted)
- You are in a **live conversation**, not reading a checklist. Vary your wording; do not repeat the same filler every turn.
- Use **short bridges** between topics: a genuine one-liner reaction (“That’s helpful context,” “Got it — thanks for clarifying”) before the next question.
- **One question at a time.** Let them finish; then respond, then ask. If they go long, you may gently narrow (“To keep us on track — in one or two sentences, what was the outcome?”).
- If they seem nervous, **one calming line** is fine; then move on. Avoid sounding rushed or like a form.
- If they start talking over you, **stop immediately** and yield the floor.

# Structure (hidden agenda — keep it light on the surface)
- Cover **exactly three main question topics** across the session, each grounded in their profile and the **Interview style** above (Sophia vs Alex). Do **not** label them “Question 1/2/3” out loud.
- You may use **one short follow-up** per topic when something is vague — same topic only, then move on.
- Aim for a **short practice session** (roughly a few minutes of dialogue). Prefer **brief turns**: your spoken replies usually **well under ~30 seconds** so they get air time.
- If time is running short, **skip depth** and offer a warm, quick close instead of squeezing another topic.
- If they go quiet after you ask something, **one gentle nudge** (“Take your time — whenever you’re ready”) before rephrasing more simply.

# Candidate Profile
${context}

# Flow (guide, don’t robot-read)
1. **Opening**: Greet them by name if it appears naturally from the profile; mention you’ve looked at their background (${profile.headline || "their experience"}) in your own words — don’t quote blocks from the CV. Invite them to begin when ready (they can say “start”, “ready”, or equivalent — accept natural cues).
2. **Middle**: Explore three topics as a **flowing interview**: acknowledge what they said, then **build** (“Building on that…”, “I’m curious about…”, “One more angle…”). Avoid robotic pivots like “Next question:”.
3. **Close**: When you’ve fairly covered three topics (or time is tight), transition naturally (“Before we wrap…”, “Last thing from me…”). Share **one** strength and **one** concrete improvement idea in plain language, then thank them and wish them well. Don’t list bullets aloud.

# Guardrails
- **Do not invent** employers, titles, skills, or achievements not supported by the profile.
- Stay within **Interview style** for this persona (Sophia vs Alex).
`.trim();

const headlineHint = profile.headline || "your experience";
const firstMessage = isSophia
? `Hi — I'm ${persona.name}. I've had a chance to look at your background around ${headlineHint}, and I'm glad we could make this work. Whenever you're ready, we can get started — just let me know.`
: `Hi — I'm ${persona.name}. I've skimmed your profile around ${headlineHint}; we'll keep this efficient. Whenever you're ready to begin, just say the word.`;

if (!process.env.ELEVENLABS_API_KEY || !process.env.ELEVENLABS_AGENT_ID) {
console.error("Missing ElevenLabs Configuration in environment variables.");
return NextResponse.json({ detail: "ElevenLabs API Key or Agent ID is missing from environment variables." }, { status: 500 });
}

return NextResponse.json({
agent_id: persona.agentId,
dynamic_instructions: instructions,
first_message: firstMessage,
voice_id: isSophia ? persona.voiceId : undefined,
use_voice_override: useVoiceOverride,
});
}
3 changes: 3 additions & 0 deletions app/api/jobs/recommend/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { NextResponse } from "next/server";
import { getAuthenticatedUser, getResume, listResumes } from "@/lib/services/db";
import { getJobRecommendations } from "@/lib/services/jobRecommendation";

/** Uses Supabase auth (cookies); avoid static analysis during `next build`. */
export const dynamic = "force-dynamic";

export const maxDuration = 60;

export async function GET(req: Request) {
Expand Down
4 changes: 3 additions & 1 deletion app/api/jobs/scrape/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Only http/https URLs are supported" }, { status: 400 });
}

const job = await extractJobFromUrl(url);
console.log("Scraping URL:", url);
const job = await extractJobFromUrl(url);
console.log("Job result:", job.title, job.company);

return NextResponse.json({ job });
} catch (err) {
Expand Down
44 changes: 44 additions & 0 deletions app/api/places/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export const runtime = "nodejs";

import { NextRequest, NextResponse } from "next/server";
import { getAuthenticatedUser } from "@/lib/services/db";

export async function GET(req: NextRequest) {
const user = await getAuthenticatedUser();
if (!user) return NextResponse.json({ place: null }, { status: 401 });

const query = req.nextUrl.searchParams.get("query");
if (!query?.trim()) return NextResponse.json({ place: null });

const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) return NextResponse.json({ place: null });

try {
const res = await fetch("https://places.googleapis.com/v1/places:searchText", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": apiKey,
"X-Goog-FieldMask": "places.addressComponents",
},
body: JSON.stringify({ textQuery: query }),
});

if (!res.ok) return NextResponse.json({ place: null });

const data = (await res.json()) as {
places?: { addressComponents?: { longText: string; types: string[] }[] }[];
};

const components = data.places?.[0]?.addressComponents;
if (!components) return NextResponse.json({ place: null });

const locality = components.find((c) => c.types.includes("locality"))?.longText;
const country = components.find((c) => c.types.includes("country"))?.longText;
const location = [locality, country].filter(Boolean).join(", ");

return NextResponse.json({ place: location ? { location } : null });
} catch {
return NextResponse.json({ place: null });
}
}
89 changes: 56 additions & 33 deletions app/api/resume/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,48 @@ import { getAuthenticatedUser } from "@/lib/services/db";
const OPENAI_MODEL = "gpt-4o-mini";
const GROQ_FALLBACK_MODEL = "llama-3.1-8b-instant";

const SYSTEM_PROMPT = `You are a professional resume coach specialising in Singapore's white-collar job market.
const BASE_SYSTEM_PROMPT = `You are an elite Singapore resume advisor: language coach, ATS specialist, Singapore market expert (MOM standards, local hiring norms), and career progression analyst. Be specific — always reference actual resume content, never generic advice. Salary benchmarks must be SGD Singapore market rates.

Analyse the resume below and return ONLY a valid JSON object with exactly these fields:
Return ONLY valid JSON with exactly these fields:

{
"overallImpression": "2 to 3 sentence summary",
"keyStrengths": ["strength 1", "strength 2", "strength 3"],
"areasToImprove": ["gap 1", "gap 2", "gap 3"],
"suggestedEdits": ["edit 1", "edit 2", "edit 3"],
"score": 7
{"overallImpression":"3–4 sentences: profile strength, Singapore market positioning, target role fit","keyStrengths":["specific strength referencing actual resume content","strength 2","strength 3"],"areasToImprove":["specific gap explaining why it weakens the resume in Singapore's market","gap 2","gap 3"],"suggestedEdits":["exact rewrite (not advice) e.g. change 'Managed social media' to 'Grew combined social following 40% to 120k'","edit 2","edit 3"],"atsAnalysis":{"keywordsFound":["kw1","kw2"],"keywordsMissing":["missing1","missing2"],"atsFriendly":true,"atsNotes":"One sentence on ATS suitability"},"careerProgression":"2 sentences on trajectory logic and competitiveness for the candidate's level in Singapore","salaryBenchmark":{"estimatedRange":"SGD X,000–Y,000/month","rationale":"One sentence based on role, sector, and years of experience"},"score":5}

Score rubric (1–10): 2pts quantified achievements with numbers; 2pts Singapore market/MOM alignment; 2pts structure/ATS compatibility; 2pts action verb quality; 2pts completeness (contact, history, education, skills).

No text outside the JSON.`;

function getLanguageName(code: string): string {
const names: Record<string, string> = {
zh: "Simplified Chinese (简体中文)",
ms: "Bahasa Melayu",
ta: "Tamil (தமிழ்)",
};
return names[code] || "English";
}

Be specific to Singapore hiring standards. No generic advice. No text outside the JSON object.`;
function buildSystemPrompt(language?: string): string {
if (!language || language === "en") return BASE_SYSTEM_PROMPT;
const langName = getLanguageName(language);
const instruction = `IMPORTANT: You must respond entirely in ${langName}. All feedback, suggestions, and analysis must be written in ${langName}. Do not use English anywhere in your response.\n\n`;
return instruction + BASE_SYSTEM_PROMPT;
}

export interface ResumeFeedback {
overallImpression: string;
keyStrengths: string[];
areasToImprove: string[];
suggestedEdits: string[];
atsAnalysis: {
keywordsFound: string[];
keywordsMissing: string[];
atsFriendly: boolean;
atsNotes: string;
};
careerProgression: string;
salaryBenchmark: {
estimatedRange: string;
rationale: string;
};
score: number;
}

Expand All @@ -44,15 +67,16 @@ function extractJson(text: string): string {
return text.trim();
}

async function callLlm(resumeText: string): Promise<string> {
async function callLlm(resumeText: string, language?: string): Promise<string> {
const systemPrompt = buildSystemPrompt(language);
const openaiKey = process.env.OPENAI_API_KEY;
if (openaiKey) {
try {
const client = new OpenAI({ apiKey: openaiKey });
const response = await client.chat.completions.create({
model: OPENAI_MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "system", content: systemPrompt },
{ role: "user", content: resumeText.slice(0, 120_000) },
],
temperature: 0,
Expand All @@ -70,7 +94,7 @@ async function callLlm(resumeText: string): Promise<string> {
const response = await client.chat.completions.create({
model: GROQ_FALLBACK_MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "system", content: systemPrompt },
{ role: "user", content: resumeText.slice(0, 120_000) },
],
temperature: 0,
Expand All @@ -86,7 +110,8 @@ export async function POST(req: NextRequest) {

const formData = await req.formData();
const file = formData.get("file");
const voiceText = (formData.get("voiceText") as string) || "";
const language = (formData.get("language") as string | null) ?? "en";
const translatedText = (formData.get("translatedText") as string | null) ?? null;

if (!file || !(file instanceof Blob)) {
return NextResponse.json({ detail: "No file provided" }, { status: 400 });
Expand All @@ -102,31 +127,29 @@ export async function POST(req: NextRequest) {
}

let resumeText: string;
try {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const { text } = await extractText(uint8Array, { mergePages: true });
resumeText = text;
} catch (e) {
return NextResponse.json(
{ detail: `File could not be read: ${e instanceof Error ? e.message : e}` },
{ status: 422 },
);
}
if (translatedText && translatedText.trim()) {
resumeText = translatedText;
} else {
try {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const { text } = await extractText(uint8Array, { mergePages: true });
resumeText = text;
} catch (e) {
return NextResponse.json(
{ detail: `File could not be read: ${e instanceof Error ? e.message : e}` },
{ status: 422 },
);
}

if (!resumeText.trim()) {
return NextResponse.json({ detail: "File produced no text" }, { status: 422 });
if (!resumeText.trim()) {
return NextResponse.json({ detail: "File produced no text" }, { status: 422 });
}
}

const combinedInput = `RESUME DOCUMENT:
${resumeText}

ADDITIONAL CONTEXT FROM CANDIDATE:
${voiceText || "None provided"}`;

let raw: string;
try {
raw = await callLlm(combinedInput);
raw = await callLlm(resumeText, language);
} catch (e) {
return NextResponse.json(
{ detail: `AI analysis failed: ${e instanceof Error ? e.message : e}` },
Expand Down
Loading