English | Türkçe
Explainable, multilingual and privacy-first CV–job matching platform. It compares a CV against a job posting and explains why it produced that score: seven weighted sub-scores, matched vs. missing skills with evidence, and actionable CV improvement suggestions. No keyword counting, no single opaque cosine similarity.
Live demo coming soon. The application can currently be run locally with
docker compose up --build; the screenshots below are captured from that build.
Job seekers cannot see how well their CV fits a posting before they apply. Most "ATS compatibility" tools are either keyword counters or black boxes that hand you a single AI score with no justification. Both leave the same two questions unanswered: which skill is actually missing, and where does that number come from.
SkillMatch AI answers: "why is my CV a 72% fit for this role, and not 100%?"
- Parses a CV (PDF/DOCX) and a job posting (text, PDF, DOCX, TXT).
- Supports Turkish and English documents, including cross-language pairs (Turkish CV ↔ English posting).
- Extracts skills through a 199-entry canonical taxonomy with aliases
(
Postgres → PostgreSQL,K8s → Kubernetes,makine öğrenmesi → Machine Learning). - Separates required from preferred expectations in the posting.
- Computes total experience by merging overlapping date ranges once, keeping internships separate.
- Judges education only against the posting's stated requirement — university name and prestige are never used.
- Produces a 0–100 score from seven independently weighted sub-scores.
- Explains every sub-score with the evidence behind it.
- Generates CV suggestions that never tell you to claim a skill you lack.
- Exports the result as JSON and PDF.
- Never stores your documents.
- It does not classify a candidate as "suitable" or "unsuitable"; it is not a hiring decision tool.
- It does not read scanned (image-only) PDFs — there is no OCR in this version, but scanned files are detected and reported clearly.
- It does not score name, age, gender, nationality or photo.
- It does not suggest adding a skill you do not have.
Also: progress screen, mobile home, mobile results.
Screenshots are captured automatically with Playwright against the running stack — none of them are mockups. The numbers below are the real output of the three synthetic demo scenarios bundled in this repository.
| Scenario | Role | Score | Band | Confidence |
|---|---|---|---|---|
strong-python-backend |
Junior Python Backend Developer | 78.5 | Strong fit | 0.94 |
moderate-ml-engineer |
Machine Learning Engineer | 51.5 | Improvable | 0.89 |
weak-devops-frontend |
Senior DevOps Engineer | 15.6 | Low fit | 0.80 |
Sub-score breakdown from the same run (sentence-transformers backend):
| Sub-score | strong | moderate | weak |
|---|---|---|---|
| Semantic fit | 71.5 | 50.5 | 28.6 |
| Required skills | 98.5 | 48.4 | 2.3 |
| Preferred skills | 61.9 | 0.0 | 0.0 |
| Experience | 100.0 | 98.4 | 34.4 |
| Education | 100.0 | 75.0 | 0.0 |
| Keyword coverage | 32.1 | 48.1 | 3.6 |
| Project relevance | 45.4 | 26.2 | 11.9 |
The same three scenarios on the lexical fallback backend (no model download)
produce 59.5 / 35.7 / 4.9. The ordering holds but the semantic component
collapses — which is exactly why a fallback run is returned with
is_degraded: true and flagged in the UI.
flowchart LR
U[User] --> F[React Frontend]
F --> A[FastAPI REST API]
A --> V[File Validation]
V --> P[PDF and DOCX Parsers]
P --> N[NLP Pipeline]
N --> S[Skill Extraction]
N --> E[Experience Extraction]
N --> D[Education Extraction]
S --> M[Matching Engine]
E --> M
D --> M
M --> C[Score Calculator]
C --> R[Recommendation Engine]
R --> O[Analysis Result]
O --> F
O --> J[JSON Report]
O --> PDF[PDF Report]
Layering rule: there is no NLP, parsing or scoring code inside
app/api/routes/*; routes only call AnalysisService. The domain layer never
imports FastAPI, so it is testable without HTTP. Details:
docs/architecture.md.
queued → extracting_text → parsing_documents → extracting_features
→ matching → generating_recommendations → generating_report → completed
The progress bar is derived from this stage ordering (progress_ratio), never
from a timer. There is no simulated progress anywhere in the app.
Frontend — React 19, TypeScript (strict), Vite 6, Tailwind CSS 3.4, Radix UI primitives (shadcn/ui pattern), React Router 7, TanStack Query 5, React Hook Form + Zod, Axios, Recharts, lucide-react, Framer Motion.
Backend — Python 3.12+, FastAPI, Uvicorn, Pydantic v2, pydantic-settings, PyMuPDF (with a pypdf fallback), python-docx, RapidFuzz, scikit-learn, NumPy, sentence-transformers / Hugging Face Transformers / PyTorch (optional), ReportLab, tenacity.
Quality — pytest + pytest-cov + httpx, Ruff, mypy (strict), Vitest + React Testing Library, Playwright, ESLint, Prettier.
DevOps — multi-stage Docker builds, Docker Compose, Nginx, GitHub Actions, healthchecks, non-root container users.
ModelManager picks the best available backend once per process:
| Priority | Backend | Condition | Result |
|---|---|---|---|
| 1 | sentence_transformers |
package installed, model reachable | full quality |
| 2 | transformers_mean_pooling |
transformers + torch present |
full quality |
| 3 | lexical_tfidf |
no model, or explicitly disabled | is_degraded = true |
Tier 3 builds hashed character- and word-n-gram vectors: fully deterministic and offline. CI runs on it so no 470 MB download is needed — but a user is never silently served a degraded result; both the UI and the report say so.
Model: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
(384 dimensions, multilingual). The cross-language comparison works because of
that model's multilingual alignment.
Exact string matching alone is not used. For each posting requirement, in order:
- exact — canonical name equality (confidence ceiling 1.00)
- normalized — punctuation/whitespace-normalised match (0.99)
- alias — taxonomy synonym,
Postgres → PostgreSQL(0.98) - token — word containment (0.92)
- fuzzy — RapidFuzz
token_set_ratio(0.88) - semantic — embedding cosine (0.82)
A match's confidence can never exceed the ceiling of the technique that
produced it. Candidates below the threshold are not counted as matched;
those between the partial and match thresholds become partial.
Context awareness: short or overloaded tokens such as CV, R, C, Go,
AI, ML are only accepted when the context rule in skill_aliases.json is
satisfied. The sentence "CV'mi ekte gönderiyorum" does not yield Computer
Vision. "Kubernetes bilgisi aranmamaktadır" does not create a requirement.
overall = Σ ( sub_score_i × effective_weight_i )
| Component | Weight |
|---|---|
semantic_similarity |
0.25 |
required_skills |
0.25 |
preferred_skills |
0.10 |
experience |
0.15 |
education |
0.08 |
keyword_coverage |
0.07 |
project_relevance |
0.10 |
Weights are overridable through the MATCHING_WEIGHTS environment variable.
If they do not sum to 1.0 the application refuses to start — and a unit test
asserts it.
If the posting states no education requirement, "education = 0" is not
assigned: the component is flagged data_available=false and its weight is
redistributed proportionally across the remaining components. The redistributed
weights are reported in weights_used.
Raw cosine is never mapped straight to a percentage. On multilingual MiniLM even two unrelated technical texts sit around 0.15–0.30:
calibrated = clamp( (raw − 0.15) / (0.78 − 0.15), 0, 1 )
Values measured in this repository (paraphrase-multilingual-MiniLM-L12-v2):
| Text pair | Raw cosine | Calibrated |
|---|---|---|
| Unrelated (TR blog ↔ EN DevOps posting) | −0.035 | 0.0 |
| Unrelated (restaurant manager ↔ DevOps) | 0.283 | 21.1 |
| Weak (frontend CV ↔ DevOps posting) | 0.242 | 14.6 |
| Moderate (ML CV ↔ ML posting, tooling gaps) | 0.564 | 65.7 |
| Strong (TR backend CV ↔ EN backend posting) | 0.640 | 77.7 |
| Very strong (EN backend CV ↔ EN backend posting) | 0.730 | 92.1 |
Full formulas, the experience/education tables and the confidence model:
docs/scoring.md.
| Range | Band |
|---|---|
| 0–39 | Low fit |
| 40–59 | Improvable |
| 60–74 | Moderate fit |
| 75–89 | Strong fit |
| 90–100 | Excellent fit |
- Uploads are processed in a temporary file that is deleted in a
finallyblock. - The
ParsedCV/AnalysisResultschemas have no raw-text field — this is a type-level constraint, not a policy note. - PII (e-mail, phone, address, national ID, date of birth, gender / marital status / nationality / religion) is masked before parsing and never reaches any sub-score.
- Results live in memory with a 30-minute default TTL and are purged
automatically;
DELETE /api/v1/analyses/{id}removes one instantly. - Logs contain no document text, name, e-mail, phone or evidence excerpt — a
unit test enforces this via
assert_no_document_text(). - The browser never writes CV content to
localStorage; there is no third-party analytics.
Details: docs/privacy.md.
The extension is never trusted alone: PDFs must carry the %PDF- signature,
DOCX files must be a valid ZIP containing word/document.xml. Zip-slip and
zip-bomb guards, filename sanitisation (path-traversal protection) and size
limits (CV 10 MB, posting 5 MB) all apply.
docker compose up --build- UI: http://localhost:8080
- API docs: http://localhost:8000/docs
On first run the backend container downloads the embedding model (~470 MB) into
a named Docker volume, so subsequent starts are fast. For a small, model-free
image set INSTALL_ML to "false" in docker-compose.yml; the system then
runs on the lexical fallback and marks every result as degraded.
For production TLS, rate limiting, readiness checks, and single-worker
constraints, see docs/deployment.md.
Requirements: Python 3.12+, Node.js 20+.
make install # backend + frontend dependencies
make install-ml # optional: torch + sentence-transformersmake backend # http://127.0.0.1:8000make frontend # http://127.0.0.1:5173 (proxies /api to the backend)Pre-download the model:
cd backend && python scripts/download_models.py| Command | What it does |
|---|---|
make install |
Install every dependency |
make backend / make frontend |
Run the dev servers |
make test |
Backend + frontend tests |
make lint |
Ruff + ESLint |
make typecheck |
mypy (strict) + tsc |
make format |
Ruff format + Prettier |
make e2e |
Playwright end-to-end tests |
make evaluate |
Evaluation with the real model |
make evaluate-lexical |
Evaluation with the offline fallback |
make openapi |
Export the OpenAPI schema to docs/openapi.json |
make docker-up / make docker-down |
Docker Compose stack |
Full reference: docs/api.md. Interactive docs at /docs.
GET /api/v1/health
GET /api/v1/readiness
POST /api/v1/analyses # multipart/form-data
GET /api/v1/analyses/{id}/status # lightweight polling
GET /api/v1/analyses/{id} # completed result
DELETE /api/v1/analyses/{id}
GET /api/v1/demo-scenarios
POST /api/v1/demo-scenarios/{scenario_id}/analyses
GET /api/v1/analyses/{id}/reports/json
GET /api/v1/analyses/{id}/reports/pdfPOST /analyses fields: cv_file (required), job_file, job_text,
language_preference, include_optional_skills. At least one of job_file /
job_text is required; if both are sent the file wins and a warning is
appended to job.parse_warnings.
Every error uses the same envelope:
{ "error": { "code": "SCANNED_PDF_UNSUPPORTED", "message": "…", "details": null, "request_id": "…" } }skillmatch-ai/
├── backend/
│ ├── app/
│ │ ├── api/ # routes, dependencies, error envelope (no business logic)
│ │ ├── core/ # config, logging, exceptions, security
│ │ ├── domain/ # enums + AnalysisService / DemoService
│ │ ├── schemas/ # Pydantic contracts
│ │ ├── parsers/ # PDF / DOCX / TXT → ParsedCV, ParsedJobDescription
│ │ ├── nlp/ # language, sections, skills, experience, education, keywords
│ │ ├── matching/ # 6 matchers + score calculator + explanation generator
│ │ ├── recommendations/
│ │ ├── reports/ # JSON + PDF export
│ │ ├── privacy/ # file validation, PII filter, temp file lifecycle
│ │ ├── repositories/ # TTL'd in-memory analysis store
│ │ └── data/ # taxonomy, aliases, section headings, demo scenarios
│ ├── tests/ # unit / api / integration
│ └── scripts/ # model download, OpenAPI export, evaluation
├── frontend/
│ ├── src/
│ │ ├── app/ # App, providers, router
│ │ ├── pages/ # 8 pages (route-based code splitting)
│ │ ├── components/ # layout, upload, analysis, charts, skills, recommendations, ui
│ │ ├── features/ # analysis API, hooks, Zod schemas
│ │ ├── lib/ # api-client, query-client, formatters, constants
│ │ └── styles/ # design tokens
│ └── tests/e2e/ # Playwright
├── evaluation/ # synthetic dataset + real metrics
├── docs/ # architecture, API, scoring, privacy
└── docker-compose.yml
evaluation/datasets/synthetic_pairs.json holds 32 labelled synthetic
CV/posting pairs (11 high, 11 medium, 10 low; 15 TR, 11 EN, 6
cross-language). make evaluate runs them through the real pipeline. The
table below is taken from evaluation/results/latest.json; nothing is
hand-written.
Backend: sentence_transformers — 32/32 pairs, 2026-08-06
| Metric | Value |
|---|---|
| MAE (vs. expected range midpoint) | 8.50 |
| RMSE | 9.70 |
| Predictions inside the expected range | 18/32 (56.2%) |
| Spearman ρ (score ↔ label rank) | 0.9344 |
| Band accuracy | 81.2% |
| Macro-F1 | 0.8130 |
| Required-skill extraction P / R / F1 | 0.972 / 0.949 / 0.960 |
The score distribution spreads realistically — low mean 29.9, medium 55.1,
high 74.4; nothing is artificially compressed into 80–100.
On the same dataset the lexical fallback yields MAE 18.34, macro-F1 0.38 and a
high recall of 0.000 (evaluation/results/lexical/). Ranking survives
(ρ = 0.94) but absolute calibration collapses — hence the is_degraded flag.
Honest limitations and the per-class breakdown:
evaluation/README.md.
Every claim in this README is produced by a check that runs in CI on every push
(workflow). The badge above reflects main.
| Check | Result |
|---|---|
| Backend tests | 912 passing, 95% coverage (--cov-fail-under=80 enforced) |
| Backend lint / format | Ruff clean |
| Backend types | mypy --strict, 79 files, clean |
| Frontend tests | 58 passing (Vitest + React Testing Library) |
| Frontend lint / types | ESLint clean, tsc -b clean |
| Frontend build | Vite production build succeeds |
| Docker | Both images build; backend image passes a container healthcheck smoke test |
| End-to-end | 14 Playwright scenarios (7 desktop + 7 mobile) against the full Dockerised stack |
| Evaluation | 32 labelled pairs through the real pipeline — see evaluation |
- No OCR. Scanned PDFs are detected and reported, but cannot be read.
- Turkish and English only. Language detection is a binary decision between the two.
- Synthetic evaluation. The dataset is hand-written and does not represent the real-world CV distribution.
- Layout-broken CVs. Skill and keyword extraction still work on a
heading-less CV, but the experience/education/project lists stay empty — the
system does not guess work history from unlabelled text, and it says so via
parse_warnings. - In-memory storage. Analyses live in process memory; horizontal scaling across workers needs the repository layer moved to Redis (it is already defined as a protocol for exactly this).
- The lexical fallback is weak. It cannot capture cross-language or paraphrased matches, which is why its results are explicitly flagged.
- Soft skills are extracted from postings but kept out of the required/preferred lists; they are reported in a separate field.
- The UI is Turkish-only today. An English interface is the next planned feature.
- English interface (highest priority)
- OCR support for scanned PDFs (Tesseract)
- Redis + Celery queue and multi-worker deployment
- Skill taxonomy mapped to ESCO / O*NET
- Batch mode: one CV against many postings
- Calibration validation on real (anonymised, consented) data
Contributions are welcome — see CONTRIBUTING.md for the
development workflow, coding standards and the checks a PR must pass.
Security issues: SECURITY.md. Community expectations:
CODE_OF_CONDUCT.md.
Any PR that changes scoring behaviour must also update docs/scoring.md and the
corresponding tests.
This project was built in a pair-programming workflow with an AI coding
assistant (Claude); the commit trailers record it. Architecture decisions,
scoring design, calibration anchors and the evaluation methodology were reviewed
and are documented in docs/ — read those if you want to judge the engineering
rather than the line count.
MIT © Mert Erdoğan Yıldırım





