feat(portal-bff): ai-bridge controller — SSE chat + JSON rag/models (#196)
## 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](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md) is promoted from `proposed` to `accepted` in the same change. Three end-user routes under `/api/ai/*`, gated by the active portal session (no `@RequireAdmin` — AI is a regular-user surface): | Route | Verb | Wire | Maps to | |---|---|---|---| | `/api/ai/chat` | `POST` | `text/event-stream` | `apf.ai.v1.ChatService.Chat` (server-stream) | | `/api/ai/rag/search` | `GET` | `application/json` | `apf.ai.v1.RagService.Search` (unary) | | `/api/ai/models` | `GET` | `application/json` | `apf.ai.v1.ModelsService.ListModels` (unary) | CSRF and session validation are delegated to the global middleware mounted in `main.ts` (per [ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) and [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)); the controller asserts `req.session.user` and emits 401 if absent. ## What lands ### `apps/portal-bff/src/grpc/ai-bridge/` ``` ai-bridge/ ├── ai-bridge.module.ts imports AiClientModule, exports the controller ├── ai-bridge.controller.ts 3 routes — POST chat (SSE), GET rag/search, GET models ├── sse.writer.ts ChatEvent oneof → SSE frame translator ├── sse.writer.spec.ts unit tests for the codec ├── ai-bridge.controller.spec.ts end-to-end against an in-process fake gRPC server └── dto/ ├── chat-request.dto.ts class-validator body shape (POST /chat) └── rag-search-query.dto.ts class-validator query shape (GET /rag/search) ``` ### SSE codec (`sse.writer.ts`) Each `ChatEvent` oneof case becomes one SSE frame with a kebab-case `event:` name and a JSON-encoded `data:` payload: ``` event: token data: {"token":"…","value":"…"} event: agent-step data: {"agent":"…","step":"…","stepId":"…"} event: tool-call data: {"callId":"…","name":"…","args":{…}} event: done data: {"stats":{"tokensIn":…,"tokensOut":…,"chunksRetrieved":…}} ``` A helper `relayErrorFrame(code, message, retriable)` synthesises a relay-side `event: error` frame that matches the AI service's own `ErrorEvent` shape — the SPA's renderer needs no second code path for relay-level failures vs upstream model errors. gRPC status codes map into the `urn: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 `done` frame closes the stream — no `[DONE]` sentinel, per ADR-0024. ### Controller (`ai-bridge.controller.ts`) - `POST /api/ai/chat` — builds an `apf.ai.v1.ChatRequest` from the validated DTO + session-derived Principal, calls `ChatClient.chat()`, drains the `ClientReadableStream<ChatEvent>` into SSE frames written on the raw Express `Response`. `req.on('close', …)` propagates browser disconnect through an `AbortController` into `call.cancel()` so the upstream LLM stops (per `apf-ai-service/docs/streaming.md`). - `GET /api/ai/rag/search` — unary RAG call. `topK` defaults to 0 (server picks the default). `source` and `documentId` query 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 `AiClientModule` now implements `OnApplicationShutdown` and closes the four gRPC stubs (Chat / Rag / Ingestion / Models). The four stubs share the same HTTP/2 channel (gRPC-js dedups on `endpoint + credentials`), so the `close()` calls are cheap, but kept explicit so adding a fifth stub later is an obvious one-line addition. `main.ts` now calls `app.enableShutdownHooks()` so `SIGTERM` / `SIGINT` / `SIGHUP` actually route through the lifecycle interface. ### DTOs `ChatRequestDto` constrains: - `messages` — 1 to 64 entries; each has `role ∈ {user, assistant, system}` (no `tool` — tool messages are constructed BFF-side per ADR-0024 §"Tool-dispatch contract") and `content` ≤ 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 - ADR-0024 frontmatter: `status: proposed` → `accepted`. - `docs/decisions/README.md` index reflects the new status. - `CLAUDE.md` Architecture 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.example` documents `AI_SERVICE_GRPC_ENDPOINT` / `AI_SERVICE_CLIENT_ID` / `AI_SERVICE_GRPC_TLS` and points operators at `apf-ai-service`'s own docker-compose for the runtime dependency. ## Notes for the reviewer - **No live AI service in this PR's local-dev stack.** `apf-ai-service` runs from its own repo (`/home/jgautier/Works/apf-ai-service`) with its own `infra/docker-compose.yml`. The BFF dials `localhost:8080` by 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. - **Tests run against an in-process fake `grpc.Server`.** All five spec cases on the controller wire it up against a fake `ChatService` + `RagService` + `ModelsService` server bound to `127.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. - **CSRF + session middleware are unchanged.** The new POST endpoint is protected by the existing double-submit CSRF middleware mounted in `main.ts` (per [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)). The SPA's fetch call needs to send the `X-CSRF-Token` header matching the `__Host-portal_csrf` cookie — same protocol as every other POST in the BFF. No per-controller wiring required. - **Manual session check rather than a guard.** Three reasons: (1) matches the existing pattern in `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()`), which `UseGuards` interacts with awkwardly. Throwing `UnauthorizedException` lets `StructuredErrorFilter` produce the 401 envelope before any header is flushed. - **Why the controller does NOT use `@Sse()`.** NestJS's `@Sse()` decorator is GET-only and emits frames from `Observable<MessageEvent>`. The chat endpoint is POST (the SPA sends conversation history in the body) and the source is a Node `Readable` stream from `@grpc/grpc-js`. Manual response handling is simpler than adapting to / from `Observable` for a single consumer. - **Cancellation contract.** When the SPA aborts the fetch, the browser closes the TCP connection, Express emits `'close'` on the request, the controller's `AbortController.abort()` triggers, `ChatClient` calls `.cancel()` on the gRPC stream, the AI service's `ServerCallContext.CancellationToken` cancels the upstream LLM. The spec covers the `'close'` → server-side `cancelled` event end-to-end. - **No ingestion route in the BFF.** Per ADR-0024 §"Out of scope", v1 admin ingestion uses the `apf-ai-service/tools/Apf.Ai.Ingest/` CLI. A future PR adds the BFF endpoint when the admin "manage AI corpus" surface ships. `IngestionClient` remains in `AiClientModule` so that future PR is one new file, not a new module plus a new client. - **No bundle-size or perf surprise.** The BFF is a Node process, not a SPA chunk — bundle budgets don't apply. The gRPC channel is opened lazily on first call; idle BFFs incur no upstream TCP cost. ## Test plan - [x] `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. - [x] `pnpm nx lint portal-bff` — 6 pre-existing warnings, no new ones from the diff. - [x] `pnpm nx build portal-bff` — clean webpack compile. - [x] Module wiring: `AppModule` imports `AiBridgeModule`, which imports `AiClientModule`. Resolves cleanly through DI; the audit-side `HashUserIdService` is satisfied by `AiClientModule`'s local provider (per the rationale recorded in PR #195's `AiClientModule` docstring). - [ ] **Manual smoke** — bring up `apf-ai-service` from its own repo (`cd ../apf-ai-service && docker compose -f infra/docker-compose.yml up`), set `AI_SERVICE_GRPC_ENDPOINT=localhost:8080` in `apps/portal-bff/.env`, run `pnpm nx serve portal-bff`. Sign in to `portal-shell`, then in a terminal: ```bash curl --cookie-jar /tmp/portal-session http://localhost:3000/api/auth/login # follow Entra… curl -N \ -H 'Content-Type: application/json' \ -H 'X-CSRF-Token: <copied from cookie>' \ --cookie /tmp/portal-session \ -d '{"messages":[{"role":"user","content":"hello"}]}' \ http://localhost:3000/api/ai/chat ``` Expect a streamed SSE response terminated by an `event: done` frame. Verify `GET /api/ai/rag/search?query=test` returns a JSON response. Verify `GET /api/ai/models` lists the configured providers. ## What's next 1. **PR (frontend chantier)** — chatbot widget on `portal-shell` consuming the SSE endpoint. Will use `fetch` + `ReadableStream` parsing (not native `EventSource`, since POST is needed). Drag / fullscreen / suggestion UX carries forward from the stargate POC's `ChatbotWidget.tsx`. 2. **PR (post-v1)** — proto-drift CI gate that diffs `proto/apf-ai/` against an upstream tag of `apf-ai-service`. 3. **Coordinated amendment** — when the first production deployment is in scope, both repos record the same prod-hardening choice (signed `Principal` envelope vs mTLS) on the same date. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #196
This commit was merged in pull request #196.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { status as GrpcStatus, type ServiceError } from '@grpc/grpc-js';
|
||||
import type { Request, Response } from 'express';
|
||||
import { ChatClient } from '../ai-client/chat.client';
|
||||
import { ModelsClient } from '../ai-client/models.client';
|
||||
import { PrincipalMapper } from '../ai-client/principal.mapper';
|
||||
import { RagClient } from '../ai-client/rag.client';
|
||||
import type { ChatEvent, ChatRequest } from '../gen/apf-ai/chat';
|
||||
import { ChatRole } from '../gen/apf-ai/common';
|
||||
import type { ListModelsResponse } from '../gen/apf-ai/models';
|
||||
import type { RagSearchResponse } from '../gen/apf-ai/rag';
|
||||
import { ChatRequestDto, type ChatMessageDto } from './dto/chat-request.dto';
|
||||
import { RagSearchQueryDto } from './dto/rag-search-query.dto';
|
||||
import { chatEventToSseFrame, relayErrorFrame } from './sse.writer';
|
||||
import type { AuthenticatedUser } from '../../auth/auth.service';
|
||||
|
||||
/**
|
||||
* BFF-facing surface of the AI relay, per
|
||||
* [ADR-0024](../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md):
|
||||
*
|
||||
* - `POST /api/ai/chat` — streaming chat, bridged from
|
||||
* `ChatService.Chat` (gRPC server-
|
||||
* stream) to `text/event-stream`.
|
||||
* - `GET /api/ai/rag/search` — unary RAG retrieval.
|
||||
* - `GET /api/ai/models` — list configured providers.
|
||||
*
|
||||
* No `@RequireAdmin()` — AI features are end-user surfaces, gated
|
||||
* only by the active portal session (`req.session.user`). The
|
||||
* session + CSRF middleware mounted in `main.ts` covers the
|
||||
* authentication + double-submit token before the controller
|
||||
* runs; this class asserts presence of `session.user` for the
|
||||
* routes that need it and lets the global middleware handle the
|
||||
* rest.
|
||||
*/
|
||||
@ApiTags('ai')
|
||||
@ApiCookieAuth('portal_session')
|
||||
@Controller('ai')
|
||||
export class AiBridgeController {
|
||||
constructor(
|
||||
private readonly chatClient: ChatClient,
|
||||
private readonly ragClient: RagClient,
|
||||
private readonly modelsClient: ModelsClient,
|
||||
private readonly principalMapper: PrincipalMapper,
|
||||
) {}
|
||||
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Streaming chat. Returns text/event-stream with one frame per ChatEvent (token / citation / agent-step / tool-call / error / done).',
|
||||
})
|
||||
@Post('chat')
|
||||
async chat(
|
||||
@Body() body: ChatRequestDto,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const user = requireSessionUser(req);
|
||||
|
||||
const principal = this.principalMapper.fromInputs({
|
||||
oid: user.oid,
|
||||
tid: user.tid,
|
||||
roles: user.roles,
|
||||
});
|
||||
const protoRequest: ChatRequest = {
|
||||
messages: body.messages.map(toProtoMessage),
|
||||
conversationId: body.conversationId ?? '',
|
||||
model: body.model ?? '',
|
||||
provider: body.provider ?? '',
|
||||
toolsAvailable: [],
|
||||
// v1: tool registry is empty (per ADR-0024 §"Tool-dispatch
|
||||
// contract"). The AI service will never emit `tool_call` for
|
||||
// requests with `toolsAvailable: []`; the SSE writer still
|
||||
// covers the case in case a future tool lands.
|
||||
rag: { enabled: false, topK: 0 },
|
||||
principal,
|
||||
};
|
||||
|
||||
writeSseHeaders(res);
|
||||
|
||||
// Browser disconnect (tab close, fetch abort, network drop) →
|
||||
// AbortController → gRPC call.cancel() → upstream LLM stop.
|
||||
// Per ADR-0024 §"SSE bridge between BFF and SPA".
|
||||
const abort = new AbortController();
|
||||
req.on('close', () => {
|
||||
if (!res.writableEnded) {
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
|
||||
const stream = this.chatClient.chat(protoRequest, { signal: abort.signal });
|
||||
|
||||
try {
|
||||
for await (const event of stream as AsyncIterable<ChatEvent>) {
|
||||
const frame = chatEventToSseFrame(event);
|
||||
if (frame !== null) {
|
||||
res.write(frame);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// gRPC `CANCELLED` after `abort.abort()` is the expected exit
|
||||
// path on browser disconnect — do not surface an error frame
|
||||
// because the response is already closing. Anything else
|
||||
// becomes a structured error event so the SPA's renderer
|
||||
// sees the failure rather than a torn-down connection.
|
||||
const code = (err as Partial<ServiceError>).code;
|
||||
const cancelled = code === GrpcStatus.CANCELLED;
|
||||
if (!cancelled && !res.writableEnded) {
|
||||
res.write(
|
||||
relayErrorFrame(
|
||||
mapServiceErrorCode(code),
|
||||
(err as Error).message ?? 'AI relay error',
|
||||
isRetriable(code),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Unary RAG retrieval bounded by the caller principal.' })
|
||||
@Get('rag/search')
|
||||
async ragSearch(
|
||||
@Query() query: RagSearchQueryDto,
|
||||
@Req() req: Request,
|
||||
): Promise<RagSearchResponse> {
|
||||
const user = requireSessionUser(req);
|
||||
const principal = this.principalMapper.fromInputs({
|
||||
oid: user.oid,
|
||||
tid: user.tid,
|
||||
roles: user.roles,
|
||||
});
|
||||
|
||||
return this.ragClient.search({
|
||||
query: query.query,
|
||||
topK: query.topK ?? 0,
|
||||
filters: {
|
||||
source: query.source ?? '',
|
||||
documentId: query.documentId ?? '',
|
||||
},
|
||||
principal,
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'List configured AI providers and the active provider.' })
|
||||
@Get('models')
|
||||
async listModels(@Req() req: Request): Promise<ListModelsResponse> {
|
||||
requireSessionUser(req);
|
||||
return this.modelsClient.listModels({});
|
||||
}
|
||||
}
|
||||
|
||||
function requireSessionUser(req: Request): AuthenticatedUser {
|
||||
const user = req.session.user;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: 'unauthenticated',
|
||||
message: 'The AI surface requires an authenticated portal session.',
|
||||
});
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
function writeSseHeaders(res: Response): void {
|
||||
res.statusCode = HttpStatus.OK;
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
// nginx-style buffering hint — keeps reverse-proxies from
|
||||
// accumulating chunks before forwarding, which would defeat the
|
||||
// streaming UX.
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
}
|
||||
|
||||
function toProtoMessage(message: ChatMessageDto): {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
} {
|
||||
return {
|
||||
role: ROLE_PROTO[message.role],
|
||||
content: message.content,
|
||||
toolCallId: '',
|
||||
name: '',
|
||||
};
|
||||
}
|
||||
|
||||
const ROLE_PROTO: Record<ChatMessageDto['role'], ChatRole> = {
|
||||
system: ChatRole.CHAT_ROLE_SYSTEM,
|
||||
user: ChatRole.CHAT_ROLE_USER,
|
||||
assistant: ChatRole.CHAT_ROLE_ASSISTANT,
|
||||
};
|
||||
|
||||
/**
|
||||
* gRPC status → SSE error code. Keeps the urn:apf-ai:* namespace
|
||||
* the AI service itself uses so a relay-side error and an upstream
|
||||
* error look the same to the SPA's renderer.
|
||||
*/
|
||||
function mapServiceErrorCode(code: number | undefined): string {
|
||||
switch (code) {
|
||||
case GrpcStatus.UNAVAILABLE:
|
||||
return 'urn:apf-ai:unavailable';
|
||||
case GrpcStatus.DEADLINE_EXCEEDED:
|
||||
return 'urn:apf-ai:timeout';
|
||||
case GrpcStatus.PERMISSION_DENIED:
|
||||
return 'urn:apf-ai:permission_denied';
|
||||
case GrpcStatus.RESOURCE_EXHAUSTED:
|
||||
return 'urn:apf-ai:rate_limited';
|
||||
case GrpcStatus.INVALID_ARGUMENT:
|
||||
return 'urn:apf-ai:invalid_argument';
|
||||
default:
|
||||
return 'urn:apf-ai:relay_error';
|
||||
}
|
||||
}
|
||||
|
||||
function isRetriable(code: number | undefined): boolean {
|
||||
return code === GrpcStatus.UNAVAILABLE || code === GrpcStatus.DEADLINE_EXCEEDED;
|
||||
}
|
||||
Reference in New Issue
Block a user