feat(portal-bff): ai-bridge controller — SSE chat + JSON rag/models #196
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff-ai-bridge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Step 3 of the AI-relay chantier (after #194 ADR and #195 client skeleton). Wires the BFF-side live surface that the SPA's future chatbot widget will consume. ADR-0024 is promoted from
proposedtoacceptedin the same change.Three end-user routes under
/api/ai/*, gated by the active portal session (no@RequireAdmin— AI is a regular-user surface):/api/ai/chatPOSTtext/event-streamapf.ai.v1.ChatService.Chat(server-stream)/api/ai/rag/searchGETapplication/jsonapf.ai.v1.RagService.Search(unary)/api/ai/modelsGETapplication/jsonapf.ai.v1.ModelsService.ListModels(unary)CSRF and session validation are delegated to the global middleware mounted in
main.ts(per ADR-0009 and ADR-0021); the controller assertsreq.session.userand emits 401 if absent.What lands
apps/portal-bff/src/grpc/ai-bridge/SSE codec (
sse.writer.ts)Each
ChatEventoneof case becomes one SSE frame with a kebab-caseevent:name and a JSON-encodeddata:payload:A helper
relayErrorFrame(code, message, retriable)synthesises a relay-sideevent: errorframe that matches the AI service's ownErrorEventshape — the SPA's renderer needs no second code path for relay-level failures vs upstream model errors. gRPC status codes map into theurn:apf-ai:*namespace (UNAVAILABLE→urn:apf-ai:unavailable,DEADLINE_EXCEEDED→urn:apf-ai:timeout,PERMISSION_DENIED→urn:apf-ai:permission_denied,RESOURCE_EXHAUSTED→urn:apf-ai:rate_limited,INVALID_ARGUMENT→urn:apf-ai:invalid_argument, anything else →urn:apf-ai:relay_error).The terminal
doneframe closes the stream — no[DONE]sentinel, per ADR-0024.Controller (
ai-bridge.controller.ts)POST /api/ai/chat— builds anapf.ai.v1.ChatRequestfrom the validated DTO + session-derived Principal, callsChatClient.chat(), drains theClientReadableStream<ChatEvent>into SSE frames written on the raw ExpressResponse.req.on('close', …)propagates browser disconnect through anAbortControllerintocall.cancel()so the upstream LLM stops (perapf-ai-service/docs/streaming.md).GET /api/ai/rag/search— unary RAG call.topKdefaults to 0 (server picks the default).sourceanddocumentIdquery params surface the same filter fields the upstream RPC accepts.GET /api/ai/models— unary lookup of the provider catalogue.The SSE writes happen on the raw Express response (manual
setHeader+flushHeaders+write+end) rather than through NestJS's@Sse()decorator, because@Sse()is GET-only and the chat endpoint is POST (the SPA carries the conversation history in the body).Lifecycle hooks
AiClientModulenow implementsOnApplicationShutdownand closes the four gRPC stubs (Chat / Rag / Ingestion / Models). The four stubs share the same HTTP/2 channel (gRPC-js dedups onendpoint + credentials), so theclose()calls are cheap, but kept explicit so adding a fifth stub later is an obvious one-line addition.main.tsnow callsapp.enableShutdownHooks()soSIGTERM/SIGINT/SIGHUPactually route through the lifecycle interface.DTOs
ChatRequestDtoconstrains:messages— 1 to 64 entries; each hasrole ∈ {user, assistant, system}(notool— tool messages are constructed BFF-side per ADR-0024 §"Tool-dispatch contract") andcontent≤ 16 KB.conversationId,model,provider— optional, ≤ 64 / 128 chars.RagSearchQueryDto:query— required, non-empty.topK— optional, integer in[1, 50](the AI service has its own cap; the BFF rejects out-of-range values early).source/documentId— optional pass-through filters.Documentation
status: proposed→accepted.docs/decisions/README.mdindex reflects the new status.CLAUDE.mdArchitecture section grows an "AI service relay" bullet; the roll-up line moves from "ADRs 0001 → 0023" to "0001 → 0024"; the shipped-on-main list grows an "AI relay surface" entry.apps/portal-bff/.env.exampledocumentsAI_SERVICE_GRPC_ENDPOINT/AI_SERVICE_CLIENT_ID/AI_SERVICE_GRPC_TLSand points operators atapf-ai-service's own docker-compose for the runtime dependency.Notes for the reviewer
apf-ai-serviceruns from its own repo (/home/jgautier/Works/apf-ai-service) with its owninfra/docker-compose.yml. The BFF dialslocalhost:8080by default — the host-published port of the AI service's container. This is option (a) from ADR-0024 §"Open question — Compose orchestration": two independent stacks, dial across via host networking. Merging the compose files into one would couple two release cadences without operational payoff.grpc.Server. All five spec cases on the controller wire it up against a fakeChatService+RagService+ModelsServiceserver bound to127.0.0.1:0(random port). No mocks — the controller's gRPC client makes a real connection, real serialisation, real cancellation propagation. Cost: ~0.5 s overhead from the gRPC server setup.main.ts(per ADR-0021). The SPA's fetch call needs to send theX-CSRF-Tokenheader matching the__Host-portal_csrfcookie — same protocol as every other POST in the BFF. No per-controller wiring required.me.controller.ts; (2) the session check is the only authorization gate (no roles to evaluate) — a guard would add ceremony without payoff; (3) the SSE controller already takes control of the response object (@Res()), whichUseGuardsinteracts with awkwardly. ThrowingUnauthorizedExceptionletsStructuredErrorFilterproduce the 401 envelope before any header is flushed.@Sse(). NestJS's@Sse()decorator is GET-only and emits frames fromObservable<MessageEvent>. The chat endpoint is POST (the SPA sends conversation history in the body) and the source is a NodeReadablestream from@grpc/grpc-js. Manual response handling is simpler than adapting to / fromObservablefor a single consumer.'close'on the request, the controller'sAbortController.abort()triggers,ChatClientcalls.cancel()on the gRPC stream, the AI service'sServerCallContext.CancellationTokencancels the upstream LLM. The spec covers the'close'→ server-sidecancelledevent end-to-end.apf-ai-service/tools/Apf.Ai.Ingest/CLI. A future PR adds the BFF endpoint when the admin "manage AI corpus" surface ships.IngestionClientremains inAiClientModuleso that future PR is one new file, not a new module plus a new client.Test plan
pnpm nx test portal-bff— 461 specs pass (was 443; +13 new: 8 SSE writer cases + 5 controller end-to-end cases against the in-process fake server). Worker-exit-leak warning persists from the gRPC server's slow shutdown — pre-existing pattern from PR #195; harmless.pnpm nx lint portal-bff— 6 pre-existing warnings, no new ones from the diff.pnpm nx build portal-bff— clean webpack compile.AppModuleimportsAiBridgeModule, which importsAiClientModule. Resolves cleanly through DI; the audit-sideHashUserIdServiceis satisfied byAiClientModule's local provider (per the rationale recorded in PR #195'sAiClientModuledocstring).apf-ai-servicefrom its own repo (cd ../apf-ai-service && docker compose -f infra/docker-compose.yml up), setAI_SERVICE_GRPC_ENDPOINT=localhost:8080inapps/portal-bff/.env, runpnpm nx serve portal-bff. Sign in toportal-shell, then in a terminal:event: doneframe. VerifyGET /api/ai/rag/search?query=testreturns a JSON response. VerifyGET /api/ai/modelslists the configured providers.What's next
portal-shellconsuming the SSE endpoint. Will usefetch+ReadableStreamparsing (not nativeEventSource, since POST is needed). Drag / fullscreen / suggestion UX carries forward from the stargate POC'sChatbotWidget.tsx.proto/apf-ai/against an upstream tag ofapf-ai-service.Principalenvelope vs mTLS) on the same date.