OnePercentJobs · AI job matching · Python · chat-first
A shorter honest digest beats a padded one
OnePercentJobs is a job-hunting copilot for people outside tech: finance, consulting and startup roles in India. Stop searching; five to ten hand-picked roles arrive in your chat every morning, each with one line on why it fits you. The work is in making sure every one of them actually does.
Role
Sole engineer
Stack
FastAPI, Celery, pgvector
Decisions
29 written design records
Tests
180, across 24 files
A job search that comes to you
There is no dashboard to check. You tell it about yourself once, in a chat, and from the next morning the right roles come to you. Everything else happens in the same conversation.
For job seekers
Feature
What it does
Onboarding as a chat
The basics, plus two short questions that make the matching personal: a project you are proud of, and why this industry.
Your CV, if you like
Upload a PDF or Word CV and its facts are added to your profile.
A morning digest
Five to ten roles before you wake up. Fewer on a thin day, never padded.
The Why Line
One sentence per role on why it fits you, citing only things you actually said.
Talk back
Reply to change your preferences, give feedback on a role or manage your plan. An agent with tools handles it.
On the channel you use
Telegram and a web chat with a magic-link sign-in and the digest by email, with WhatsApp next on the same interface.
Behind it
Feature
What it does
Nightly harvest
Job boards, YC and company hiring pages, read from sitemaps and ATS feeds, with duplicates folded together.
Admin gate
Digests are built early and held for an optional human look before they go out automatically.
Payments
Razorpay UPI autopay, with signed webhooks handled exactly once.
Job search is hours of scrolling for minutes of signal
Someone looking for a finance or consulting role checks the same five sites every day, wades through the same reposted listings, and applies to whatever looks close enough. In a poll of a few hundred job seekers, searching across many sources came up as the biggest time sink by far.
The obvious product is a feed that ranks jobs for you. The hard part is trust. A list that is mostly right but sometimes sends a fresher a Vice President role does more harm than no list at all, because after the first bad one nobody reads the rest.
How I got there
Three passes, in the order they happened.
Explore
Collect everything, rank by similarity, let a model explain
Realise
A live test sent a fresher a senior role, with a confident reason
Insight
Cheap sources, hard guards, a skeptical second pass
Rules first, model second, and a second model that says no.
From every posting to a handful
Four steps, cheapest first. Each one only sees what the one before it let through, so the expensive model calls are spent on very few postings.
Harvest from sitemaps, not pages
Job boards are read from their sitemaps and company boards from their ATS JSON. No headless browsers and no anti-bot fights. Postings are fingerprinted on company, title and location so a reposted job is one job.
app/harvest/
Hard rules in SQL
Role family, experience band, freshness and whether the listing is still live, all in one query. Unknown values pass; only a known mismatch is a no.
app/matching/retrieval.py
Retrieve with pgvector
The 30 to 50 postings closest to your profile, in the same Postgres query as the filters. One database, nothing to keep in sync.
Curate
A model re-ranks the shortlist against your story and writes a Why Line for each pick. It is told not to pad to a target count.
app/matching/curation.py
Verify, and drop
A second, skeptical pass checks each pick against your actual facts and drops it if the seniority, the domain or the reason does not hold. Kept picks get their Why Line rewritten to cite only what you said.
The live test that changed the design
In an early test, a seeker with two years of experience was shown a Senior Software Engineer role and a 12 to 16 year leadership role. The cause was upstream: one source gave a title and nothing else, so experience was blank on every posting, the lenient filter let them all through, and matching fell back to title similarity, which cannot tell junior from senior. Then the model wrote a confident reason for each mismatch.
Three fixes followed. A deterministic guard drops clearly senior titles for junior seekers. The verification pass was added after curation. And postings with no job description at all are kept out, a rule on the defect rather than a ban on any one source.
The guard is deliberately narrow
It fires only on unambiguous markers and only for juniors. Anything ambiguous goes on to the verification pass, which has the whole profile.
# 'lead'/'leader'/'manager' are deliberately EXCLUDED - too ambiguous
# ('Lead Generation', 'Associate Manager', 'Team Lead') - and left to verification.
_SENIOR_MARKERS = (
" senior ", " sr. ", " sr ", " staff ", " principal ", " head of ", " head, ",
" director", " vp ", " vp, ", " vice president", " president", " chief ",
)
def is_senior_title(title: str) -> bool:
t = f" {title.lower().strip()} "
# 'Chief of Staff' is an early-career founder's-office role, not a C-suite seat.
if "chief of staff" in t:
return False
return any(marker in t for marker in _SENIOR_MARKERS)Written down before they were built
Every real choice has a short design record: what was decided, what else was considered, and why.
Decision
Why
pgvector, not a separate vector database
Filtered vector search is one SQL query, and there is no second store to keep in sync. Retrieval sits behind an interface so it can move if it ever needs to.
Postgres is the only source of truth
Celery and Redis move work around, but every task is idempotent and every fact lives in Postgres, so a lost message is a retry, not lost data.
Batch the nightly extraction
Pulling structured facts out of thousands of postings does not need to be instant, so it is built to run through the batch API at half the price. Curation has a deadline, so it runs live.
One channel interface
Telegram and web chat with email sit behind one messaging interface, so a new channel like WhatsApp is an adapter, not a rewrite.
One model provider
Every model call goes through OpenRouter, so switching models is a config change.
What OnePercentJobs came down to:
01
Cheapest step first
Sitemaps, then SQL rules, then vectors, then models. Each stage only sees what survived the last.
02
A model that says no
A skeptical second pass drops any pick the profile cannot back, and rewrites the reason for the rest.
03
Decide on paper
29 design records, so every trade-off was argued once and can be read again.