Skip to content

Repository files navigation

English | Türkçe

SkillMatch AI

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.

CI Release License Python FastAPI Node React TypeScript Docker

Live demo coming soon. The application can currently be run locally with docker compose up --build; the screenshots below are captured from that build.


The problem

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%?"

What it does

  • 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.

What it does not do

  • 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.

Demo

Home Upload
Landing — problem, features and one-click demos Analysis — CV upload + posting text/file
Results Comparison
Results — overall score, seven sub-scores, radar and bar charts Detailed comparison — matched / partial / missing skills
Recommendations Methodology
CV suggestions — prioritised, each with a reason Methodology — weights, calibration, real metrics

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.


Architecture

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]
Loading

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.

Pipeline stages

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.


Tech stack

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.


NLP approach

Three-tier embedding layer

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.

Six-technique skill matching cascade

Exact string matching alone is not used. For each posting requirement, in order:

  1. exact — canonical name equality (confidence ceiling 1.00)
  2. normalized — punctuation/whitespace-normalised match (0.99)
  3. alias — taxonomy synonym, Postgres → PostgreSQL (0.98)
  4. token — word containment (0.92)
  5. fuzzy — RapidFuzz token_set_ratio (0.88)
  6. 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.


Scoring

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.

Missing-data policy

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.

Semantic calibration

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.

Score bands

Range Band
0–39 Low fit
40–59 Improvable
60–74 Moderate fit
75–89 Strong fit
90–100 Excellent fit

Privacy

  • Uploads are processed in a temporary file that is deleted in a finally block.
  • The ParsedCV / AnalysisResult schemas 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.

File safety

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.


Getting started

Docker (recommended)

docker compose up --build

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.

Local

Requirements: Python 3.12+, Node.js 20+.

make install        # backend + frontend dependencies
make install-ml     # optional: torch + sentence-transformers
make backend        # http://127.0.0.1:8000
make frontend       # http://127.0.0.1:5173  (proxies /api to the backend)

Pre-download the model:

cd backend && python scripts/download_models.py

Commands

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

API

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/pdf

POST /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": "" } }

Project structure

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

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.


Verification

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

Known limitations

  • 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.

Roadmap

  • 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

Contributing

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.

Disclosure

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.

License

MIT © Mert Erdoğan Yıldırım

About

Explainable, multilingual and privacy-first CV–job matching platform built with React, FastAPI and local NLP.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages