Skip to main content

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:

  • splitMessages separates system messages into Gemini's systemInstruction and maps the rest to contents (assistantmodel).
  • completeJson sets responseMimeType: 'application/json' in the request config, then JSON.parses the response text and runs schema.parse(...) if a schema was supplied.
  • stream is 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/analyze and .../autocode let analysts score/code historical responses — see Sentiment & coding.