Chat Pipeline

Mobile chat UX: SSE event flow, per-message feedback, Quick Help overlay, PDF export, conversation management.

Overview

The chat surface is the GENIE.AI mobile app’s main user-facing feature. This document describes what the user sees and how it is wired — from sending a message to receiving a streamed RAG answer, leaving feedback, exporting to PDF, or asking Quick Help to bootstrap a query. It complements Mobile Architecture (which covers the wiring of SseParser and AuthInterceptor) and User Authentication (which covers the Bearer-token plumbing on every chat call).

Prerequisites

RequirementWhy
Authenticated sessionEvery chat call goes through AuthInterceptor (Bearer token)
Backend reachable at ${backendUrl}The POST /api/queries/stream endpoint
Observability stack (optional)Trace the chat pipeline via VictoriaTraces — see Observability — Tracing

1. Endpoints

The chat uses two endpoints on the backend. Both are mounted in components/gov-chat-backend/routes/query-routes.js:

EndpointMethodPurpose
/api/queries/streamPOSTSSE streaming RAG response
/api/queries/POSTNon-streaming fallback (single JSON response)

The mobile app always tries the streaming endpoint first. It falls back to /api/queries/ only when AuthInterceptor is not wired (a defensive code path used in tests; production builds always use streaming).

For full request/response shapes and error codes, see API Contracts (backend).

2. SSE event taxonomy

SseParser (lib/services/sse_parser.dart) chunks the text/event-stream from /api/queries/stream into five typed events. Each is a Dart class with the type field verified verbatim:

