The Ai port & Gemini adapter
All model work — verbatim sentiment, topic/theme coding, and speech-to-text — runs server-side only, behind a clean TypeScript port. No provider SDK type appears outside the adapter; domain and route code depend only on the port. This is the ports/adapters discipline that lets a later Azure AI Foundry adapter be a config change rather than a rewrite (see Contributing).
The port
AiProvider (packages/ports/src/ai.ts) is the model-agnostic gateway:
export interface AiProvider {
complete(req: ChatRequest): Promise<ChatResult>;
completeJson<T>(req: JsonRequest<T>): Promise<T>;
stream(req: ChatRequest): AsyncIterable<ChatChunk>;
embed(req: EmbedRequest): Promise<number[][]>;
}
completeJson<T> is the workhorse for structured extraction: the caller passes a Zod schema
(JsonRequest.schema, typed unknown at the port so zod doesn't leak), and the adapter validates the
model's JSON output against it before returning T. Both verbatim sentiment and topic coding use this
path.
Speech-to-text sits behind a separate TranscriptionProvider port so swapping to Whisper / Azure
Speech later is a composition-root edit.
The Gemini adapter
makeGeminiAi (packages/adapters/src/gemini.ts) implements AiProvider over the official Google
Gen AI SDK (@google/genai, Workers-native via fetch), server-side only. The initial model is
Gemini 3.1 Flash Lite (GEMINI_MODEL in api/wrangler.toml [vars], default
gemini-3.1-flash-lite).
Key behaviours:
splitMessagesseparatessystemmessages into Gemini'ssystemInstructionand maps the rest tocontents(assistant→model).completeJsonsetsresponseMimeType: 'application/json'in the requestconfig, thenJSON.parses the response text and runsschema.parse(...)if a schema was supplied.streamis single-shot for now (it yields one chunk) — token-by-token SSE is a later refinement; async-iterable callers still work unchanged.- The SDK surfaces a failed call as a thrown error; the route error handler maps unhandled errors to a 500 envelope.
makeGeminiTranscription implements TranscriptionProvider: the worker resolves the R2 audio object
once and passes the bytes (the audioRef-fetch path is the fallback for fetchable URLs), inlines them
as base64, and asks a multimodal Gemini model to transcribe verbatim. Gemini returns no confidence
score, so that field is omitted.
Wiring
The adapter is constructed in the composition root and exposed as ports.ai. The
VerbatimService (api/src/domains/verbatim.ts) takes it by constructor injection:
const verbatim = new VerbatimService(ports.db, ports.ai);
This is the only seam the domain knows about. In tests, ports.ai is a fake that returns canned JSON,
so service logic is exercised without a network call (see Testing).
The GEMINI_API_KEY is a Worker secret, never in source or the browser (see
Configuration). Calls only ever originate in the Worker.
Where it's used
- On capture (async): the ingestion queue consumer enriches each new response — see Queues.
- On demand (backfill):
POST /v1/surveys/:id/verbatim/analyzeand.../autocodelet analysts score/code historical responses — see Sentiment & coding.