§ unswayed-backend · API contract & docs
Lenux AI — talent pool & top-3 matches (Phase 13, UN-109)
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 LenuxCompanyContextService — requireCompany 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:
- The opted-in global pool — every
ApplicantwithtalentPoolOptIn = true. These candidates have explicitly said "any employer may discover me". - The company's own applicants — anyone with a
JobApplicationto 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,nodekeeps 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 ofrecentlyActive(default;Applicant.updatedAtdesc),experienceYears(desc), orrelevanceScore.relevanceScoreis 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):
- Build the visible candidate set for the job's company (same union as above).
- For each candidate, read the latest persisted score via
CompatibilityScoringService.getLatestScore(candidateId, jobId). - Partition into already-scored vs unscored.
- 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-candidatesjob on thelenux-aiqueue (the exact same job UN-102 uses) and returnscoringInProgress: true. The client polls again once the background worker has persisted the scores.
- ≤ 3 unscored → score them synchronously with
- Sort the candidates that now have a score by
totalScoredesc (with acandidateIdtie-break) and return the toplimit(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 (importsLenuxCommonModule+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
limitis pagination (cap 100). The top-matcheslimitis "how many matches to return" (default 3, also capped defensively at 100). They are separate parameters on separate routes. scoringInProgressonly goes true on the >3-unscored path. If everyone is already scored, or ≤3 are unscored (and get computed synchronously), it isfalseand 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
visibleApplicantIdsand reused everywhere. - Relevance sort without a job is a no-op — there's no jobId on the company
list, so
relevanceScorefalls back to recentlyActive by design (documented, not a bug).