Skip to content

Repository files navigation

Job Fit & Salary Estimator

Drop a Czech tech CV (PDF or DOCX) → typed pipeline produces:

  • Seniority Score (0-100) composed of Skills · Experience · Education · Traits sub-scores
  • Salary Estimate in CZK / month, with skill-premium adjustments and a sanity check against survey data
  • Explanation with strengths, gaps, and a concrete plan to lift salary by ~30%

Stack

TypeScript on Node 22, Effect-TS for the typed pipeline, and TanStack Start (Vite plugin) for the UI. The salary reference data lives in SQLite via better-sqlite3. The LLM stages run on any of Anthropic Claude, OpenAI, or Google Gemini — switchable via one env var.


Run it

Docker (recommended)

git clone https://github.com/jrydel/kp_cv_analyzer.git
cd kp_cv_analyzer
cp .env.example .env   # paste at least one of ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY
docker compose up --build

Open http://localhost:3000 and drop a CV in. The container:

  • builds in a multi-stage image (~250 MB final), rebuilds better-sqlite3 against prod-only deps so the native binding matches the runtime arch
  • runs as a non-root node user
  • bind-mounts ./data read-write so audits.db (per-run history) survives restarts; the committed salary.db and data/reference/ are also visible through that mount

To override the port:

PORT=4000 docker compose up --build

Local (Node 22+, no Docker)

cp .env.example .env
npm install
npm run db:seed     # only if data/salary.db is missing; the snapshot is committed
npm run dev         # http://localhost:5173

The default provider is Anthropic because Claude reads PDFs natively (no extraction step). Set AI_PROVIDER=openai or AI_PROVIDER=google in .env to compare models against the same pipeline. OpenAI doesn't accept native PDF — only DOCX works there.

A scriptable endpoint is also exposed:

curl -X POST http://localhost:3000/api/analyze \
  -F "cv=@/path/to/cv.pdf" \
  -H "accept: application/json" | jq .

For the live progress stream (used by the UI):

curl -N -X POST http://localhost:3000/api/analyze \
  -F "cv=@/path/to/cv.pdf" \
  -H "accept: text/event-stream"

How it works

PDF/DOCX
  │
  ▼
[1] CvIngestionService            ──── Anthropic (or Google) reads the PDF natively;
   StructuredCV                         DOCX goes through mammoth → text → same
                                        generateObject call. Effect Schema decode
                                        on the result.
  │
  ├──► [2] RuleScorerService      ──── deterministic skills (taxonomy-weighted),
  │      SkillsScore · ExpScore         experience (level-multiplied years on a
  │      EduScore                       saturating curve), education (tier table
  │                                      + bonus for top CZ universities)
  │
  ├──► [3] TraitScorerService     ──── single LLM call → leadership / ownership /
  │      TraitsScore                    communication / growth_mindset, averaged.
  │                                      Conservative rubric anchors keep output
  │                                      reproducible.
  │
  ▼
[Composite] = Σ(score × weight) / Σweights      weights 30/35/15/20
              clamped to 0-100, mapped to level
  │
  ▼
[4] SalaryService (SQLite-backed) ──── (role, level) bucket lookup → [p25, p50, p75]
   SalaryEstimate                      × (1 + Σ skill premiums) → range.
                                        Sanity-checked against survey corridor;
                                        outside-corridor → confidence = low.
  │
  ▼
[5] ExplanationService            ──── final LLM call grounded in every sub-score
   strengths · gaps · +30% plan         reasoning + salary adjustments.
  │
  ▼
AnalysisResult (typed, validated, with confidence + warnings surfaced)

Each stage is an Effect Service composed in PipelineService. The orchestrator emits per-stage events onto an Effect Stream, adapted to Server-Sent Events at the API boundary so the UI renders incrementally.

Validation layers

  • Schema — every LLM output passes through generateObject (JSON Schema enforced) and is decoded by Effect Schema for typed pipeline values.
  • Domain boundsSchema.between, Schema.Literal enums, capped years, etc. Out-of-range values clamp with a warning.
  • Cross-source reconciliationsalary.p50 is reconciled against the survey_ranges table; outside ±30% of the survey corridor the result is labeled confidence: low and a sanity warning is attached. A degraded output is never silent.

Where the salary data comes from

Note. Nothing in this project is a trained ML model — there's no gradient descent, no fine-tuning, no embeddings index. The "dataset" below is the reference data the deterministic scorer and LLM stages ground their numeric outputs in. The LLM stages (CV ingestion, trait scoring, explanation) use general-purpose foundation models (Anthropic Claude / OpenAI / Google Gemini) zero-shot, with the dataset rows passed into the prompts or queried alongside.

