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%
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.
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 --buildOpen http://localhost:3000 and drop a CV in. The container:
- builds in a multi-stage image (~250 MB final), rebuilds
better-sqlite3against prod-only deps so the native binding matches the runtime arch - runs as a non-root
nodeuser - bind-mounts
./dataread-write soaudits.db(per-run history) survives restarts; the committedsalary.dbanddata/reference/are also visible through that mount
To override the port:
PORT=4000 docker compose up --buildcp .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:5173The 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"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.
- Schema — every LLM output passes through
generateObject(JSON Schema enforced) and is decoded by Effect Schema for typed pipeline values. - Domain bounds —
Schema.between,Schema.Literalenums, capped years, etc. Out-of-range values clamp with a warning. - Cross-source reconciliation —
salary.p50is reconciled against thesurvey_rangestable; outside ±30% of the survey corridor the result is labeledconfidence: lowand a sanity warning is attached. A degraded output is never silent.
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 topdata/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.
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
- Replace synthetic-but-grounded baseline with real scraped data as the default snapshot in CI.
- Per-stage cost & latency telemetry in
meta.durationsMsis 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.