Rank candidates against a job — and prove the ranking. Every score comes with the evidence behind it.
Proof of concept · .NET 10 · Blazor · Supabase · Azure OpenAI
- Overview
- What's included
- How it works
- Architecture
- Running it locally
- Configuration & secrets
- Roles & access
Hiring teams increasingly use AI to rank candidates. The catch: most tools hand back a score with no explanation. That's hard to trust, hard to defend to a hiring manager or a candidate, and impossible to reproduce or audit later.
Rationale takes a different approach — it shows its work. For every candidate, instead of a single mystery number, you get:
- 📊 A score per criterion — must-haves, nice-to-haves, experience, domain fit, and seniority — rather than one opaque total.
- 📌 The evidence behind each score — the exact lines from the CV that justify it.
⚠️ Missing must-haves flagged up front.- 🔁 Repeatable results — the same candidate and job produce the same outcome, so a decision can be revisited and explained.
- ⚖️ Fair by design — only the CV's content is scored. Names and contact details are stored, but never seen by the AI.
The guiding idea: the AI explains, the code decides the number. The AI reads a CV and produces the building blocks — a score per criterion and the evidence for each. The application then calculates the overall score itself. The AI never sets the final number, so every result traces back to evidence.
A deliberately small, focused product — a screening engine wrapped in a lightweight mini-ATS, not a full applicant tracking system.
| ✅ Included | 🚫 Out of scope (by design) |
|---|---|
| Candidate management (manual entry or import from a CV) | Candidate sourcing |
| Job definitions to screen against | Messaging / candidate communications |
| AI screening with cited evidence | Dashboards & reporting |
| A three-stage triage Board (New → In Review → Decided) | Configurable pipelines / SLAs |
| Admin-only user management with roles |
Three things happen behind the scenes — signing in, screening, and saving results:
- Sign in. You log in, and Supabase confirms who you are and what you're allowed to do.
- Screen. You pick a job and run screening. Each CV goes to the AI, which scores it against the job's criteria and cites its evidence. The app combines those into an overall score and ranks the candidates.
- Save. The whole run is stored, so it can be reopened, shared, and audited later — no need to re-run the AI.
See the detailed flow (sign-in, screening, and storage)
sequenceDiagram
actor R as Recruiter
participant W as Web (Blazor Server)
participant AUTH as Supabase Auth
participant API as API
participant AOAI as Azure OpenAI
participant DB as Supabase Postgres
rect rgb(238, 246, 255)
Note over R,AUTH: 1 · Authentication
R->>W: Sign in (email + password)
W->>AUTH: Authenticate
AUTH-->>W: Secure token (role included)
end
rect rgb(238, 255, 244)
Note over R,DB: 2 · Candidate screening
R->>W: "Screen candidates" for a job
W->>API: Request screening (with token)
API->>AUTH: Verify token
API->>DB: Load job + candidates
loop Each candidate
API->>AOAI: Score CV content only
AOAI-->>API: Per-criterion scores + cited evidence
end
API->>API: Calculate overall score (in code)
end
rect rgb(255, 248, 236)
Note over API,DB: 3 · Data storage
API->>DB: Save the run (results + evidence)
API-->>W: Ranked candidates
W-->>R: Explainable ranked list
end
Built with Clean Architecture, scaled to a POC. Dependencies point inward —
Core knows nothing about the database, the AI provider, or the web framework — and
the boundary is enforced at compile time (Api → Infrastructure → Core, Web → Core).
flowchart LR
R["Recruiter<br/>(browser)"]
subgraph host["Azure App Service"]
W["MiniAts.Web<br/>Blazor Server"]
API["MiniAts.Api<br/>Minimal API"]
end
subgraph svc["Managed services"]
AUTH["Supabase Auth<br/>ES256 / JWKS"]
DB[("Supabase Postgres")]
AOAI["Azure OpenAI<br/>gpt-5.4-mini"]
end
R --> W
W -->|"sign in"| AUTH
W -->|"HTTPS + token"| API
API -->|"verify token"| AUTH
API -->|"EF Core / Npgsql"| DB
API -->|"screening + CV extraction"| AOAI
In plain terms: the browser talks to the web app; the web app talks to the API; the API is the only part that touches the database, the login service, and the AI.
flowchart TD
A["POST /jobs/{id}/screen"] --> SJ["ScreenJob use-case · Core"]
SJ --> L["Load job + candidates<br/>from Postgres"]
L --> LOOP["For each candidate<br/>(capped concurrency)"]
LOOP --> P["AI scoring provider<br/>one structured call per candidate"]
P --> SCH["Strict JSON schema from Criterion enum<br/>fixed seed · versioned prompt<br/>CV text only — fairness"]
SCH --> OUT["Per-criterion scores<br/>+ cited evidence + missing must-haves<br/>(no overall-score field)"]
OUT --> SC["Scorer computes weighted overall<br/>(code owns the number)"]
SC --> PER["Persist results<br/>one RunId · jsonb breakdown"]
PER --> RANK["Ranked list + evidence"]
In plain terms: each candidate is scored in its own AI call using only their CV; the app — not the AI — calculates the overall score and saves the whole run together.
Resilience. Transient errors are retried with backoff; a single failed candidate is skipped rather than failing the run; if the AI isn't configured, screening returns a clean error instead of a misleading empty result.
| Project | Role |
|---|---|
MiniAts.Core |
Domain + application logic. Pure C#, zero infrastructure dependencies. |
MiniAts.Infrastructure |
The database (EF Core / Postgres), Azure OpenAI, and CV text extraction. |
MiniAts.Api |
Minimal API + the app's composition root. Validates Supabase tokens. |
MiniAts.Web |
Blazor Server UI — CRUD, CV import, the triage Board, and user management. |
tests/MiniAts.UnitTests |
Fast, offline tests (dotnet test). |
tests/MiniAts.Evals |
AI evaluation suite that runs against the real model. |
For the full architecture handoff and design invariants, see
AGENTS.mdandCLAUDE.md.
# 1. Local Postgres (throwaway creds) — or point the connection string at Supabase
docker compose up -d
# 2. Apply migrations (install once: dotnet tool install --global dotnet-ef)
dotnet ef database update -p src/MiniAts.Infrastructure -s src/MiniAts.Api
# 3. Load the synthetic seed (1 job, 18 proof candidates)
dotnet run --project src/MiniAts.Api -- seed
# 4. Run the API and the web app (separate terminals)
dotnet run --project src/MiniAts.Api # http://localhost:5103
dotnet run --project src/MiniAts.Web # http://localhost:5104
# Tests
dotnet test # fast, offline, free
dotnet run --project tests/MiniAts.Evals # AI eval — hits the real model, costs moneySecrets live in user-secrets or environment variables — never in tracked
appsettings*.json. Endpoint and deployment names are not secret; keys, passwords,
and connection strings are. Each integration degrades gracefully when unconfigured:
the app still runs, and the affected feature fails clearly only when used.
dotnet user-secrets set "ConnectionStrings:Postgres" "<supabase-session-pooler-conn>" --project src/MiniAts.Api
dotnet user-secrets set "AzureOpenAI:Endpoint" "<https://...>" --project src/MiniAts.Api
dotnet user-secrets set "AzureOpenAI:Deployment" "gpt-5.4-mini" --project src/MiniAts.Api
dotnet user-secrets set "AzureOpenAI:ApiKey" "<key>" --project src/MiniAts.ApiSign-in is handled by Supabase. Access is role-based:
| Role | Can do |
|---|---|
| admin | Everything — create, update, delete, and manage users. |
| member | Read-only — view candidates, jobs, screenings, and the Board. |
Rationale's core is the screening engine — explainable, ranked candidate assessment with a human deciding. The ideas below are illustrative extensions that build on that core to automate more of the hiring workflow. They are roadmap suggestions, not built features, and each keeps a human in the loop.
Rationale can act as a service for external automation. As one example, an LLM-based assistant can register a candidate, read the structured screening result — per-criterion scores, gaps, and cited evidence, not just a pass/fail — and compose a tailored interview brief that probes exactly the criteria a candidate scored low or uncertain on for the role they matched. A recruiter reviews and approves the brief before any interview runs.
The assistant prepares and explains; the recruiter decides. Rationale passes the reasons, not just a verdict.
flowchart TD
A[External AI assistant] -->|registers candidate| B[Rationale screening]
B -->|structured result: scores, gaps, cited evidence| A
A -->|composes tailored interview brief from the gaps| C[Draft interview brief]
C --> D{Recruiter approves?}
D -- Approve / edit --> E[Tailored interview proceeds]
D -- Reject --> F[Recruiter decides next step]
A lightweight intake channel that keeps email out of the platform entirely. A recruiter reviews an incoming application in their own mailbox (where corporate spam/malware filtering has already scanned the attachment) and saves the CV to a watched location. A trigger then registers it as a pending candidate in Rationale, ready to register and screen through the existing pipeline.
The recruiter is the relevance gate; the mailbox scan is the malware gate. A human still approves registration and screening.
flowchart TD
A[Candidate emails CV] --> B[Recruiter reviews in mailbox<br/>spam + malware already filtered]
B --> C[Saves CV to watched storage]
C --> D[Trigger: blob-created event / poll]
D --> E[File scanned, then surfaced as a pending candidate]
E --> F[Recruiter registers + picks job to screen against]
F --> G[Existing pipeline: Extract -> Register -> Screen]