§ unswayed-backend · API contract & docs
Lenux AI — interview question generation (Phase 13, UN-103)
Lenux AI — interview question generation (Phase 13, T-13.2 / UN-103)
This slice gives recruiters AI-generated interview questions and a place to curate
them. It lives at src/lenux-ai/interview-questions/ and rides the shared
Lenux /api/v1/* surface (bare camelCase JSON, employer-only, per-company /
per-user rate limits). It is the second slice of Phase 13, built on the common
primitives the orchestrator landed in src/lenux-ai/common/.
What it does
Two LLM-backed endpoints produce questions; the rest is plain company-scoped CRUD for storing and organising them.
| Method & path | Limit | What it does |
|---|---|---|
POST /api/v1/jobs/:jobId/interview-questions/generate |
30/hour/company | Generate questions for a job from its JD + skills |
POST /api/v1/candidates/:candidateId/interview-questions/follow-up?jobId= |
30/hour/company | Generate follow-ups anchored to a candidate's resume claims |
GET /api/v1/question-banks?companyId= |
60/min/user | List banks (each with a questionCount) |
POST /api/v1/question-banks?companyId= |
60/min/user | Create a bank {name, roleTag?} |
PUT /api/v1/question-banks/:id |
60/min/user | Rename / re-tag a bank |
DELETE /api/v1/question-banks/:id |
60/min/user | Delete a bank |
GET /api/v1/question-banks/:bankId/questions |
60/min/user | List the questions in a bank |
POST /api/v1/question-banks/:bankId/questions |
60/min/user | Add a question to a bank {questionId} |
PUT /api/v1/interview-questions/:id |
60/min/user | Edit a question's text (flips source → custom) |
The two AI endpoints carry @RequireLenuxFeature('questionGeneration'), so a
company can switch them off via its CompanyLenuxSettings.
How generation works (the AI path)
QuestionGenerationService is the only piece that talks to the model. It:
- Builds a prompt with no identity. The JD title/skills/requirements go in
for
generate; forfollow-upthe candidate's resume is first run through the sharedUcnProfileService, which yields a UCN-stripped projection (company names areConfidential, no real name/photo/contact). Resume claims are encoded as addressable refs likeexperience[0].descriptionso the model can cite which claim a follow-up probes. - Asks for STRICT JSON (
{"questions":[…]}) and parses it, tolerating a markdown code fence and a top-level array. - Validates the category enum against
technical | behavioral | situational | culture_fit(normalising case / dashes / spaces). Malformed individual items are dropped; if the whole response is unusable it retries exactly once, then raises422. - Maps an unconfigured provider to 503. The model is reached through the
shared
AiCompletionPort(the same OpenAI adapter Lexi uses). A missingOPENAI_API_KEYthrowsLexiUnavailableError, which the controller turns into a503— identical to the Lexi contract. - Scrubs identity defensively. Even though the model only ever sees stripped
data,
stripIdentity()removes any leakedConfidential/Candidate #UCN-…markers from the generated text before it is persisted.
Persistence (InterviewQuestionsService.persistGenerated / persistFollowUps)
writes the rows inside a single prisma.$transaction of ordered create calls —
not createMany — so the returned rows keep the model's ordering and carry their
new ids (a timestamp read-back is non-deterministic for same-millisecond inserts).
Tenant isolation (the important invariant)
Banks and questions are strictly company-scoped. Every read and write resolves
the caller's employer through LenuxCompanyContextService and then re-checks the
target row's companyId:
- A
companyIdquery param that isn't the caller's employer →403. - A bank or question owned by another company →
404(existence is never leaked). - Adding a cross-tenant question to your own bank →
403(the bank is yours, but the question isn't).
requireJob is used for the job-scoped routes (job not owned → 404).
Data model
Three tables (added to the schema by the Phase-13 migration):
interview_questions—companyId, optionalemployerJobId/candidateId,questionText,categoryenum,sourceenum (ai_generated|custom),sourceClaimRef(e.g.experience[2].description),createdBy.question_banks—companyId,name,roleTag?.question_bank_items— the(bankId, questionId)pivot.
Editing a question's text via PUT /interview-questions/:id flips its source
from ai_generated to custom, marking it as human-curated.
Rate limiting & the 429 body
The AI endpoints are limited 30/hour/company; the CRUD endpoints 60/min/user. When
a window is exhausted the shared LenuxRateLimitGuard emits the verbatim Jira
body and a Retry-After header:
{ "error": "rate_limit_exceeded", "limit": 30, "window": "1h", "retryAfterSeconds": 1800 }
The global per-IP throttler is @SkipThrottle()'d off on this controller.
Testing
- Unit (89 tests) — the category/identity helpers, the generation service (happy path, fence stripping, retry-on-malformed, retry-on-all-invalid-category, 503 passthrough, identity scrub, follow-up claim refs), the persistence/CRUD service (tenant 403/404 across every read & write), the controller (503 mapping, bare-response shaping, delegation), and every DTO's validation rules.
- e2e (20 tests,
test/lenux-interview-questions.e2e-spec.ts) — boots the real app with theAiCompletionPortoverridden by a scripted fake, then drives every endpoint against Postgres: generation + persistence, the retry→422 path, the 503 path, cross-tenant 404/403, employer-only 403, 401, the verbatim 429 body after 30 calls, feature-flag 403, and the full bank/question CRUD lifecycle.
Coverage on the slice is ~96% statements / ~97% branches, above the repo's 90% gate on all four metrics.