9.8 KiB
9.8 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-gemini-integration-asset-pipeline | 01 | tdd | 1 |
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.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; } ```export function getSettings(db: IDBDatabase): Promise<Settings | null>;
export function saveSettings(db: IDBDatabase, settings: Settings): Promise<void>;
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 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<GeminiConfig | null> {
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<string | null>`
- 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<Blob | null>`
- 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<boolean>
export async function incrementApiCall(db: IDBDatabase, type: 'text' | 'image'): Promise<void>
```
- 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.
<success_criteria>
- 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/</success_criteria>