§ unswayed-backend · API contract & docs
Lexi AI — job discovery & match scoring (Phase 14, UN-113)
Lexi AI — job discovery & match scoring (Phase 14, UN-113)
T-14.5 is the applicant's side of compatibility scoring. The crucial design
fact: this slice computes nothing new. It is a thin read / presentation
layer over the Phase-13 UN-102 scoring engine (CompatibilityScoringService)
— there is no second scoring algorithm and no new scores table. Recommendations
come straight out of candidate_scores; the on-demand match path delegates to
the engine; refresh only enqueues the engine's existing background job.
The five endpoints
All live on JobDiscoveryController (@Controller('v1')) behind the standard
Lexi guard stack: JwtAuthGuard, AccountStatusGuard, UserTypeGuard, LenuxRateLimitGuard, LexiFeatureGuard + @RequireUserType(applicant) +
@BareResponse(). The path {userId} must equal the caller
(LexiUserContextService.resolveSelfApplicant), and the resolved Applicant.id
is the scoring candidateId.
| Method + path | Limit | Feature flag | What it does |
|---|---|---|---|
GET users/{userId}/jobs/recommended?page=&limit= |
60/min/user | jobRecommendations |
Latest-version score per job, sorted by totalScore desc, paginated, joined to the job (200) |
POST users/{userId}/jobs/recommended/refresh |
10/hr/user | jobRecommendations |
Enqueue UN-102 scoring for active, unscored jobs (202) |
GET users/{userId}/jobs/{jobId}/match |
60/min/user | jobRecommendations |
One job's score; computes on-demand if uncomputed (200) |
GET users/{userId}/job-preferences |
60/min/user | — | Stored prefs, or defaults (200) |
PUT users/{userId}/job-preferences |
60/min/user | — | Upsert / partial merge (200) |
Only the three scoring routes are gated by the jobRecommendations flag;
job-preferences are ownership-checked only (so a candidate can still set
preferences while recommendations are off).
recommended — read + rank + join, no compute
Reads every candidate_scores row for the caller's candidateId, reduces to the
latest version per employerJobId (a job's score set is bounded, so this is
done in memory — same approach the engine's getRanked uses), sorts by
totalScore descending with a deterministic employerJobId tie-break,
paginates, then joins EmployerJob for only the page (title,
employer.companyName → company, first EmployerJobLocation → JobLocation.name
→ location). A score whose job row is missing degrades gracefully to
null display fields. Each row returns {jobPostingId, title, company, location, totalScore, breakdown, reasonOfMatch, computedAt} plus the standard
{page, limit, total, totalPages} pagination block.
refresh — enqueue only (202)
Loads active EmployerJobs (status = 1) and the candidate's existing scored
employerJobIds in parallel, then enqueues one score-candidates job per
unscored active job:
QueuePort.enqueue(LENUX_QUEUE, LENUX_JOBS.SCORE_CANDIDATES,
{ employerJobId, candidateIds: [applicant.id], recompute: false })
It never runs scoring inline — the Phase-13 ScoreCandidatesHandler does the
compute+persist on the worker. The 202 body is
{status:'queued', userId, estimatedCompletionSeconds}, where the estimate
reuses the engine's estimateCompletionSeconds (~7s/job, floored at 5s).
match — read, else compute-on-demand, else enqueue + 422
CompatibilityScoringService.getLatestScore(candidateId, jobId)— if present, use it.- Else
computeForCandidate(jobId, candidateId)synchronously, and persist the result as a new versionedcandidate_scoresrow (so the next read is cheap) — presentation persistence over the engine's result, not a new engine. - If compute returns
null(the candidate cannot be profiled yet), enqueue a backgroundscore-candidatesjob and return 422 ("queued; try again").
An unknown / non-numeric jobId is 404 (manual parse, matching the Lenux
scoring controller). The response is {jobPostingId, totalScore, breakdown, reasonOfMatch, missingSkills}, where missingSkills = the JD's skills[]
minus the candidate's latest resume resumeData.skills (case-insensitive).
job-preferences — stored, surfaced, not yet weighting the engine
UserJobPreferences is a 1:1 row (userId @id): desiredTitles, locations,
remotePreference (remote|hybrid|onsite|any, default any), salaryMin?,
employmentTypes. GET returns defaults (empty arrays + any) when no row
exists. PUT is a partial merge over the resolved current — an absent field
keeps its current value — then upserts.
Deliberate limitation: preferences are stored and surfaced only. The UN-102 engine is unchanged — it still scores on skills / experience / education / certifications. Folding preference-weighting into the engine is a documented future extension, not part of this slice.
Reuse — the acceptance bar
The slice imports ScoringModule and injects the real
CompatibilityScoringService (getLatestScore, computeForCandidate) plus the
global QueuePort and the shared LENUX_QUEUE / LENUX_JOBS.SCORE_CANDIDATES
constants. A module-wiring test asserts the injected scoring service is the real
one — proving no duplicate scoring code.
Files
src/lexi-ai/job-discovery/job-discovery.service.ts— read/rank/join, refresh enqueue, on-demand match + persist, preferences upsertsrc/lexi-ai/job-discovery/job-discovery.controller.ts— the five endpoints +jobIdparse-to-404src/lexi-ai/job-discovery/dto/job-discovery.dto.ts— recommended paging + the preferences upsert body (enum-validated)src/lexi-ai/job-discovery/job-discovery.types.ts— response shapes + JSON-array narrowing + preference defaultssrc/lexi-ai/job-discovery/job-discovery.module.ts— importsLexiCommonModule+ScoringModuletest/lexi-job-discovery.e2e-spec.ts— 27 e2e cases (records enqueues via a fakeQueuePort)
Testing
TDD throughout; 31 unit + 27 e2e, slice coverage ~99% lines / 97% branches /
100% functions. The e2e boots AppModule + JobDiscoveryModule with a recording
fake QueuePort and exercises: ranking + dedupe-by-version + pagination; the
empty page; refresh's per-job enqueue (and the inactive-job / all-scored cases);
match against an existing score, the on-demand compute that persists a real row
(through the live UN-102 engine), and missingSkills; ownership 403/404, the
feature-flag 403s (and that preferences are not gated), and the verbatim 429
bodies (1m and 1h windows).