--- phase: 02-gemini-integration-asset-pipeline plan: 01 type: tdd wave: 1 depends_on: [] files_modified: - src/api/config.ts - src/api/gemini.ts - src/api/config.test.ts - src/api/gemini.test.ts - src/types.ts - src/storage/db.ts autonomous: true requirements: [GAPI-01, GAPI-02, GAPI-03, GAPI-06] must_haves: truths: - "Config loader fetches and parses public/config.json with typed result" - "Text generation calls Gemini REST API with retry logic (3 attempts, exponential backoff)" - "Image generation calls Gemini REST API with reference images as base64 inlineData" - "Rate-limiting prevents more than 1 image + 1 text call per exercise unit" - "All API functions return null on failure (caller uses fallback)" artifacts: - path: "src/api/config.ts" provides: "Config loader with GeminiConfig interface" exports: ["loadConfig", "GeminiConfig"] - path: "src/api/gemini.ts" provides: "Isomorphic Gemini API client" exports: ["generateText", "generateImage"] - path: "src/api/config.test.ts" provides: "Config loader tests" - path: "src/api/gemini.test.ts" provides: "Gemini client tests" key_links: - from: "src/api/gemini.ts" to: "src/api/config.ts" via: "loadConfig() provides API key and model names" pattern: "loadConfig" - from: "src/api/gemini.ts" to: "src/storage/db.ts" via: "rate-limit check reads/writes settings store" pattern: "getSettings|saveSettings" --- Create the config loader and isomorphic Gemini API client with text generation, image generation, retry logic, and rate-limiting. Purpose: This is the foundation for all AI-powered features. Everything in Phase 2 depends on a working, tested Gemini API client. Output: `src/api/config.ts`, `src/api/gemini.ts` with full test coverage. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @src/types.ts @src/storage/db.ts @public/config.json ```typescript export interface Settings { id: 1; audioEnabled: boolean; apiKey: string; } ``` ```typescript export function getSettings(db: IDBDatabase): Promise; export function saveSettings(db: IDBDatabase, settings: Settings): Promise; ``` Task 1: Config loader and Gemini API client with tests (TDD) src/api/config.ts, src/api/config.test.ts, src/api/gemini.ts, src/api/gemini.test.ts, src/types.ts, src/storage/db.ts - src/types.ts (existing interfaces — extend Settings with apiCallsToday + lastApiCallDate per D-03) - src/storage/db.ts (existing DB methods — pattern to follow) - public/config.json (actual config structure: geminiApiKey, geminiModel, imageModel) - SPEC.md sections 6.4 and 9.1 (API endpoints and config format) Config loader (src/api/config.ts): - Test: loadConfig() returns GeminiConfig with geminiApiKey, geminiModel, imageModel - Test: loadConfig() returns null when fetch fails (e.g. 404) - Test: loadConfig() returns null when JSON is malformed - Test: loadConfig() caches result after first successful call (does not re-fetch) Gemini text client (src/api/gemini.ts — generateText): - Test: generateText returns extracted text from Gemini response - Test: generateText retries on 500 error up to 3 times with backoff - Test: generateText returns null after 3 failed attempts - Test: generateText sends correct request body (model, contents with systemInstruction) Gemini image client (src/api/gemini.ts — generateImage): - Test: generateImage returns Blob from base64 image in Gemini response - Test: generateImage sends reference images as inlineData parts - Test: generateImage sets response_modalities to ["IMAGE"] in generationConfig - Test: generateImage returns null on failure Rate-limiting: - Test: checkRateLimit returns true when under limit (0 calls today) - Test: checkRateLimit returns false when at limit (1 image + 1 text today) - Test: incrementApiCall increments correct counter - Test: rate limit resets when lastApiCallDate differs from today **Step 1: Extend Settings type (per D-03)** In `src/types.ts`, add to `Settings` interface: ```typescript apiCallsToday: { text: number; image: number }; lastApiCallDate: string; // ISO date "YYYY-MM-DD" ``` **Step 2: Create src/api/config.ts (per D-07, GAPI-01)** ```typescript export interface GeminiConfig { geminiApiKey: string; geminiModel: string; // e.g. "gemini-2.5-flash" imageModel: string; // e.g. "gemini-3.1-flash-preview-image" } let cachedConfig: GeminiConfig | null = null; export async function loadConfig(): Promise { if (cachedConfig) return cachedConfig; try { const res = await fetch('/config.json'); if (!res.ok) return null; const data = await res.json(); if (!data.geminiApiKey || !data.geminiModel || !data.imageModel) return null; cachedConfig = data as GeminiConfig; return cachedConfig; } catch { return null; } } // For Node.js usage in build script (per D-01) export function loadConfigFromObject(obj: GeminiConfig): void { cachedConfig = obj; } export function resetConfigCache(): void { cachedConfig = null; } ``` **Step 3: Create src/api/gemini.ts (per D-07, D-08, D-09, D-10)** - `generateText(prompt: string, systemPrompt: string, config: GeminiConfig): Promise` - POST to `https://generativelanguage.googleapis.com/v1beta/models/${config.geminiModel}:generateContent?key=${config.geminiApiKey}` - Body: `{ contents: [{ role: "user", parts: [{ text: prompt }] }], systemInstruction: { parts: [{ text: systemPrompt }] } }` - Extract `response.candidates[0].content.parts[0].text` - Retry 3 times with delays [1000, 2000, 4000] ms on non-2xx responses (per D-09) - Return null on final failure - `generateImage(prompt: string, referenceImages: Blob[], config: GeminiConfig): Promise` - POST to `https://generativelanguage.googleapis.com/v1beta/models/${config.imageModel}:generateContent?key=${config.geminiApiKey}` - Build parts array: for each referenceImage, convert to base64 and add as `{ inlineData: { mimeType: "image/png", data: base64 } }`, then add text part with prompt (per D-08) - generationConfig: `{ responseModalities: ["IMAGE"] }` - Extract base64 image from `response.candidates[0].content.parts[0].inlineData.data` - Convert base64 to Blob and return - Return null on failure (no retry for image — too expensive) - Rate-limiting helpers (per D-10, GAPI-06): ```typescript export async function checkRateLimit(db: IDBDatabase, type: 'text' | 'image'): Promise export async function incrementApiCall(db: IDBDatabase, type: 'text' | 'image'): Promise ``` - Check `settings.lastApiCallDate` — if different from today, reset counts to 0 - Max 1 image + 1 text per exercise unit (check `apiCallsToday.text < 1` or `apiCallsToday.image < 1`) **Step 4: Write tests** - Use `vi.fn()` and `vi.spyOn(globalThis, 'fetch')` to mock fetch calls - Use `fake-indexeddb` for rate-limit tests (already in devDeps) - Test config loader with mocked fetch responses - Test generateText with mocked Gemini responses - Test generateImage with mocked responses containing base64 image data - Test retry logic by failing first 2 calls then succeeding - Test rate-limit read/write/reset cycle **Follow TDD cycle:** Write all tests first (RED), then implement (GREEN), then refactor. cd /home/dev/workspace/zauberwald && npx vitest run src/api/ - `grep -c "export async function loadConfig" src/api/config.ts` returns 1 - `grep -c "export interface GeminiConfig" src/api/config.ts` returns 1 - `grep -c "export async function generateText" src/api/gemini.ts` returns 1 - `grep -c "export async function generateImage" src/api/gemini.ts` returns 1 - `grep -c "export async function checkRateLimit" src/api/gemini.ts` returns 1 - `grep "apiCallsToday" src/types.ts` matches (Settings extended per D-03) - `grep "lastApiCallDate" src/types.ts` matches (Settings extended per D-03) - `npx vitest run src/api/` passes with 0 failures - At least 10 test cases across config.test.ts and gemini.test.ts Config loader fetches/caches config.json. generateText sends correct Gemini REST request with retry (3x exponential backoff). generateImage sends reference images as base64 inlineData with response_modalities: ["IMAGE"]. Rate-limiting checks and increments counters in Settings store with daily reset. All tests pass. - `npx vitest run src/api/` — all tests pass - `grep "generateText\|generateImage\|loadConfig\|checkRateLimit" src/api/gemini.ts src/api/config.ts` shows all exports - Config loader parses public/config.json and caches result - generateText calls Gemini REST with retry logic, returns string or null - generateImage calls Gemini REST with base64 reference images, returns Blob or null - Rate-limiting tracks text/image calls per day in Settings store - All tests pass via `npx vitest run src/api/` After completion, create `.planning/phases/02-gemini-integration-asset-pipeline/02-01-SUMMARY.md`