CODEX // oaoisme wiki

§ unswayed-backend · API contract & docs

Lenux AI — talent pool & top-3 matches (Phase 13, UN-109)

updated 2026-06-16

Lenux AI — talent pool & top-3 matches (Phase 13, UN-109)

The talent-pool slice (src/lenux-ai/talent-pool/) is the recruiter-facing way to browse candidates beyond the people who applied to a specific job, and to surface the best-fit candidates for a job — including people who never applied. It is the last slice of Phase 13 (Lenux AI) and is built directly on top of the compatibility-scoring slice (UN-102), which it reuses rather than re-implements.

Like every Lenux endpoint it lives on the /api/v1/* surface: bare camelCase JSON (no {status,message,data} envelope), employer-only, the global per-IP throttle skipped in favour of per-user Lenux rate limits, and behind a company feature flag.

What it exposes

Three GET endpoints, all employer-only, all 60 requests / minute / user, all gated on the scoring feature flag (the pool reads conceptually belong to the same recruiter-intelligence feature as scoring, so one flag governs all three):

Endpoint Purpose
GET /api/v1/companies/{companyId}/talent-pool The filtered, sorted, paginated candidate pool visible to the company.
GET /api/v1/jobs/{jobId}/talent-pool/top-matches?limit=3 The top candidates for a job by compatibility score, across the whole pool.
GET /api/v1/talent-pool/{candidateId} UCN-stripped detail for one candidate.

companyId is the caller's Employer.id (a uuid); jobId is the numeric EmployerJob.id; candidateId is an Applicant.id (a uuid). Tenant scoping is done with LenuxCompanyContextServicerequireCompany for the company-scoped routes (a mismatched companyId → 403) and requireJob for top-matches (a job owned by another employer → 404).

The core idea: who is "in the pool"?

The pool for a company is the union of two sets, deduplicated:

  1. The opted-in global pool — every Applicant with talentPoolOptIn = true. These candidates have explicitly said "any employer may discover me".
  2. The company's own applicants — anyone with a JobApplication to any of the company's jobs, regardless of their opt-in flag.

The important rule that falls out of this: a candidate with talentPoolOptIn = false is excluded from a company's pool unless they are a direct applicant to that company. You can always see people who applied to you; you can only see strangers if they opted in. This is implemented in TalentPoolService.visibleApplicantIds(employerId), which runs both queries in parallel and folds the ids into a Set (the dedupe).

isVisibleToCompany(employerId, candidateId) reuses that same set for the detail-endpoint's 404 gate — if the candidate isn't in the visible set, the endpoint 404s before building any profile (so it never leaks the existence of a private candidate).

UCN stripping is the default, always

Every candidate the API returns is projected through UcnProfileService.build(), the shared identity-stripping projection. There is no disclosure-gate table in the system today, so identity is always stripped — ucnState is always "stripped", the displayName is always Candidate #UCN-…, and company / institution names in experience and education are always the literal string "Confidential". The detail endpoint's experience/education arrays come straight from this projection, so the real employer and school names physically never appear in the response payload.

The list endpoint — filter, sort, paginate

GET …/talent-pool accepts:

  • skills — comma-separated; AND / contains semantics: a candidate must contain every requested token (case-insensitive substring), e.g. skills=react,node keeps only candidates who have both.
  • experienceYearsMin / experienceYearsMax — inclusive bounds on the summed years of experience.
  • location — case-insensitive substring match on the candidate's (coarse, country-level) location.
  • availability — exact (case-insensitive) match on the self-declared availability string (open_to_offers | not_looking | …).
  • sortBy — one of recentlyActive (default; Applicant.updatedAt desc), experienceYears (desc), or relevanceScore. relevanceScore is only meaningful with a job context; the company list has no job, so it degrades gracefully to recentlyActive ordering (every candidate's relevance is null, so the sort falls through to the recency tie-break).
  • page / limit — camelCase pagination; limit is capped at 100.

Filtering and sorting are done in memory (talent-pool.query.ts: pure matchesFilters / sortCandidates helpers) after building the stripped profiles, because the filterable fields (summed experience years, parsed skills) are derived from resume data, not first-class indexed columns. The pool per company is bounded, so this is correct and simple; the helpers are pure and exhaustively unit-tested. Sorts carry a deterministic candidateId tie-break so pagination is stable.

Response shape:

{
  "data": [
    { "candidateId": "…", "displayName": "Candidate #UCN-1055",
      "headline": "Backend Engineer, 5 years experience",
      "skills": ["NestJS"], "experienceYears": 5,
      "location": "United States", "availability": "open_to_offers",
      "ucnState": "stripped" }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
}

Top-matches — the interesting part

GET /jobs/{jobId}/talent-pool/top-matches?limit=3 ranks candidates by CandidateScore.totalScore, and crucially it ranks the whole visible pool, not just applicants — so a strong opted-in stranger can out-rank a weak applicant.

The algorithm (TalentPoolService.topMatches):

  1. Build the visible candidate set for the job's company (same union as above).
  2. For each candidate, read the latest persisted score via CompatibilityScoringService.getLatestScore(candidateId, jobId).
  3. Partition into already-scored vs unscored.
  4. Handle the unscored set by size:
    • ≤ 3 unscored → score them synchronously with computeForCandidate(jobId, candidateId). This is the pure, deterministic compute engine (no DB write, no LLM, no I/O), so it is well under the 3-second budget the ticket calls for — that is why the synchronous path is safe and no explicit timer is needed.
    • > 3 unscored → don't block the request. Enqueue a score-candidates job on the lenux-ai queue (the exact same job UN-102 uses) and return scoringInProgress: true. The client polls again once the background worker has persisted the scores.
  5. Sort the candidates that now have a score by totalScore desc (with a candidateId tie-break) and return the top limit (default 3).

Response shape:

{
  "jobId": "123",
  "scoringInProgress": false,
  "matches": [
    { "candidateId": "…", "totalScore": 87.5,
      "breakdown": { "skills": 90, "experience": 80, "education": 100, "certifications": 100 },
      "reasonOfMatch": ["Strong skills match…"],
      "displayName": "Candidate #UCN-1055", "ucnState": "stripped",
      "source": "talent_pool" }
  ]
}

Every match carries source: "talent_pool" so the caller can tell these apart from scores that came from the applicant-ranking endpoint.

How it reuses, not duplicates, the scoring slice

TalentPoolModule imports LenuxCommonModule (tenant context, UCN profile, rate-limit + feature guards) and ScoringModule, and injects CompatibilityScoringService. It calls exactly three things on it: getLatestScore (read), computeForCandidate (pure on-demand compute, no persist) and, indirectly, the same score-candidates queue job (via QueuePort) when it needs to fan a large batch out to the background. There is no second scoring implementation — the determinism and versioning guarantees of UN-102 are inherited for free.

Files

  • talent-pool.controller.ts — the three handlers, guard stack, query parsing, response shaping.
  • talent-pool.service.ts — visibility union, profile loading, list, top-matches orchestration (sync-vs-queue decision).
  • talent-pool.query.ts — pure filter/sort/parse helpers (the in-memory query engine).
  • talent-pool.module.ts — wiring (imports LenuxCommonModule + ScoringModule).
  • Unit specs alongside each; e2e at test/lenux-talent-pool.e2e-spec.ts (boots the real app + Postgres, drains the queue inline for the >3 case — no sleeps).

Gotchas

  • Don't confuse the two "limits". The list endpoint's limit is pagination (cap 100). The top-matches limit is "how many matches to return" (default 3, also capped defensively at 100). They are separate parameters on separate routes.
  • scoringInProgress only goes true on the >3-unscored path. If everyone is already scored, or ≤3 are unscored (and get computed synchronously), it is false and the matches are returned immediately.
  • A private non-applicant is invisible to the detail endpoint — it 404s before building a profile, mirroring the list exclusion. Visibility is decided once in visibleApplicantIds and reused everywhere.
  • Relevance sort without a job is a no-op — there's no jobId on the company list, so relevanceScore falls back to recentlyActive by design (documented, not a bug).