Files
apf_portal/apps/portal-bff/src/grpc/ai-bridge/ai-bridge.controller.ts
T
julien 883c5151de
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m20s
CI / check (push) Successful in 4m2s
CI / a11y (push) Successful in 1m23s
CI / perf (push) Successful in 6m0s
Docs site / build (push) Successful in 2m56s
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
2026-05-19 22:39:35 +02:00

233 lines
7.5 KiB
TypeScript

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;
}