CODEX // oaoisme wiki

§ unswayed-backend · API contract & docs

Lenux AI — assistant chat (Phase 13, UN-106)

updated 2026-06-16

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 with actionTaken: null.
    • state-changing intent (shortlist/reject/status) → creates a LenuxPendingAction (pending_confirmation, expiresAt = now + 15min) and returns it as actionTaken with data.type = "pending_action". Nothing is mutated yet.
  • POST .../actions/{actionId}/confirm — executes the pending action (the shortlist mutation: set JobApplication.status = shortlisted in a transaction, write the append-only status-history row, notify), marks it executed and 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.userId and session.companyId must match the caller) — else 404, no cross-session leakage.
  • Cross-tenant context injection is blocked: a contextPayload.entityId is 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 autoRanking is 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.