§ unswayed-backend · API contract & docs
Lenux AI — assistant chat (Phase 13, UN-106)
What it is
The chat slice (src/lenux-ai/chat/) is the recruiter assistant — a conversational
front door to the rest of Lenux. A recruiter types "show me the top 5 candidates for the
backend role" and gets data inline; they type "shortlist these three" and the assistant
proposes an action that only runs after explicit confirmation. It's on the /api/v1/*
surface (bare camelCase, employer-only) behind the chat feature flag.
The four endpoints
POST /api/v1/lenux/chat/sessions— opens a session bound to{userId, companyId}.POST /api/v1/lenux/chat/sessions/{id}/messages— the core. Persists the user message, classifies intent via the LLM (AiCompletionPort→ strict JSON{intent, params}), and routes:- read-only intent (e.g. top candidates) → calls the shared
CompatibilityScoringService.getRanked(...)and returns the data inline withactionTaken: null. - state-changing intent (shortlist/reject/status) → creates a
LenuxPendingAction(pending_confirmation,expiresAt = now + 15min) and returns it asactionTakenwithdata.type = "pending_action". Nothing is mutated yet.
- read-only intent (e.g. top candidates) → calls the shared
POST .../actions/{actionId}/confirm— executes the pending action (the shortlist mutation: setJobApplication.status = shortlistedin a transaction, write the append-only status-history row, notify), marks itexecutedand stores the result. An expired pending action returns 410 Gone.GET .../sessions/{id}/messages— the conversation history.
Rate limits — two windows on one endpoint
POST .../messages is limited 30/min per user (the @RateLimit decorator) and
200/hour per company (LLM cost). Since one decorator carries one rule, the
company-hour limit is enforced manually inside the handler against the shared
LenuxRateLimitStore (store.hit('chat-messages-company:'+companyId, 200, 3600000)),
throwing the same verbatim RateLimitExceededException. So both 429 bodies look identical
to the client.
Safety
- Session ownership is checked on every endpoint (
session.userIdandsession.companyIdmust match the caller) — else 404, no cross-session leakage. - Cross-tenant context injection is blocked: a
contextPayload.entityIdis validated against the caller's company before it ever reaches the LLM. - UCN-respecting: no identity fields are passed into the prompt.
- Feature-flag aware: when
autoRankingis off, the intent router refuses to offer ranking/auto-ranking actions (it returns a plain "ranking is disabled" reply instead of invoking the scorer).
Why confirm-before-act
The whole point is that the assistant can take actions, which makes a misread intent
dangerous. Separating "propose" (a persisted LenuxPendingAction with a TTL) from
"execute" (/confirm) means a hallucinated or ambiguous instruction can never silently
shortlist or reject candidates — the recruiter always sees and confirms the exact payload
first, and stale proposals expire.