ADR-045: Conversational AI Trip-Brief Chat
Withdrawn (2026-08-11) — AI support has been removed from the product. See ADR-052. This record is kept for historical context only.
- Status: Withdrawn by ADR-052 (AI support removed)
- Date: 2026-06-23
- Depends on: ADR-042 (Per-User BYO-Token AI), ADR-043 (Synchronous Structural Computation)
Context and Problem Statement
The "Assistant IA" card on the trip-creation screen (pwa/src/components/ai-chat-card.tsx) is a stub: the assistant turns are canned local strings, and "Valider et continuer" fires a single-shot generation via POST /trips/ai-generate with the raw textarea text as a brief (handleAiGeneration, use-trip-planner.ts). Recette #649 asks for the opposite: the rider should be able to converse with the AI so it can refine the need and ask clarifying questions, and only launch the itinerary computation once the brief is good enough.
The building blocks already exist (do not rebuild them):
- Per-user LLM resolution (ADR-042):
UserLlmResolverInterface::forUser()→ResolvedLlmClient, withLlmClientInterface::chat(model, messages[], systemPrompt, options)returning free text (no nativeresponse_format/ tool-call; JSON is requested in the prompt and parsed leniently). - Lenient JSON parsing of an LLM reply into a typed action (
ChatActionInterpreter, used by the loaded-trip chatPOST /trips/{id}/ai-chat). - The whole generation pipeline:
POST /trips/ai-generate→GenerateAiRouteHandler→AiTripGenerationService::generate(brief, ResolvedLlmClient, locale)(LLM spec extraction → geocode → coverage guard → Valhalla routing), already hardened with corrective re-prompts andAiGeneratedRouteoutcomes. - Per-user rate limiting on the AI endpoints.
The gap is purely the multi-turn conversation that builds the brief before generation. The loaded-trip chat (POST /trips/{id}/ai-chat, TripChatProcessor, ChatHistoryStore) cannot be reused as-is: it is keyed by an existing tripId, its actions mutate an existing trip, and its history store assumes a trip exists.
Scope and Non-Goals
This ADR covers only the pre-trip generation chat — the conversational refinement of a brief before any trip exists, ending in a call to the existing generation pipeline.
Out of scope: the in-ride / loaded-trip chat.
Superseded by ADR-048 (2026-08-06): the paragraph below is history. The loaded-trip chat, its mutation actions (
split_stage/merge_stages/find_poi/ …) and the persistedTripChatMessagehistory have all been removed, and the in-ride POI search has been rebuilt AI-free over the Tier-1 index. Only the pre-trip brief chat this ADR introduces remains an AI surface; it is unaffected.
The assistant on an existing trip is already implemented and is covered by ADR-042: POST /trips/{id}/ai-chat (TripChatProcessor), front panel ai-chat-panel.tsx, the split_stage / merge_stages / add_waypoint / change_accommodation / adjust_distance / change_route / find_poi / info actions, in-ride POI search (InRideAssistant, ADR-040 PostGIS), and persisted history (TripChatMessage, GET /trips/{id}/ai-chat-history). This ADR reuses its ChatActionInterpreter lenient-parsing approach but does not modify it. The two chats are distinct endpoints with distinct purposes — build a brief vs. mutate an existing trip — and share no state. Any recette feedback that the in-ride assistant "does not work" is tracked separately (it is most likely a side effect of the now-fixed AI-analysis persistence bug, to be reconfirmed once the recette stack is rebuilt with a configured provider), not a gap this ADR addresses.
Decision Drivers
- Real refinement loop — the AI must ask questions and converge, not echo canned text (recette #649).
- Reuse the generation pipeline — no second LLM→geocode→Valhalla path; the chat only produces the brief.
- Stateless backend — the app keeps computation stateless (ADR-043); avoid a new server-side conversation session if the client can carry it.
- User stays in control — the rider can launch at any time; the AI recommends, it does not gate.
- Bounded LLM cost — one call per user turn (BYO-token), capped by a turn limit, per-user rate limiting, and a server-side payload ceiling (max message count + per-message length).
- Untrusted client input — the conversation is supplied by the browser each turn; message roles and payload size are validated server-side so a malicious client cannot inject a
systemturn or inflate token cost.
Considered Options
Option A — Keep the single-shot stub
Rejected. No conversation: the rider cannot refine the need and the AI cannot ask questions, which is exactly the recette ask.
Option B — Server-side conversation session (Redis)
A new pre-trip session store (mirroring ChatHistoryStore but keyed by a session id instead of a trip id) holds the running conversation server-side. Rejected: it adds transient state, a session identifier to mint and expire, and a new Redis pool — for no benefit, since the client already holds the message list and the backend is otherwise stateless.
Option C — Stateless chat endpoint + reuse generation (Chosen)
A new stateless endpoint takes the conversation from the client on every turn and returns the assistant reply plus a structured verdict; the client owns the history. Launching the trip reuses the existing POST /trips/ai-generate with a consolidated brief.
Decision
Option C.
New endpoint — POST /trips/ai-chat (stateless)
- Input:
{ messages: [{ role: "user" | "assistant", content: string }] }— the full conversation so far, sent by the client each turn. The processor enforces a hard ceiling of N messages (e.g. 2x the client-side turn cap) and a per-message character limit (e.g. 4,000 characters), returning a 422 if either bound is exceeded. - Output (structured per turn):
{ reply: string, readyToGenerate: boolean, collected: { start?, end?, durationDays?, profile?, ... } }. - Processor (stateless State Processor): resolves the per-user provider (
forUser(); returns a 422 with{ error: "ai_not_configured" }if no provider is configured — diverging from the in-chatinfohint used by the loaded-trip chatPOST /trips/{id}/ai-chat(ADR-042 error taxonomy) because the caller here needs a discrete signal to surface the provider-setup CTA before any chat UI is mounted), applies a dedicated per-user rate limit, validates that every message role is strictlyuserorassistant(any other value, includingsystem, is rejected with a 422 before reaching the LLM), builds a system prompt that instructs the model to (a) ask focused clarifying questions, (b) keep a running structured summary of what it has understood, and (c) signalreadyToGenerateonce it has the essentials. It callsLlmClientInterface::chat(...)and parses the reply leniently into the structured shape, reusing theChatActionInterpreterapproach (Markdown-fence tolerant, prose-wrapper tolerant, fallback to a plain reply withreadyToGenerate: falseon parse failure). No server-side conversation state is stored.
Launching the computation — reuse POST /trips/ai-generate
"Lancer le calcul d'itinéraire" consolidates the conversation into a single brief (the collected parameters, plus the user turns as fallback) and calls the existing POST /trips/ai-generate. AiTripGenerationService re-extracts the spec and runs the unchanged geocode → coverage → Valhalla pipeline. The chat never routes; it only produces the brief.
Front-end behaviour (ai-chat-card.tsx)
- Each user message posts the conversation to
/trips/ai-chat; the reply is appended to the visible history. - The "Lancer le calcul d'itinéraire" button is clickable at any time, with one hard floor: at least a geocodable departure must be present in
collected(otherwise the generation has nothing to route).readyToGeneratefrom the model drives the recommended/highlighted state of the button — the AI recommends, the rider decides. - A short recap of the collected parameters is shown alongside the chat so the rider sees what the AI understood.
- A turn cap (client-side) plus the per-user rate limit bound the LLM cost; reaching the cap nudges toward launching.
Consequences
Positive
- Real conversational refinement — the AI asks questions and converges; the rider keeps control via an always-available launch.
- Maximum reuse — generation (
/trips/ai-generate), provider resolution (ADR-042), lenient JSON parsing, and rate-limiting patterns are reused; only one stateless endpoint and the card UI are new. - Stateless — no pre-trip session, no new Redis pool, consistent with ADR-043.
Negative
- Conversation re-sent each turn — the client posts the growing message list every turn; bounded by the turn cap, and conversations are short by design.
- No native structured output — the verdict is requested via the prompt and parsed leniently; mitigated by reusing the battle-tested
ChatActionInterpretertolerance and a safe fallback (readyToGenerate: false). - One LLM call per user turn — more provider calls than the single-shot stub; on BYO-token plans this is the rider's quota, bounded by the turn cap and rate limit.
Neutral
- Double spec pass — the chat collects parameters, then
AiTripGenerationServicere-extracts the spec from the consolidated brief. Acceptable: it keeps the chat decoupled from the routing pipeline and avoids a second generation path. - Stub replaced —
ai-chat-card.tsx's cannedstubGreeting/stubReplyand the direct single-shothandleAiGenerationwiring are superseded by the live chat; the i18n keys move from canned copy to real chat strings.