CODEX // oaoisme wiki

§ unswayed-backend · API contract & docs

Lexi AI — job discovery & match scoring (Phase 14, UN-113)

updated 2026-06-17

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).

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.companyNamecompany, first EmployerJobLocation → JobLocation.namelocation). 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

  1. CompatibilityScoringService.getLatestScore(candidateId, jobId) — if present, use it.
  2. Else computeForCandidate(jobId, candidateId) synchronously, and persist the result as a new versioned candidate_scores row (so the next read is cheap) — presentation persistence over the engine's result, not a new engine.
  3. If compute returns null (the candidate cannot be profiled yet), enqueue a background score-candidates job 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 upsert
  • src/lexi-ai/job-discovery/job-discovery.controller.ts — the five endpoints + jobId parse-to-404
  • src/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 defaults
  • src/lexi-ai/job-discovery/job-discovery.module.ts — imports LexiCommonModule + ScoringModule
  • test/lexi-job-discovery.e2e-spec.ts — 27 e2e cases (records enqueues via a fake QueuePort)

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).