CODEX // oaoisme wiki

§ unswayed-backend · API contract & docs

Lenux AI — compatibility scoring (Phase 13, UN-102)

updated 2026-06-16

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 global ResponseInterceptor honours 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 a Retry-After header.

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+) years pattern in the requirements/description, else a map from experienceLevel (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 actual ApplicantResume row 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 a User), 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. ScoringModule imports LenuxCommonModule (shared primitives) and NotificationsModule (the shortlist notification); Prisma and the queue come from their global modules. It deliberately does not touch app.module.ts — the Phase-13 orchestrator owns the top-level wiring.