A 3-layer dataset in data/salary.db:

Layer Table Source Rows Role
Live listings salary_listings (source-tagged startupjobs.cz) startupjobs.cz sitemap + per-offer JSON-LD JobPosting blocks with baseSalary { currency, minValue, maxValue, unitText } hundreds (varies per scrape) Per-listing CZK monthly observations, bucketed at scrape time
Curated baseline salary_listings (seed rows) Hand-curated from public 2025/26 Czech market indicators (Hays Czech salary guide, Grafton salary guide, ISPV) 120 rows Always-present floor so every (role, level) bucket has ≥3 samples, even with zero scraped data
Sanity oracle survey_ranges Manually extracted Hays Czech 2025 + Grafton 2025 ranges 37 rows Cross-checks the pipeline's p50; outside ±30% of the corridor → confidence: low
Skill premiums skill_premiums Curated (~10 entries: kubernetes, rust, ml-engineering, ai-ops, …) ~10 Deterministic multipliers applied on top of the bucket baseline
Skill taxonomy data/reference/skill_taxonomy.json Curated (skill → tier + weight) ~150 entries Feeds the deterministic skills sub-score

Considered and not used:

  • nofluffjobs.cz — original target; effectively became Polish-only by 2026 (legacy Czech inventory migrated). Old scraper deprecated.
  • Kaggle / HuggingGFace salary datasets — global / mostly USD, not comparable to Czech-tech CZK ranges. Rejected as misleading rather than useful.

Full write-up — what was scraped, why startupjobs over nofluffjobs, how role / level inference works, why the baseline coexists with scraped rows, and how getBucketOrNearest keeps the pipeline alive when a niche bucket is empty — lives in docs/dataset.md.

To refresh:

npm run db:seed         # rebuild the baseline (no network)
npm run scrape:refresh  # layer live startupjobs.cz listings on top

data/salary.db is committed so the tool runs offline out of the box — the live scrape just layers fresher listings on top. seed.ts recomputes p25/p50/p75 buckets per (role, level) at build time, so every runtime query is a single indexed lookup.


Project layout

src/
├── ai/
│   ├── ModelProvider.ts        Vercel AI SDK provider switch (Anthropic / OpenAI / Google)
│   └── structured.ts            generateObject + Effect Schema decode helper
├── data/
│   ├── migrations.sql           SQLite schema
│   └── SalaryRepository.ts     read-only Effect Service over data/salary.db
├── domain/
│   ├── enums.ts                 Role / Level / etc.
│   ├── errors.ts                tagged errors
│   ├── progress.ts              ProgressEvent union for SSE
│   └── schemas.ts               StructuredCV, AnalysisResult, sub-score types
├── services/
│   ├── AppLive.ts               Layer composition root
│   ├── CvIngestionService.ts   PDF→Claude, DOCX→mammoth→Claude
│   ├── RuleScorerService.ts    deterministic skills/exp/edu sub-scores
│   ├── TraitScorerService.ts   LLM trait sub-score
│   ├── SalaryService.ts        bucket lookup + premiums + sanity
│   ├── ExplanationService.ts   final LLM explanation
│   └── PipelineService.ts      orchestrator + progress Stream
├── routes/
│   ├── __root.tsx
│   ├── index.tsx                drag-drop UI + live timeline + result card
│   └── api/analyze.ts          POST /api/analyze (JSON or SSE)
├── components/                  CvUpload, ProgressTimeline, ResultCard, shadcn ui/*
└── styles/globals.css

scripts/
├── seed.ts                      builds data/salary.db from baseline data
└── scrape.ts                   refreshes from live nofluffjobs.cz

data/
├── salary.db                    shipped snapshot (run scripts/seed.ts to rebuild)
└── reference/
    └── skill_taxonomy.json      curated (skill → tier+weight) for the skills sub-score

Roadmap

  • Replace synthetic-but-grounded baseline with real scraped data as the default snapshot in CI.
  • Per-stage cost & latency telemetry in meta.durationsMs is wired up but not yet visualised.
  • Synthetic-CV evaluation harness to regression-test scoring stability across model changes.
  • Multi-locale support (slovak CVs, brno-vs-prague salary deltas).
  • Trait scoring grounding: today the trait LLM call is unconstrained reasoning over CV text; a more rigorous version would require the LLM to cite specific CV spans for each trait score.

About

Score a Czech tech CV and estimate its CZK salary, with an LLM-written growth plan.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages