CODEX // oaoisme wiki

§ unswayed-backend · API contract & docs

Lenux AI — interview question generation (Phase 13, UN-103)

updated 2026-06-16

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

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:

  1. Builds a prompt with no identity. The JD title/skills/requirements go in for generate; for follow-up the candidate's resume is first run through the shared UcnProfileService, which yields a UCN-stripped projection (company names are Confidential, no real name/photo/contact). Resume claims are encoded as addressable refs like experience[0].description so the model can cite which claim a follow-up probes.
  2. Asks for STRICT JSON ({"questions":[…]}) and parses it, tolerating a markdown code fence and a top-level array.
  3. 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 raises 422.
  4. Maps an unconfigured provider to 503. The model is reached through the shared AiCompletionPort (the same OpenAI adapter Lexi uses). A missing OPENAI_API_KEY throws LexiUnavailableError, which the controller turns into a 503 — identical to the Lexi contract.
  5. Scrubs identity defensively. Even though the model only ever sees stripped data, stripIdentity() removes any leaked Confidential / 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 companyId query 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_questionscompanyId, optional employerJobId / candidateId, questionText, category enum, source enum (ai_generated | custom), sourceClaimRef (e.g. experience[2].description), createdBy.
  • question_bankscompanyId, 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 the AiCompletionPort overridden 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.