Built for job seekers in Germany. Upload your resume, paste a job description, and get a precise AI-powered match score with keyword gaps, section breakdown, and live job listings from three German job boards.
The job search process is opaque for candidates. Companies run resumes through automated filters and candidates have no visibility into what those filters are looking for. You apply, you wait, you hear nothing, and you have no idea why.
I needed a tool that would look at any job description and tell me exactly how well my profile matches it, which keywords I am missing, and whether it is even worth tailoring my resume for that role. Not a generic score. A specific analysis against a specific job every time.
So I built it. The core works and I use it for every role I consider applying to.
Resume Analyser - Upload a PDF or DOCX resume and paste any job description. The AI pipeline extracts structured data from both, runs a weighted scoring engine over five resume/JD sections (required skills, responsibilities, preferred skills, education, certifications) plus two hard pass/fail gates (years of experience, language level), and returns a 0-100 match score with a full breakdown, keyword gap list, and actionable recommendations. Re-running the same pair shows the score delta.
The matcher goes beyond naive keyword comparison: skill aliases resolve spelling variants (k8s = kubernetes, js = javascript), an evidence search finds skills used in experience bullets but missing from the skills section, responsibility coverage is judged by an LLM per duty rather than by keyword overlap, and prose similarity scores are calibrated against the embedding model so honest matches read as honest scores. A skill is matched or it is not - there is no fuzzy "related skill" credit, because the embedding model cannot reliably separate a genuinely related skill from a coincidentally similar one. Sections the JD says nothing about are excluded from the weighted overall instead of diluting it. Extraction handles academic profiles (publications, thesis, research roles) and German vocational qualifications (Ausbildung, Meister, Fachinformatiker) alongside standard industry CVs.
AI Resume Improvement - After an analysis, one click feeds the identified gaps into a rewrite engine that generates improved, JD-aligned bullet points grounded in your real experience - shown as before/after pairs with copy buttons.
Live Job Fetcher - Pulls fresh listings from Adzuna Germany, Arbeitnow, and the Bundesagentur fur Arbeit (up to 200 per search title with pagination), scores each against your loaded resume, and presents them ranked by match score. Job roles are managed as editable keyword chips; with the entry-level filter on, each role is combined with the configured entry keywords (junior ml engineer, intern ml engineer, ...) at search time, a free keyword gate blocks seniority and working-student markers before any LLM tokens are spent, and an LLM gate scoped to IT & Computer Science roles makes the final call with a deterministic seniority override. Runs stream results in live, can be stopped mid-flight without losing scored jobs, and auto-fetch runs on a per-user schedule. Deleting a job offers undo; application status (applied / interview / offer / rejected) is tracked per job and summarised in a day-grouped History timeline. CSV export included.
ATS Check and Optimise - Scans your resume for structural issues that confuse Applicant Tracking Systems (missing sections, formatting problems, keyword coverage), then optionally rewrites it for the job description and exports the result as a ready-to-send DOCX.
Resume Vault - Store up to 3 resume versions (base + tailored). Switch between them instantly when scoring a new job. Past analyses can be reopened in full from the History tab.
LLM Routing - Every LLM call goes through one router (call_llm()) with task-based
tiering. High-volume work (resume/JD extraction, the per-job JD parse) runs on a cheap
fast model (OpenAI GPT-4o-mini); low-volume work the user reads or that decides a single
score (profile summary, ATS rewrite, per-duty responsibility judge) runs on a stronger
model (GPT-5-mini, or Claude through the Anthropic provider). Each tier names its own
provider, so cheap and quality work can run on different vendors, and a tier whose API
key is missing falls back to the active provider instead of failing. The router adds four
retries with exponential backoff, an automatic Groq fallback, proactive tokens-per-minute
pacing for Groq, and a typed result that degrades instead of raising. Providers are forced
into native JSON mode so structured extraction can never return malformed output. The
active provider is an app-wide setting controlled by the admin account.
Security & Beta Hardening - Per-IP rate limits on credential endpoints and a per-user budget on every LLM-backed endpoint, request body size caps, security headers, constant-time invite code checks, clamped user-supplied fetch limits, opaque upload tokens (clients never see file paths), and JD length caps on all analysis inputs. All state is scoped per user; analysis results are cached (versioned by scoring engine) so repeat runs cost zero tokens.
| Score | Label |
|---|---|
| 80 and above | Excellent Match |
| 60 to 79 | Good Match |
| 40 to 59 | Partial Match |
| Below 40 | Poor Match |
| Layer | Technology |
|---|---|
| Backend API | FastAPI + Uvicorn |
| Database | SQLite (local) or Turso (cloud) |
| Frontend | React 18 + Vite + Tailwind CSS |
| Animations | Framer Motion |
| PDF / DOCX parsing | pdfplumber, PyMuPDF, python-docx |
| LLM providers | OpenAI, Anthropic (Claude), Groq, Ollama - one task-routing call_llm() |
| Embeddings | sentence-transformers paraphrase-multilingual-MiniLM-L12-v2 (matching + dedup) |
| Semantic dedup | ChromaDB (cross-board near-duplicate jobs) |
| Job sources | Adzuna Germany, Arbeitnow, Bundesagentur fur Arbeit |
| Auth | JWT (HS256) via python-jose + bcrypt |
Two pipelines share the same extraction step, LLM router, and scoring engine.
Analyser pipeline - one resume against one JD:
upload -> parse (PDF/DOCX -> text) -> extract_all() -> match() -> generate_summary() -> SHA-256 cache -> JSON
2 LLM calls deterministic 1 LLM call
+ 1 LLM per-duty coverage judge
Job-match pipeline - many live jobs, run as a background task:
GET /api/match/run
fetch_combined() discover_and_score()
Adzuna (per title, per country) 1. skip ids in seen_jobs (DB)
Arbeitnow feed (DE) 2. recency filter (free)
Bundesagentur API (DE) 3. keyword seniority gate (free regex)
merge + dedup (id, title|company) 4. LLM relevance gate (1 call / 30 titles)
5. ChromaDB dedup (title+company embedding)
6. enrich thin snippets (JSON-LD scrape)
7. extract_jd() + match() (1 LLM call / job)
-> upsert score + JD json -> dashboard
Design rule: the LLM extracts structured data; a deterministic engine computes the score. Scores stay reproducible, cacheable, and explainable, and cost scales with the number of jobs rather than with model reasoning. Cheap deterministic gates run before any paid LLM call, so each run only pays to score the jobs that survive the funnel.
JobsFitAI/
backend/
main.py FastAPI entry point, routers, scheduler
config.yaml LLM providers, matcher weights, job search settings
api/routes/ One file per feature group (auth, resumes, analyzer, matches, ats)
core/ Config, database, security, logger, state, upload tokens
models/ User model (DB queries for the users table)
repositories/ Data access layer for all other tables
schemas/ Pydantic request/response shapes
tests/ Frontend-backend contract test suite (pytest)
services/
ats.py ATS check and optimise logic
job_matcher.py Fetch + score pipeline orchestration (stoppable runs)
job_relevance.py LLM relevance gate (IT/CS scoped, seniority override)
title_expander.py Entry-keyword search expansion + exclude gate
fetchers/ Adzuna, Arbeitnow, Bundesagentur fetchers
extractors/ LLM-based resume and JD extraction
llm/ call_llm() router + providers (native JSON mode)
matcher/ Scoring engine + per-section scorers + skill aliases
parsers/ PDF / DOCX text extraction
prompts/ LLM prompt builders + JSON schemas
frontend/
src/
pages/ Landing, Login, About, Pricing, Privacy
components/tabs/ Analyzer, ATS, JobMatches, Resumes, History, Settings
components/ ResumePicker, AnalysisResults, TopBar, Sidebar, Toast, ui
layouts/AppShell.jsx Main app shell (sidebar + content)
lib/auth.js apiFetch() - attaches Bearer token, redirects to login on 401
lib/errors.js errMsg() - maps API error codes to human-readable messages
App.jsx React Router config
index.css Design tokens + global styles
Backend
cd backend
uv run uvicorn main:app --reload --port 8080Frontend
cd frontend
npm install
npm run devOpen http://localhost:5173 for the React app, or http://localhost:8080 to hit the API directly.
Tests - the contract test suite runs offline against a throwaway database:
cd backend
uv run pytest testsEnvironment variables - copy .env.example to backend/.env and fill in your keys:
JWT_SECRET=your-long-random-secret
OPENAI_API_KEY=sk-...
GROQ_API_KEY=gsk-...
ADZUNA_APP_ID=your-app-id
ADZUNA_APP_KEY=your-app-key
ADMIN_EMAILS=you@example.com # admin role (app-wide LLM settings)
INVITE_CODE=your-beta-code # optional: makes registration invite-only
Optional: APP_ENV=production enforces production requirements (JWT_SECRET must be set),
and ALLOWED_ORIGINS restricts CORS to your real domain.
Every credential is server-side only and read from the environment. The frontend reads no environment variables at all - no key, public or otherwise, is bundled into the browser. There are no Supabase, Stripe, MongoDB, or Firebase credentials in this project.
| Rule | Status |
|---|---|
| No secret as a string literal anywhere in source | Verified - full-tree scan, none found |
.env gitignored and never committed |
Verified - only .env.example is tracked |
| Secrets absent from git history | Verified - no key pattern in any commit |
| No secret in logs (values, URLs, connection strings) | Enforced - log lines name variables, never values |
| No secret in any API response | Enforced - /api/llm-settings returns has_key as a boolean only |
| API keys sent as request params, never interpolated into logged URLs | Verified |
Before deploying:
- Generate a fresh
JWT_SECRET(python -c "import secrets; print(secrets.token_hex(32))"). Without a fixed value, every restart invalidates all sessions; withAPP_ENV=productionthe server refuses to boot without it. - Set
ALLOWED_ORIGINSto your realhttps://domain. The default allows localhost only, and in production the server refuses to boot on*or anyhttp://origin. - Set
INVITE_CODEto keep registration closed during beta.
Resumes and passwords must never cross the network in clear text. Protection is split between the platform and the app:
The platform terminates TLS. The app cannot encrypt the wire itself - your host
(Fly, Railway, Render, Cloudflare, or an nginx/Caddy reverse proxy) must serve HTTPS with
a valid certificate. Run uvicorn behind it with --proxy-headers --forwarded-allow-ips=*
so the app can see the original scheme.
The app refuses to be used insecurely. With APP_ENV=production:
| Protection | Effect |
|---|---|
HTTPSRedirectMiddleware |
Any plain-HTTP request is redirected before a body is read |
Strict-Transport-Security (1 year, includeSubDomains) |
Browsers refuse http:// for this domain entirely, even if a user types it |
Content-Security-Policy |
script-src 'self' - the JWT lives in localStorage, so blocking injected scripts is the main defence against token theft; connect-src 'self' stops any exfiltration endpoint |
secure + httpOnly + sameSite on session cookies |
Cookie is never sent over plain HTTP |
| CORS allowlist | Boot fails on * or http:// origins - a wildcard with credentials would let any site read authenticated responses |
| Body size cap, per-IP and per-user rate limits | Limits abuse of the exposed surface |
All outbound calls (OpenAI, Groq, Adzuna, Arbeitnow, Bundesagentur) already use HTTPS.
Rotate any key that was ever pasted into a file, a commit, a screenshot, or a chat. Git history is permanent: deleting a secret from the current code does not remove it from earlier commits, and a leaked key stays valid until you revoke it at the provider (OpenAI, Groq, Adzuna, Turso). Rotation is the only fix - re-writing history is not enough if the repository was ever pushed or cloned.
| Group | Endpoints |
|---|---|
| Auth | POST /api/auth/register, /login, /change-password; GET /me |
| Resumes | GET/POST/DELETE /api/resumes, /{id}/file, /label, /use-for-matching, /re-extract, /recommend |
| Analyzer | POST /api/upload, /resume-preview, /analyze; POST /api/improve-resume |
| Job Matches | GET /api/match/run, /state, /detail, /export; POST /applied, /app-status, /filters, /score-jd, /scheduler, /stop, /delete, /restore, /clear |
| ATS | POST /api/ats/check, /optimise, /docx |
| History | GET /api/history, /api/history/analysis |
| LLM Settings | GET/POST /api/llm-settings (POST is admin-only), GET /api/llm-ping |
Core pipeline works end to end: resume parsing, LLM extraction with canonical skill normalization, alias- and evidence-aware skill scoring with calibrated prose similarities and an LLM per-duty coverage judge, keyword gap analysis, ATS check and optimise with DOCX export, AI resume improvement, and live job fetching from three German job boards through a two-part entry-level gate (keyword expansion + IT/CS-scoped LLM relevance).
The React frontend is live with full auth (invite-only beta), resume vault, analyzer with improve flow, ATS tab, job matches with editable role chips, live run progress, stop control, undo delete and application tracking, a day-grouped history timeline, and settings with account management. The API is hardened for public beta (rate limits, body caps, security headers, per-user isolation). A contract + matcher test suite (58 tests) pins every frontend-backend interaction and every scoring behaviour.
In progress: production deployment prep (usage quotas, GDPR account deletion, Docker), then Pro plan billing.
Built by Harman. Open to feedback, ideas, and conversations about making the job search less of a black box.
MIT License. See LICENSE file.