Event classtype JSON fieldPayloadWhen the backend emits it
SseChunkEventchunkcontent: stringIncremental LLM token — drives the typewriter animation
SseMetadataEventmetadatasourceDocuments: [...], confidenceScore: number, isGrounded: bool, responseTime?: numberAfter retrieval completes — drives the Related Documents sidebar
SseTranslationEventtranslationcontent: stringStreaming translation replaces the partial stream mid-flow (issue #829)
SseDoneEventdonequeryId: stringStream complete — finalises the message bubble and the related-documents sidebar
SseErrorEventerrormessage: stringBackend stream-level error — surfaces a retry banner

How the events drive the UI

EventUI side-effect
SseChunkEventAppend content to the in-progress assistant message bubble; auto-scroll to bottom
SseMetadataEventPush source documents into RightSidebarComponent as a list of cards
SseTranslationEventReplace the in-progress bubble’s content with the translated markdown (preserves formatting via AST-aware translateMarkdown-per-unit replacement)
SseDoneEventFinalise the bubble; flip the loading indicator off; enable the Send button and the Feedback button
SseErrorEventSurface an error banner with Retry; preserve the user’s input

Reconnection

The current build does not attempt automatic SSE reconnection on a dropped stream. If the network blip causes the stream to terminate, SseParser flushes its buffer and the UI surfaces a “Connection lost — retry?” banner. The retry sends the same query and starts a fresh stream. (Long-term reconnect/backoff is on the roadmap.)

3. Markdown rendering and PDF export

In-app markdown rendering uses flutter_markdown ^0.7.7 to render the assistant’s response inline.

For PDF export, the app builds a pw.Document from the same markdown content via the pdf + printing packages:

ComponentPurpose
pdf (^3.11.1)Builds the PDF document
printing (^5.13.1)Native print/share dialog (iOS share sheet, Android print framework) — Printing.sharePdf(bytes: await pdf.save(), filename: ...) (chatbot_component.dart:1171)

The two rendering paths share a markdown AST; the PDF path walks the AST and emits pw.Widget nodes so formatting (lists, headings, code blocks, tables) survives the export. The default filename is genie_chat_YYYY-MM-DD.pdf (e.g. genie_chat_2026-09-18.pdf).

4. Per-message feedback

ChatResponseFeedbackDialog (lib/components/chat/chat_response_feedback_dialog.dart) opens when the user taps the thumbs-up or thumbs-down button on any assistant message. The dialog collects five fields:

FieldTypeUIDefault
rating1-5 (int)Star row below the thumbsAuto-set to 4 if thumbFeedback = up, 2 if thumbFeedback = down
thumbFeedbackup / downTwo large buttons at the topUser-tapped
skinTonehex string (ARGB32 → hex)5-swatch colour picker (bias / fairness signal)#FFCBA4 (neutral mid-tone)
textstringFree-text boxempty
messageIdstring(internal)Derived from the message’s id / timestamp, stripped of any messages/ prefix

The dialog body emits { rating, thumbFeedback, skinTone, text, messageId } via widget.onSubmit({...}). Of those, only rating and comment (mapped from the local text field by chatbot_component.dart) are POSTed to /api/queries/{id}/feedback; thumbFeedback, skinTone, and messageId are collected client-side and discarded before the request. The thumb → rating auto-set is intentional: it gives admins a single 1-5 scale to aggregate, while still letting the user override by tapping a different star.

Why the skin-tone selector? It is a fairness / bias signal for downstream analytics. The user is informed that this field is collected for quality purposes (the UI labels it explicitly). Optional — the user can dismiss the dialog without selecting a tone.

5. Quick Help overlay

The Quick Help overlay loads on first launch and after a fresh conversation. It surfaces contextual buttons sourced from assets/config/quickhelp/ (loaded by GenieAIConfig at startup). The JSON shape per button:

{
  "id": "just-chat",
  "title": { "en": "Just Chat", "es": "Solo Chatear" },
  "icon": { "type": "file", "value": "/config/quickhelp/just-chat.svg" },
  "category": null
}

A button may also carry an action object with visibleText and hiddenPrompt keys (each a locale-keyed map of strings). When action is present, chatbot_component.dart:739-740 (inside _quickHelpPressed, starting at line 737) reads button['action']['visibleText'] to prefill the chat input and button['action']['hiddenPrompt'] to seed the LLM’s hidden prompt — these fields are optional and only present when the button’s prompt needs to differ from its visible label.

User flow:

  1. Overlay appears above the chat input.
  2. User taps a button → the chat is prefilled with action.visibleText (or title when action is absent), and the LLM hidden prompt is set from action.hiddenPrompt when present.
  3. The overlay auto-hides after first interaction (or via the explicit dismiss control).

Authoring Quick Help buttons

FilePurpose
assets/config/quickhelp/<icon>.svgButton icon (SVG)
assets/config/genie-ai-config.jsonList of Quick Help buttons — id, title (locale-keyed), icon (file path), optional category and action
lib/services/genie_ai_config.dartLoads and parses the JSON at startup

6. Conversation management

New chat

Tapping the + button (add_circle_outline) opens ConfirmDialog with three branches:

BranchBehaviour
SaveCalls POST /api/queries/{queryId}/conversation with the current title and message history; opens the new-chat view
DiscardWipes the in-memory state without persisting; opens the new-chat view
CancelDismisses the dialog; the current conversation stays active

This guard runs only when the current conversation has unsaved changes (e.g. the user has typed but not sent a message, or a feedback dialog is still open).

Save conversation

Tapping Save in the conversation sidebar opens a title dialog (default: first user message, truncated to 50 chars) and then POST /api/queries/{queryId}/conversation to persist the title + full message history to the backend. Saved conversations appear in the All tab of the sidebar.

Chat history tabs

The sidebar (components/sidebar/chat_folders_panel.dart) has three tabs:

TabSourceNotes
AllAll saved conversationsNewest first
StarredConversations marked with a star (per-user)Star toggle is local; not synced to the backend
ArchivedConversations the user archivedHidden from All; restorable

Folders

The sidebar supports folders with CRUD operations (create, rename, delete, drag-drop into folder). On first launch, the Inbox folder is auto-created. Star/Archive are folder-scoped where applicable.

7. Service tree

The service tree (components/sidebar/service_tree_panel.dart) renders the institutional service hierarchy from the backend:

Service categories (top-level)
├── Health
│   ├── Maternal health
│   ├── Child immunisation
│   └── ...
├── Education
│   ├── Primary enrolment
│   └── ...
└── ...

Tapping a leaf service injects its categoryId + serviceLabels into the chat context. The right sidebar updates to show related documents for that service. Multi-select is supported — tap multiple leaves to filter the retriever to the union of their labels.

A search box at the top of the panel does a case-insensitive substring match across the visible tree, and shows a “Match found in ” subtitle on each match. Empty search restores the full tree.

8. Share chat (WhatsApp)

The Share action on any assistant message formats the chat transcript (user + assistant turns, with role prefixes) and tries, in order:

  1. Native WhatsApp app — opens via deep link: whatsapp://send?text=<urlencoded transcript>
  2. WhatsApp Web fallback — opens https://wa.me/?text=<transcript> in the system browser if the native app is not installed

The transcript is plain text (Markdown stripped). Each role is prefixed with *Role*: (italic in WhatsApp’s lightweight markup). A header line Conversation with Genie (<title>): is prepended. Implementation lives at chatbot_component.dart:_shareToWhatsApp() (lines 1183-1224).

9. Per-message rate-limit and retry (roadmap — not yet implemented)

The backend may enforce rate limits on /api/queries/stream per Keycloak consumer. [Not yet implemented] in the current mobile client: no Retry-After handling, no automatic retry, and no rate-limit toast exist anywhere in mobile/genie_ai_mobile/lib/. The following steps describe the intended behaviour once the feature lands:

  1. Read the Retry-After header (seconds).
  2. Show a “Rate limited — retrying in Ns” toast.
  3. Schedule a single retry after the delay (one-shot, no backoff loop).

The user can cancel the pending retry by tapping Cancel.

10. FAQ auto-translation

The app ships a static FAQ at assets/FAQ.md. On first launch (and on locale change), right_sidebar_component.dart POSTs the English markdown to the backend’s /api/translate/markdown endpoint with { markdown, sourceLang: 'en', targetLang: <locale> } and renders the response’s translated_markdown field.

[future caching] The current code re-fetches on every locale change and every sidebar open that triggers a refetch — there is no shared_preferences caching in right_sidebar_component.dart. Per-locale caching is planned but not yet implemented.

If translation fails (network error, backend returns 5xx), the FAQ renders in English with a “Translation unavailable” banner.

11. User Profile

UserProfileComponent (components/user/user_profile_component.dart) is a 12-tab government-style identity profile, each tab editing a dedicated section of the /api/me JSON document:

TabFields (per assets/i18n/.../userProfile.fields)
personalfullName, dob, gender, nationality, maritalStatus
civilbirthCert, deathCert, marriageDivorce, adoption, citizenship, immigration
addresscurrentAddress, previousAddresses, homeOrRental, utilityBills, landRecords
identityidCard, passport, driversLicense, voterId, ssn, militaryRecords
healthmedicalHistory, vaccinations, insuranceDetails, disability, bloodType, organDonor, prescriptions, mentalHealth
employmenteHistory, currentEmployer, workPermits, certifications, unemployment, tin, businessAffiliations
educationschools, diplomas, performance, scholarships
financialincomeTax, bankAccounts, propertyTax, businessTax, pensionContrib, loanAid
socialpensionStatus, unemployment, disability, childcare, foodAssistance, housingAssistance
criminalpoliceRecords, courtCases, finesPenalties, paroleProbation, citizenshipRevocation
transportvehicleReg, trafficViolations, licenseHistory, publicTransportCard
civicvoterRegistration, electionHistory, partyMembership, militaryStatus, publicServiceRoles

Each tab also supports profileIcon (image picker → /api/me PUT) — this lives in personalIdentification and is independent of the per-tab field list above. No biometric field is wired in the current build. All tabs read/write through currentUserApiProvider → CurrentUserApi.apiMeGetWithHttpInfo() / apiMePutWithHttpInfo() (see mobile/CLAUDE.md).

Notifications (UI toasts), Theme (light / dark / system), Language, Reset data, and Delete account are not part of UserProfileComponent — they live in components/settings/settings_component.dart. Star/Archive are managed by components/sidebar/chat_folders_panel.dart (also outside UserProfileComponent). See the Settings section of the mobile deployment guide.

12. Failure modes and debug

SymptomLikely causeFirst check
Stream never emits a chunk eventBackend OPEA pipeline stalledTrace via VictoriaTraces — see Observability — Tracing
translation event corrupts markdownOld non-AST translation logicConfirm the backend has the streaming-translation fix (issue #829); check routes/query-routes.js for the STREAMING_TRANSLATION_ENABLED branch
Feedback POST returns 404Backend cleared the in-memory query before the user submittedIncrease backend retention or submit feedback sooner after the message lands
PDF export fails on webweb_file_utils.dart blocked by browser download permissionsUse a non-incognito tab; allow downloads for the app origin
Quick Help buttons missingassets/config/genie-ai-config.json failed to loadCheck GenieAIConfig logs for the parse error
Related Documents sidebar is empty after metadataBackend retrieval returned no sources for the queryVerify the user is filtering by a category with knowledge-base entries
Save conversation returns 500Backend conversation store errorcurl -i the same PUT to see the error body; report to backend maintainers