Vacanti AI
AI job matching SaaS, designed, built and shipped solo. Live in production.
A matching engine with four-axis scoring, embeddings on pgvector, and a vision-based evaluation pipeline that catches false positives before users see them. Next.js, Supabase, Drizzle and Stripe, taken to production by one person.
What I built
Vacanti AI is an AI job matching SaaS for job seekers in Australia. I designed, built and shipped it alone, from the matching engine to payments, and it runs in production today.

The matching engine
Scoring is a four-axis weighted model (title, seniority, years and embedding similarity) with guardrails on top. The one that mattered most is a hard ceiling for title matches without domain experience, because the worst failure of a matcher is a confident false positive. Embeddings run on pgvector inside Supabase; job extraction uses an LLM with structured output.

Measuring quality before users see it
I built an evaluation pipeline that renders real resumes and scores them with a vision model across twenty-one cases, so scoring changes get checked against ground truth before deploy. It caught real failures: high scores that looked right and were wrong.
Product, stack, and the first users
Around the engine sits an application workspace: resume tailoring, cover letters, screening answers and a kanban of applications. The stack is Next.js, Supabase with pgvector, Drizzle, Clerk and Stripe, with the UI in English and Japanese.

Taking it to market
Building it was half the job; the other half was selling it. I produced the brand and promo video above and took Vacanti into language schools in Sydney, running job-hunting workshops where students got their market value scored live and walked through their first matches in person. I still run the product's Instagram, and the workshops doubled as user acquisition: teach the room, and the room signs up.


/* v15 4-axis title-weighted scorer (career-changer friendly)
* embedding' = (titleAxis=100 && embeddingAxis<70) ? 70 : embeddingAxis
* core = title*0.50 + level*0.25 + years*0.15 + embedding'*0.10
* raw = core + clamp(0..+8, languageBonus)
* ceiling = 50 + level_axis // level-floor rule
* final = clamp(40, min(raw, ceiling), 100)
*
* v20: title-match-without-domain-experience hard ceiling.
* A target list with a broad token like "Sales" gave titleAxis=100
* against any role containing the word, producing a 100/100 score
* for an obvious mismatch. Fix: when titleAxis=100 but
* candidateYearsByDomain[jobDomain]=0, cap the final score at 85. */