§ unswayed-backend · Chat & messaging (Phase 9)
Lexi AI assistant
Lexi AI assistant
Lexi is the public, throttled AI assistant (§9) — the second half of Phase 9, fully
independent of the chat domain. Two routes, no database models, one big prompt. It
lives in src/chat-ai/.
The wire
POST /api/ai/assistant takes question (≤ 1500 chars) and an optional session_id
(UUID). The response is always { answer, session_id } — the session id is
generated server-side when omitted and always echoed back, so the first response
seeds the thread id the client persists. POST /api/ai/assistant/reset forgets a
session ("New Chat"). Both routes are public (OptionalJwtAuthGuard — a bearer
token personalizes, guests get the generic greeting) and hard-throttled at 30
requests/minute/IP (the legacy throttle:30,1, enforced route-level regardless of
the global limit).
The prompt is a versioned artifact
The legacy buried a ~43 KB "Lexi v2.0" system prompt — the entire platform knowledge
base, tone rules, and guardrails — in a PHP heredoc inside the service. The rebuild
ports it verbatim (byte-verified: same length, same 799 lines, the \$ escapes
unescaped) into lexi-prompt.v2.ts, a standalone versioned file that can be reviewed
and revised independently of the service code. The per-user session-context and
identity lines are ported with their exact strings; since the rebuild's users table
has no name columns, personalization resolves names through the same shared
applicant/employer name resolution every resource uses.
Sessions: ephemeral by design, Redis when available
History lives under lexi_session_<uuid> — 10 turns (20 messages), 2-hour TTL
refreshed on every save — exactly the legacy cache semantics. The store is a small
SessionStorePort with two adapters chosen at boot, mirroring the queue's
optional-Redis posture (ADR-0020): when the queue's REDIS_URL detection finds a
reachable Redis, sessions go there (SETEX); otherwise a bounded, clock-injected
in-memory map serves dev/test. Ephemeral is a feature, not a bug — a cache flush
loses the thread, which is the documented legacy behavior; persistence/analytics is
flagged future work.
The completion port and the 503 taxonomy
The OpenAI call sits behind AiCompletionPort — gpt-4o (from OPENAI_MODEL) with
the verbatim sampling params (max_tokens 800, temperature 0.6, top_p 0.95, frequency_penalty 0.1, presence_penalty 0.0) so answers stay on-brand. Every failure
class maps to a user-safe 503 with the exact legacy strings: a missing
OPENAI_API_KEY → The AI assistant is not configured. Please contact support.
(the app boots and the whole test pyramid runs without a key — that 503 is the
contract, like JSearch's); transport failures → Could not reach the AI assistant. Please check your connection and try again.; an empty/odd response → Received an unexpected response from the AI. Please try again.; provider/quota errors per the
legacy switch. Nothing provider-internal ever leaks; anything genuinely unexpected is
the logged 500 fallback.
Testing it
The unit suite (131 tests) pins session resolution, the history cap/TTL via an
injected clock, the personalized-vs-guest prompt branch, both stores, every 503
string, and the throttle metadata. The e2e suite proves history actually flows
without poking internals: it overrides AiCompletionPort with a scripted fake whose
answer encodes the received messages array — so turn two demonstrably sees turn one,
and a reset demonstrably forgets it. The unconfigured-503 and the 31st-request-429
each get their own app instance.