§ unswayed-backend · API contract & docs
Lexi AI — assistant chat (Phase 14, UN-114)
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-only —
check_ats_score,job_match,recommendations. Resolved inline: the matching sibling service runs immediately, the assistant message carries the result underdataandactionTaken: null. - Content-generating —
tailor_resume,generate_cover_letter,apply_tailoring. Never run inline. ALexiPendingAction(pending_confirmation,expiresAt = now + 15minviaClockService) is created and the message returnsdata:{type:'pending_action'}plus anactionTakensummary ({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_score→ResumeTailoringService.atsScan(userId, applicant, resumeId, {jobPostingId})job_match→JobDiscoveryService.match(applicant, jobId)recommendations→JobDiscoveryService.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_resume→ResumeTailoringService.tailor(...)generate_cover_letter→CoverLetterService.generate(...)apply_tailoring→ResumeTailoringService.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:
- Session ownership — every endpoint resolves the session via
requireSession(id, user.id); another user's session 404s. - No cross-user context injection — an attached
contextPayload.entityIdis ownership-checked before it ever reaches the LLM: aresumemust belong to the caller's applicant, ajobmust 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 mappingsrc/lexi-ai/chat/chat.service.ts— session lifecycle/ownership, message handling, read-only routing, pending-action propose/confirm, historysrc/lexi-ai/chat/chat-intent.service.ts— LLM intent classifier (retry-once,unknownfallback)src/lexi-ai/chat/chat-intent.types.ts— intent vocabulary + param whitelist/coercionsrc/lexi-ai/chat/dto/send-message.dto.ts— message body + ownership-checked contextsrc/lexi-ai/chat/chat.module.ts— importsLexiCommonModule+ the three sibling modules; binds its ownAiCompletionPorttest/lexi-chat.e2e-spec.ts— 27 e2e cases (scriptedAiCompletionPortfake)
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).