"HIREFLOW" — AI job-search service: resume analysis, hh.ru vacancy scoring, and cover-letter generation
A web service that helps a job seeker: it takes the resume text, parses it through an LLM into a structured profile, finds matching vacancies on hh.ru, scores each one on a 100-point scale against the resume, and, on demand, generates a personalized cover letter. All of it lives in a multi-user workspace with data isolation, a knowledge base about the user, and tracking of AI-call costs.

Slide 1 of 8
DESCRIPTION
A full-stack web application in Python 3.11 (FastAPI, a fully asynchronous stack) on top of PostgreSQL. The server delivers a server-rendered interface built with Jinja2 (no frontend build step) and at the same time acts as a full-fledged JSON API. Data is stored in PostgreSQL via SQLAlchemy 2.x (async, asyncpg driver), with the schema managed through Alembic migrations. Configuration and all secrets live only in the environment (.env) via pydantic-settings; hardcoding keys in the code is forbidden as a matter of project principle.
The overarching idea is not "yet another vacancy parser" but the foundation of a career OS: the MVP implements the first two modules (search/scoring and letters), but the architectural "seams" are already laid out so the product can grow evolutionarily, without rewriting the working core.
The working pipeline: resume → analysis → search → scoring → letter
This is the product's core invariant — it must stay functional at every step of development. The user pastes the resume text; the LLM turns it into a structured ResumeProfile (skills, roles, keywords, expectations); a search plan is built from the profile and hh.ru vacancies are collected; the vacancies are deduplicated and stored; then they go through resume-match scoring; the output is a list of matches with scores and rationales, from which a cover-letter draft is generated with a single click.
LLM behind a single provider-agnostic interface
All model access is hidden behind an LLMProvider abstraction with two implementations — OpenAI (default) and Anthropic Claude. The provider is chosen by a setting in .env; the key and model also come from the environment and are never entered by the user or stored in the database (this is a separate security invariant). Each provider's client is cached as a per-process singleton (a single httpx pool, without recreating it on every request), it has a per-call timeout set, and the SDK's built-in retries are forcibly disabled — instead of blind retries, resilience is ensured at the service level. For structured tasks (resume analysis, scoring, letters) OpenAI's native JSON mode is used, and response parsing is additionally guarded by a custom JSON extractor — if the model returns garbage or wraps the answer in text, the parser extracts a valid object rather than failing the request. There is also a mode for operating through a proxy (socks5/http) — so that a server without direct access to OpenAI can still reach the LLM.
Resilience to a "bad" model response
Each structured service was designed on the assumption that the LLM might respond invalidly. Resume analysis and letter drafting make one retry, and if that too fails Pydantic validation, the error is normalized to a single LLMError, which the routes return as an honest 502 rather than an unhandled 500. Scoring is even gentler: if scoring a particular vacancy fails, a safe default is substituted (score=0, verdict "weak"), and the whole batch doesn't collapse because of one problematic vacancy.
Vacancy scoring: a cheap pre-filter + parallel LLM
Before spending paid calls, vacancies pass through a pre-filter without the LLM: already-scored ones are dropped, along with a blacklist by title/company. The remaining ones are scored by the LLM in parallel with a concurrency limit (a semaphore of 5 simultaneous calls) — fast, but without overloading the provider. The model returns a score of 0–100, a verdict, and a list of reasons; the result is saved with an idempotent UPSERT (ON CONFLICT on the resume+vacancy pair), so re-scoring updates the score rather than producing duplicates. There is a configurable "emphasis" — a hint about the priority of criteria (skills / experience / culture / salary / format) that is mixed into the system prompt and shifts the scoring in the direction the user wants. Matches are filtered by the MIN_SCORE threshold and returned with a rationale — why this particular vacancy fits.
Letter generation with an optional "critic"
A letter is created strictly as a draft (draft status) and is never sent automatically — this is another deliberate invariant. The basic mode is a draft based on the profile and the vacancy; with the LETTER_CRITIC_ENABLED flag on, a second pass is added: a separate critic prompt evaluates the draft and returns edits, after which the letter is rewritten. The user can also manually give a rewrite instruction ("make it shorter", "add something about experience with X") — the service regenerates the text while preserving the facts and the professional tone. Every version of the letter is saved (versioned); nothing is overwritten.
Multi-tenancy and isolation by Workspace
The application is multi-user from the start, but the data container is not the user but the Workspace — designed for future career coaches and HR teams. On registration, a user + a personal Workspace + a membership (owner) are created. Any user entity (resumes, scores, letters, documents, statistics) is scoped to a workspace_id, and access goes only through a FastAPI dependency "current Workspace + membership check" — one Workspace's data is physically invisible to another.
Authentication security
Passwords are hashed with argon2id (argon2). Sessions are server-side: a random token (secrets.token_urlsafe) is placed in the cookie, while only its sha256 hash is stored in the database, so a leak of the sessions table doesn't yield ready-made tokens. The cookie is marked HttpOnly and SameSite=Lax, and the Secure flag is enabled in production (HTTPS). All unsafe methods are protected by CSRF: the token is bound to the session, accepted from a form field or the X-CSRF-Token header, and compared in constant time (hmac.compare_digest). Password verification doesn't fail on a corrupted hash — it returns False rather than an exception.
Knowledge Base — centralized knowledge about the user
A separate module, /knowledge: here a resume is just one document type (kind), alongside portfolio, projects, experience, certificates, references, sample letters, notes, and links (the list of types is easily extensible). Files (PDF/DOCX/TXT) are uploaded via drag & drop with a triple check — extension, MIME, and size (≤5 MB); file names are generated, and the files themselves are stored outside the web root (UPLOAD_DIR) and served only under authentication. Text is extracted from PDF/DOCX (pypdf / python-docx). Each document is versioned: the full history is kept, the current version is the one with the highest number, and "restoring" an old version creates a new one from it without losing history. From the document text, the LLM extracts a structured analysis (summary, skills, technologies, projects, experience), which is stored separately, tied to the version.
The AI engine as a separate platform layer
Beyond the pipeline there is a shared AI module: the user profile as context for generations, a registry of models/providers, search and letter-style settings at the Workspace level, and a generation log. All LLM calls are written to the llm_calls table with the model and token count — the /usage endpoint returns a cost summary (how many calls and tokens the service has "consumed"), which makes the AI cost transparent rather than hidden.
Client-side collection of hh.ru results (a key engineering decision)
hh.ru actively defends against server-side scraping (it may return 403 by IP/geo), so page collection is offloaded to the user's side: the server builds a plan (from the resume — a list of search-page URLs and, subsequently, vacancy-page URLs), and the user's browser downloads these pages with its own IP and cookies and sends the "raw" HTML back. The server holds all the "brains" — resume analysis, HTML parsing, dedup and storage, scoring; the browser acts only as a courier. Flow diagram: plan (server) → download results (client) → search parsing (server) → download vacancies (client) → details parsing (server) → score (server) → results. Thanks to this, the server never visits hh.ru — no IP ban, no parsing load on the backend. All steps require authentication, and CSRF is passed through the X-CSRF-Token header. The client side is deliberately "thin" and safe: it can download only https://hh.ru/* (a strict allow-list, so the channel can't be used as an arbitrary proxy), communicates with the page strictly by origin check, and works only on the service's own domains.
Job Sources as plugins
There is no hard-wired dependency on HeadHunter in the code outside its adapter: a vacancy source is an implementation of a common contract (search / get_details → a normalized vacancy), and a vacancy's identity is defined by the pair (source, external ID). This is the seam along which new platforms (LinkedIn, Habr Career, etc.) are added later without reworking the core.
Interface
Server-rendered Jinja2 + modern CSS without a bundler: a design system built on CSS tokens, component CSS (buttons, cards, forms, tables, tabs, toasts, overlays), and a separate layer for responsiveness and "motion." There is a "search wizard" (a step-by-step wizard), a matches page, a letter screen with edits, an action history, a dashboard workspace made of widgets (so modules can be added without rework), a feedback form, and a branded /docs section. The client-side JavaScript is vanilla, split by task (the wizard, orchestration of client-side search, a bridge to the browser collector, the model-selection panel, toasts, etc.).
Cross-cutting decisions and "honest" MVP limitations
Unified logging; a timeout on every LLM call so a hung call doesn't hold a worker; additive migrations (new fields nullable → backfill → enforce) so existing tables aren't broken. The current MVP's limitations are described directly in the documentation as deliberate technical debt: the pipeline runs synchronously within the request (moving it to a background queue is a separate task), the search takes a single page of results, and the Knowledge Base is not yet connected to the search itself. The project deliberately maintains extensive engineering documentation (VISION, design system, development principles, accessibility and deployment rules) — as a framework for further growth. Production is deployed on a VPS (systemd); an update is git pull + alembic upgrade + service restart.
TOOLS USED
- Python 3.11
- FastAPI (async)
- Uvicorn
- PostgreSQL
- SQLAlchemy 2.x (async ORM)
- asyncpg
- Alembic
- Pydantic v2 / pydantic-settings
- OpenAI Chat Completions (JSON mode)
- Anthropic Claude Messages API
- httpx (client pool
- socks5/http proxy)
- asyncio (concurrency semaphore)
- argon2id (argon2-cffi)
- Server sessions (secrets + sha256)
- CSRF (hmac.compare_digest)
- Jinja2 (server-rendered UI)
- HTML5 / CSS3 (design tokens
- component CSS)
- Vanilla JavaScript
- Manifest V3 browser extension
- pypdf / python-docx
- HH API / hh.ru HTML parsing
- ruff
- pytest
- Docker Compose (PostgreSQL)
- systemd (VPS)
- Git
RESULT
- Designed and built an AI job-search service on an asynchronous FastAPI + PostgreSQL stack (SQLAlchemy 2.x async, Alembic) with an end-to-end pipeline: "resume analysis → vacancy search → scoring → cover letter."
- Hid the LLM behind a provider-agnostic interface with OpenAI and Claude adapters: provider/model selection and the key come only from .env, clients are cached, there's a per-call timeout, and SDK retries are disabled in favor of service-level resilience.
- Ensured resilience to an invalid model response: native JSON mode + a custom JSON extractor, a retry with Pydantic validation, a unified LLMError→502, and safe defaults (scoring doesn't collapse because of a single vacancy).
- Implemented 0–100 match scoring: a cheap pre-filter (already-scored + blacklist) → parallel LLM with a concurrency semaphore → idempotent UPSERT of scores, a configurable criteria "emphasis," and a match-selection threshold.
- Built cover-letter generation strictly as drafts (draft, no auto-send) with an optional "critic" pass and rewriting per the user's instructions; every version is versioned.
- Built a multi-user model with isolation by Workspace: every entity is scoped to workspace_id, access goes only through a "Workspace + membership" dependency, and one Workspace's data is inaccessible to another.
- Implemented secure authentication: argon2id passwords, server-side sessions (a random token in the cookie, its sha256 hash in the database), HttpOnly/SameSite=Lax cookies /Secure in production, CSRF on all unsafe methods with constant-time comparison.
- Built a Knowledge Base: typed documents (a resume being one particular kind), PDF/DOCX/TXT upload with extension/MIME/size checks and storage outside the web root, text extraction (pypdf/python-docx), versions with restore, and a structured AI analysis of the document.
- Extracted the platform AI engine: the user context-profile, a model registry, a generation log, and cost tracking — all LLM calls are logged with tokens, and a cost summary is available via /usage.
- Implemented client-side collection of hh.ru results through the user's browser (a Manifest V3 extension as a "courier"): the server builds the plan and parses the HTML, while downloading happens with the user's IP/cookies — the backend never visits hh.ru, which removes IP bans and parsing load; the channel is restricted by an allow-list to hh.ru only.
- Laid the platform's architectural seams: Job Sources as plugins (no dependency on hh.ru outside the adapter, a vacancy's identity = source + external ID), additive migrations, and versioning — for evolutionary growth without rewriting the core.
- Built a server-rendered interface in Jinja2 without a build step: a design system on CSS tokens, component CSS with responsiveness, a "search wizard," matches and letter pages, history, dashboard widgets, feedback, and a branded /docs.
- Brought the project to a working MVP and deployed it on a VPS (systemd) with an update procedure of git pull + alembic upgrade + service restart; maintains extensive engineering documentation (VISION, design system, principles) as a framework for further growth.
AI-ASSISTANT
Ask a question about this project