CODEX // oaoisme wiki

§ unswayed-backend · API contract & docs

Lexi AI — assistant chat (Phase 14, UN-114)

updated 2026-06-17

Lexi AI — assistant chat (Phase 14, UN-114)

T-14.6 is the applicant's conversational front door to every other Lexi tool. It is a persisted chat with a confirm-before-act loop: read-only questions are answered inline, but anything that generates content (tailoring, a cover letter, applying changes) is proposed as a pending action and only runs when the user explicitly confirms. It mirrors the Lenux recruiter chat (UN-106) but is per-user / applicant-side and routes to the Lexi sibling slices.

The four endpoints

All live on ChatController (@Controller('v1')) behind the standard Lexi guard stack: JwtAuthGuard, AccountStatusGuard, UserTypeGuard, LenuxRateLimitGuard, LexiFeatureGuard + @RequireUserType(applicant) + @BareResponse(), every handler gated by @RequireLexiFeature('chat'). Unlike the rest of Phase 14 there is no {userId} path param — the routes hang off the authenticated caller (user.id); session ownership (session.userId === user.id) is re-checked on every endpoint, returning 404 on mismatch so existence never leaks.

Method + path Limit(s) What it does
POST lexi/chat/sessions 60/min/user Create a LexiChatSession for the caller (201)
POST lexi/chat/sessions/{id}/messages 30/min/user AND 200/hr/user Persist message, classify intent, answer inline or propose a pending action (201)
POST lexi/chat/sessions/{id}/actions/{actionId}/confirm 60/min/user Execute the pending action; expired → 410 (200)
GET lexi/chat/sessions/{id}/messages 60/min/user History, createdAt asc, wrapped in {data} (200)

The two message windows

The message endpoint enforces two rate-limit windows — one decorator can only express one. @RateLimit({limit:30, windowMs:60_000, scope:'user'}) covers the per-minute budget via LenuxRateLimitGuard; the 200/hour/user budget is enforced by hand against the same shared store:

const r = this.rateLimit.hit(`lexi-msg-user:${user.id}`, 200, 3_600_000);
if (!r.allowed) throw new RateLimitExceededException(200, '1h', r.retryAfterSeconds);

Either window trips the verbatim Jira 429 body ({error:'rate_limit_exceeded', limit, window, retryAfterSeconds} + Retry-After).

Intent classification

ChatIntentService asks AiCompletionPort (bound locally via the shared openaiConfig factory, models.intent, temperature 0, JSON mode) for STRICT JSON {intent, params}, parses + validates, retries once on malformed output, and degrades to the safe unknown intent rather than hard-failing — a chat must never 422 on a fuzzy message. A LexiUnavailableError (unset key) propagates and the controller maps it to 503. The prompt carries small, identity-free hint lists of the applicant's resumes and the active jobs so the model can resolve "my resume" / "the backend role" to an id; params are whitelisted + coerced (positive-int ids, trimmed strings, deduped change-id arrays) so a malformed or injected field is dropped.

The vocabulary splits in two:

  • Read-onlycheck_ats_score, job_match, recommendations. Resolved inline: the matching sibling service runs immediately, the assistant message carries the result under data and actionTaken: null.
  • Content-generatingtailor_resume, generate_cover_letter, apply_tailoring. Never run inline. A LexiPendingAction (pending_confirmation, expiresAt = now + 15min via ClockService) is created and the message returns data:{type:'pending_action'} plus an actionTaken summary ({actionId, type, payload, status, expiresAt}).

Read-only routing (reuse, don't rebuild)

The chat injects the three sibling services and calls their real methods:

  • check_ats_scoreResumeTailoringService.atsScan(userId, applicant, resumeId, {jobPostingId})
  • job_matchJobDiscoveryService.match(applicant, jobId)
  • recommendationsJobDiscoveryService.recommended(applicant, 1, limit) (default limit 5)

When a required parameter is missing (e.g. ATS scan with no job) the chat replies in plain text asking for it rather than erroring. The resume defaults to the applicant's latest resume when none is named; a named resume is ownership-checked first.

Confirm-before-act

confirmAction loads the action, asserts it belongs to the session (else 404), rejects a non-pending_confirmation status as 410 (single-shot), and checks expiry against ClockService.now() — an expired action is flipped to expired and 410'd. Otherwise it dispatches to the relevant service and stores the result:

  • tailor_resumeResumeTailoringService.tailor(...)
  • generate_cover_letterCoverLetterService.generate(...)
  • apply_tailoringResumeTailoringService.apply(...)

The action is then marked executed with its result persisted, and the response is {actionId, status:'executed', result}. (A confirmed content action that calls the LLM can itself raise LexiUnavailableError → 503; the confirm handler maps it too.)

Cross-user safety

Two layers, both proven in e2e:

  1. Session ownership — every endpoint resolves the session via requireSession(id, user.id); another user's session 404s.
  2. No cross-user context injection — an attached contextPayload.entityId is ownership-checked before it ever reaches the LLM: a resume must belong to the caller's applicant, a job must exist; a non-numeric / unowned id is 403. The same check guards a content-generating action that names a resume.

Files

  • src/lexi-ai/chat/chat.controller.ts — the four endpoints + the dual message window + 503 mapping
  • src/lexi-ai/chat/chat.service.ts — session lifecycle/ownership, message handling, read-only routing, pending-action propose/confirm, history
  • src/lexi-ai/chat/chat-intent.service.ts — LLM intent classifier (retry-once, unknown fallback)
  • src/lexi-ai/chat/chat-intent.types.ts — intent vocabulary + param whitelist/coercion
  • src/lexi-ai/chat/dto/send-message.dto.ts — message body + ownership-checked context
  • src/lexi-ai/chat/chat.module.ts — imports LexiCommonModule + the three sibling modules; binds its own AiCompletionPort
  • test/lexi-chat.e2e-spec.ts — 27 e2e cases (scripted AiCompletionPort fake)

Testing

TDD throughout; 70 unit + 27 e2e. The e2e boots AppModule + ChatModule with a scripted AiCompletionPort (replies consumed FIFO so both the intent call and a confirmed action's content call are deterministic and offline) and exercises every surface: session create (bare/persisted, 401/403/404, feature-flag off); each read-only intent inline; each content intent proposed-not-executed (asserting no tailoring run / cover letter row is written until confirm); confirm executing a cover letter and a tailor run with results stored; the 410 paths (re-confirm + expired); cross-session 404; ownership 404s and the cross-user resume-context 403; the 503 path; and both verbatim 429 windows (1m and 1h).