AI assists — the reusable service & front-end kit
The product-wide AI "assists" are small, user-initiated helpers. They all go through one backend service and one front-end kit — reuse them; do not hand-roll a one-off AI button, drawer, or route.
Backend — AiAssistService
api/src/domains/ai-assist.ts → AiAssistService(db, ai), exposed by api/src/routes/ai.ts under
/v1/ai/*. Each method:
- pulls the data it needs through the existing domain services (
SurveyService.get,ResponseService.detail,VerbatimService.list,LeadService.detail,AlertService.list,ReportingService.agentScorecard) so survey/own-scope guards are inherited; - makes one
ai.completeJson<T>({ messages, schema, responseSchema })call (constrained JSON); - fails loudly — a missing entity throws
NotFoundError(404), an AI failure throwsAppError(..., 'ai_unavailable', 502), and empty data short-circuits without a model call.
The ten assists: improve-question, translate-survey, summarize-verbatims, suggest-topics,
draft-reply, triage-alert, qualify-lead, coaching-plan, write-invite, report-narrative. Each
route is gated on a sensible existing permission and the ai feature flag (system admins bypass).
Contrast with the Executive view, which is deterministic-first and degrades gracefully, and with domain AI (capture-time sentiment/coding, survey generation, feedback structuring, theme generation) which lives in its own domain service. All of it uses the one Gemini
AiProviderport — no provider SDK appears outside the adapter.
Front end — the AiAssist kit
frontend/src/components/ai/AiAssist.tsx:
AiLauncher— a one-tag Sparkles button that runs aproducer()and renders the result in a side-sheetDrawerviarender(). The common case.AiButton— the bare trigger;useAi<T>()— the loading/error/result hook for bespoke flows (e.g. the translate language picker, the inline "Write with AI" that fills a form).CopyField(labelled read-only block + copy) andAiListfor the result body.
AiButton/AiLauncher auto-hide when the ai feature flag is off for the user (system admins
bypass), so every assist is gated in one place. The frontend api lives in frontend/src/lib/api/ai.ts.
Adding a new assist
- Add a method to
AiAssistService(fetch via a domain service → onecompleteJson). - Add the route to
api/src/routes/ai.ts(permission gate; thefeatureGate('ai')already applies). - Add the request schema to
packages/contracts/src/ai.tsand the DTO tofrontend/src/lib/api/types.ts+ a method infrontend/src/lib/api/ai.ts. - Surface it with
<AiLauncher … />at the call site.