Job Finder: design decisions and why
Most job searches turn into the same chore: check five boards, check a dozen company career pages, mentally re-filter for remote and salary every time. Job Finder pulls all of that into one place and scores each result against a CV. The more interesting part, for a portfolio, is the shape of the decisions underneath it. See it on the projects page for the repo link; this is the reasoning.
one interface, many sources
Aggregators (Adzuna, Reed), remote boards (Remotive, RemoteOK, Himalayas),
company ATS endpoints (Greenhouse, Lever, Ashby), and even a
schema.org/JobPosting scraper for bespoke career pages that expose nothing
else: all of these are wildly different APIs, or no API at all. They’re
forced behind one SourceAdapter interface:
class SourceAdapter(Protocol):
def fetch(self, ctx: FetchContext) -> Iterable[RawJob]: ...
Adding a source is a new file plus a registry entry, never a change to the
pipeline. fetch → normalise → dedup → upsert doesn’t know or care whether a
RawJob came from a JSON API or an HTML scrape.
the seam is the database
Postgres is the boundary between ingestion and serving. Celery Beat fires fetch tasks on a schedule; the API and the React SPA only ever read from Postgres, never from a source directly. That means a source going down, rate limiting, or being slow doesn’t touch the request path, and swapping or adding sources never means touching the frontend.
dedup across sources, not just within one
The same role often appears on more than one board with a different
external_id each time. A single content_hash (title, company, location)
catches re-fetches of the same listing from the same source cheaply: if
the hash hasn’t changed, skip it. A separate dedup_key, slug-based across
the same three fields, is what catches the same role appearing on two
different boards:
def make_dedup_key(title: str, company: str, location: str | None) -> str:
return f"{_slug(company)}::{_slug(title)}::{_slug(location or '')}"
Simple, and deliberately so: no fuzzy matching, no embeddings. It’s a cross-source join key, not a similarity search.
fencing the LLM into three jobs
It would be easy to let an LLM “handle” the whole pipeline: parse, classify, score, summarise, all in one prompt. Job Finder does the opposite. The LLM is used for exactly three well-defined tasks (CV parsing, title-variation expansion, and CV-to-job fit scoring), and nothing else is allowed to be non-deterministic. Ingestion, normalisation, and dedup are plain code with tests and fixtures per adapter.
The scoring prompt is also deliberately narrow about what it’s allowed to claim:
Capability fit score 0–100 … Do NOT factor in job-hunting probability, score capability only.
No fabricated “likelihood of getting this job” number, just matched skills,
gaps, and a small set of flags (stretch_role, missing_must_have,
below_salary_target, …). The provider itself is swappable (Anthropic or
OpenAI) behind one LLMProvider protocol, config-only, no rebuild.
multi-tenant from day one, single-user in practice
Users, search profiles, and CVs are first-class from the first migration, with OIDC auth and an allowlist. Even though the only real user will be me, friends can be added by pointing them at the same OIDC provider, with zero schema change. Building the tenancy boundary in from the start was cheaper than retrofitting it once “just for me” data model decisions had already been made elsewhere.
shipping it
Production is a self-contained docker-compose.prod.yml: postgres,
redis, a one-shot migrate service that runs Alembic before api starts,
api, worker, beat, and web (nginx serving the built SPA, proxying
/api/). Nothing sensitive lives in the repo; every secret is injected as an
environment variable, and a missing required one fails loudly at startup
rather than silently misconfiguring the stack.
It runs the same way the rest of the homelab does: Portainer polling the repo for GitOps-style redeploys, Traefik terminating TLS in front of it. One more service on infrastructure that already exists, not a new thing to operate.
what I’d do differently
The ATS adapters (Greenhouse, Lever, Ashby) need a company’s board token configured per source; there’s no discovery step that finds “does this company use Greenhouse” on its own. That’s a deliberate v1 scope cut, and the obvious next piece if this grows past a personal tool.