§ unswayed-backend · API contract & docs
Lenux AI — compatibility scoring (Phase 13, UN-102)
What it is
Lenux is the recruiter-side AI assistant (Phase 13). Its first slice, T-13.1 / UN-102, is the compatibility-scoring engine: given a job and a set of applicants, it computes a deterministic 0–100 match score per candidate, a per-category breakdown, and a short list of plain-language "reasons of match" — all from a UCN-stripped view of the candidate (no name, school, or employer names ever reach the scorer).
It lives in src/lenux-ai/scoring/ and is the first piece of the brand-new
/api/v1/* surface.
The /api/v1 surface — a deliberate contract deviation
The rest of the backend speaks snake_case under /api and wraps every response
in a { status, message, data } envelope. The Lenux endpoints break from that on
purpose, to match the Jira tickets exactly:
- Bare JSON bodies (no envelope) — via the class-level
@BareResponse()decorator, which the globalResponseInterceptorhonours by skipping the wrap. - camelCase fields (
totalScore,reasonOfMatch,ucnState). - Per-company / per-user rate limits instead of the global per-IP throttle
(which is turned off here with
@SkipThrottle()). - A custom 429 body:
{ error: "rate_limit_exceeded", limit, window, retryAfterSeconds }plus aRetry-Afterheader.
Every endpoint is employer-only and behind the scoring feature flag, enforced
by the guard stack:
JwtAuthGuard → AccountStatusGuard → UserTypeGuard → LenuxRateLimitGuard → LenuxFeatureGuard.
The endpoints
| Method + path | Limit | Notes |
|---|---|---|
POST /api/v1/jobs/:jobId/candidates/score |
20/min/company | 202; enqueues a score-candidates job. Validates each candidate has applied to the job and is UCN-processed (has a resume) — else 422. |
GET /api/v1/jobs/:jobId/candidates/ranked?page=&limit= |
60/min/user | Latest version per candidate, ranked by totalScore desc, paginated (camelCase {page,limit,total,totalPages}). |
GET /api/v1/candidates/:candidateId/score?jobId= |
60/min/user | Latest score object; 404 when none. jobId is a required query. |
PUT /api/v1/jobs/:jobId/scoring-weights |
10/min/company | Persists per-job weights; the four must sum to 1.0 ± 0.01 else 422. |
POST /api/v1/jobs/:jobId/candidates/shortlist |
30/min/user | Sets applications to shortlisted (recording the transition), flags the latest score, notifies the employer. |
jobId is the numeric EmployerJob.id; a non-numeric id is a 404. candidateId
is an Applicant.id (uuid).
The scoring algorithm (deterministic, no LLM)
The acceptance bar is "the score is deterministic for identical inputs", so the
engine (scoring.engine.ts) is a set of pure functions — no I/O, no clock, no
randomness, no LLM. Four categories, each 0–100:
- skills — case-insensitive set overlap:
matched / required * 100(100 when the job lists no required skills). - experience — candidate years vs a
[min,max]range derived from the job: first a(\d+)-(\d+) yearspattern in the requirements/description, else a map fromexperienceLevel(junior→[0,2], mid→[2,5], senior→[5,99], lead→[8,99]).years >= min ? 100 : years/min * 100. - education — candidate's degree ordinal (0–5) vs a required ordinal parsed from the job text. 100 if no degree is required.
- certifications — best-effort cert names parsed from the job text, matched against the candidate's certs. 100 if none are required.
totalScore = Σ category * weight, rounded to one decimal. Default weights are
{skills:0.4, experience:0.3, education:0.2, certifications:0.1}, overridable
per job.
reasonOfMatch — a flat array of sentences
The four categories are ranked by contribution (score * weight); the top 3
that actually contributed (score > 0) each render one templated sentence, e.g.
"Matches 3/3 required skills including React, NestJS, and PostgreSQL",
"4 years experience meets the 3-5 year requirement" ("exceeds" above the max),
"Bachelor's degree in Computer Science satisfies education requirement",
"Holds AWS Certified Developer certification". It is always a flat array of
strings — never nested objects — which the Jira contract requires.
Versioning — scores are never overwritten
CandidateScore rows are append-only per (candidate, job): each scoring run
writes a new row with version = currentMax + 1. Reads always take the highest
version. This keeps a full history and means a re-score (with new weights, say)
is auditable rather than destructive.
How the queue fits in
POST .../score does not score inline — it enqueues a score-candidates job on
the lenux-ai queue. The ScoreCandidatesHandler self-registers with the
QueueProcessorRegistry (the same pattern as the notification handlers) and
delegates to CompatibilityScoringService.runScoreJob, which computes + persists
the versioned rows. The response is a 202 with an estimatedCompletionSeconds
(≈ max(5, count*7)).
In e2e tests the queue is drained deterministically — the test resolves the processor from the registry and runs it directly, so there is no sleep.
The reusable service
CompatibilityScoringService is exported from ScoringModule because wave-2
slices (talent-pool top-matches, the chat assistant) reuse it. Its public API:
scoreCandidate(job, profile, weights): ScoreResult— pure compute.computeForCandidate(jobId, candidateId): Promise<ScoreResult | null>— load-by-id + compute.runScoreJob(payload): Promise<void>— the queue job (compute + persist).getLatestScore(candidateId, jobId): Promise<CandidateScore | null>.getRanked(jobId, page, limit): Promise<{ rows, total }>— latest version per candidate, total-score desc.
Gotchas
- UCN-processed ≠ "is an applicant".
UcnProfileService.build()returns a profile for any existing applicant, even one with no resume. So the score endpoint checks for an actualApplicantResumerow and a buildable profile — the resume is what the UCN pipeline produces, and that is the real "processed" signal. - Rate-limit scope. A "company" is one
Employer(1:1 with aUser), so the per-company and per-user counters both ultimately key off the user id; the scope still names a distinct bucket per the Jira semantics. - Wiring.
ScoringModuleimportsLenuxCommonModule(shared primitives) andNotificationsModule(the shortlist notification); Prisma and the queue come from their global modules. It deliberately does not touchapp.module.ts— the Phase-13 orchestrator owns the top-level wiring.