Tech Stack
Every dependency was researched and validated. Here's what we use and why.
Core
| Package |
Purpose |
What |
| Python 3.14+ |
Language |
Async I/O everywhere (API, DB, scraping) + the richest AI/data library ecosystem |
| FastAPI |
HTTP framework |
Need auto-generated OpenAPI docs and native async — both missing in Flask/Django |
| uvicorn |
ASGI server |
FastAPI speaks ASGI, not WSGI — uvicorn is the standard ASGI server |
| Pydantic |
Data validation & serialization |
Python doesn't enforce types at runtime — Pydantic validates data boundaries and structures LLM output |
| SQLAlchemy 2.x |
ORM + async |
Handwritten SQL across many tables becomes unmaintainable — ORM maps objects to rows with async support |
| asyncpg |
PostgreSQL async driver |
A synchronous DB driver (psycopg2) blocks the entire async event loop |
| Alembic |
DB migrations |
Schema changes applied manually drift between environments — Alembic tracks and replays them |
| uv |
Package manager |
Reproducible installs need a lockfile; pip doesn't have one, poetry is slow — uv does both fast |
Job Data Sources
| Source |
Method |
Purpose |
| Adzuna API |
HTTP API (free) |
Primary job data source — structured JSON, no scraping |
| Seek.com.au |
httpx + beautifulsoup4 |
Fallback scraper for AU jobs |
| LinkedIn |
httpx + beautifulsoup4 |
Fallback scraper for public listings |
| ~~Indeed~~ |
~~playwright~~ |
Deferred — Cloudflare protection, high ban risk |
Networking & Scraping
| Package |
Purpose |
What |
| httpx |
Async HTTP client |
Need non-blocking HTTP calls throughout the app — httpx is async-native unlike requests |
| beautifulsoup4 |
HTML parsing |
Seek/LinkedIn serve HTML, not JSON — need a parser to extract job data from DOM |
LLM Integration
| Package |
Purpose |
What |
| litellm |
Unified LLM client |
Each LLM provider has its own SDK and API shape — litellm abstracts them into one completion() call |
Why LiteLLM?
- Multi-provider: Claude, GPT, Gemini, Mistral, DeepSeek — switch models by changing a string
- Unified
completion() / acompletion() API — no per-provider code paths
- Structured output via
response_format with Pydantic models
- Streaming, tool use, fallback/retry all built-in
- Lightweight wrapper, not a framework
Alternatives considered
| Alternative |
Verdict |
anthropic SDK directly |
Good for Claude-only, but we need multi-provider |
pydantic-ai |
Promising, but LiteLLM is more mature and has wider model coverage |
langchain |
Overkill — too much abstraction for our use case |
CLI Client
| Package |
Purpose |
What |
| typer |
Argument parsing |
CLI needs subcommands, help text, and shell completions — typer generates all from type hints |
| httpx |
HTTP client (reused) |
CLI calls the API server over HTTP — reuses the same httpx already in the project |
| rich |
Terminal formatting |
Job listings and config output need structured display — rich renders tables, progress bars, colors |
Web Frontend
The frontend is in frontend/ — see Web App reference for usage.
| Package |
Purpose |
What |
| React 19 |
Web framework |
Daily job tracking needs a visual dashboard — CLI alone isn't enough for reviewing and managing applications |
| Vite+ |
Unified toolchain |
Wraps Vite 8 (build/dev) + Vitest 4 (tests) + Oxc (lint/fmt) under one vp CLI — one binary instead of stitching together npm scripts for vite/vitest/eslint/prettier |
| TypeScript 5.7+ |
Type checking |
Catches contract drift between frontend and backend Pydantic models at build time |
| @testing-library/react 16 |
Component testing |
Behavioral assertions (queries by role/label) instead of testing implementation details |
No UI component library — the design system lives in frontend/src/styles/tokens.css (CSS custom properties) and frontend/src/components/Primitives.tsx (Button, Card, Badge, Chip, etc.). Inline styles bind to the tokens.
Why Vite+ instead of standalone Vite/Vitest?
Vite+ is from VoidZero — the same team behind Vite, Vitest, and Oxc. It's a thin unification layer (still pre-1.0 at v0.1.18 but actively shipping):
- One CLI for the whole loop —
vp dev, vp build, vp test, vp check, vp fmt instead of npm run dev / vitest / eslint / prettier
- Strict type-aware lint included — caught 8 hygiene issues on the first run that vanilla
tsc --noEmit had been silent about
- Bundled Node manager (
vp env) — replaces nvm/asdf for this project
vp migrate auto-converted the existing Vite/Vitest setup; rollback is just one commit revert if needed
Infrastructure
| Tool |
Purpose |
What |
| Docker Compose |
Local orchestration |
Local dev needs Postgres running — one docker compose up instead of installing natively |
| PostgreSQL 18 |
Database |
Job data has nested fields (skills, keywords) and needs full-text search — Postgres handles both with JSONB and tsvector |
| Supabase |
Cloud database (optional) |
Some environments can't run Docker — Supabase provides free hosted Postgres with zero setup |
Key Decisions
PostgreSQL vs SQLite
- JSONB native support for skills, keywords, analysis results
- Full-text search built-in (useful for job searching/filtering)
- Async driver (asyncpg) pairs perfectly with FastAPI
- Docker makes local setup trivial (
docker compose up -d)
- Supabase option for cloud hosting (free, no Docker needed)
- Production-ready if deployment needs change later
Adzuna API vs direct scraping
- Structured JSON — no HTML parsing, no selector maintenance
- Free tier (250 requests/day) is sufficient for personal job search
- Covers AU, US, UK, CA, NZ markets
- No ban risk — official API with proper credentials
- Seek/LinkedIn scrapers kept as fallback for coverage gaps
LiteLLM vs direct provider SDKs
- Multi-provider support is a requirement — need Claude, GPT, Gemini at minimum
- LiteLLM provides a single interface, avoiding per-provider code paths
- Fallback chains: if Claude is down, automatically try GPT
- Cost tracking built-in — useful for monitoring per-job analysis costs
- Thin wrapper — doesn't hide the underlying API semantics