233 lines
9.8 KiB
Markdown
233 lines
9.8 KiB
Markdown
---
|
|||
|
|
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"
|
||
|
|
---
|
||
|
|
|
||
|
|
<objective>
|
||
|
|
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.
|
||
|
|
</objective>
|
||
|
|
|
||
|
|
<execution_context>
|
||
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||
|
|
</execution_context>
|
||
|
|
|
||
|
|
<context>
|
||
|
|
@.planning/PROJECT.md
|
||
|
|
@.planning/ROADMAP.md
|
||
|
|
@.planning/STATE.md
|
||
|
|
|
||
|
|
@src/types.ts
|
||
|
|
@src/storage/db.ts
|
||
|
|
@public/config.json
|
||
|
|
|
||
|
|
<interfaces>
|
||
|
|
<!-- Existing types from src/types.ts -->
|
||
|
|
```typescript
|
||
|
|
export interface Settings {
|
||
|
|
id: 1;
|
||
|
|
audioEnabled: boolean;
|
||
|
|
apiKey: string;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
<!-- Existing DB methods from src/storage/db.ts -->
|
||
|
|
```typescript
|
||
|
|
export function getSettings(db: IDBDatabase): Promise<Settings | null>;
|
||
|
|
export function saveSettings(db: IDBDatabase, settings: Settings): Promise<void>;
|
||
|
|
```
|
||
|
|
</interfaces>
|
||
|
|
</context>
|
||
|
|
|
||
|
|
<tasks>
|
||
|
|
|
||
|
|
<task type="auto" tdd="true">
|
||
|
|
<name>Task 1: Config loader and Gemini API client with tests (TDD)</name>
|
||
|
|
<files>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</files>
|
||
|
|
<read_first>
|
||
|
|
- 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)
|
||
|
|
</read_first>
|
||
|
|
<behavior>
|
||
|
|
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
|
||
|
|
</behavior>
|
||
|
|
<action>
|
||
|
|
**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<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.
|
||
|
|
</action>
|
||
|
|
<verify>
|
||
|
|
<automated>cd /home/dev/workspace/zauberwald && npx vitest run src/api/</automated>
|
||
|
|
</verify>
|
||
|
|
<acceptance_criteria>
|
||
|
|
- `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
|
||
|
|
</acceptance_criteria>
|
||
|
|
<done>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.</done>
|
||
|
|
</task>
|
||
|
|
|
||
|
|
</tasks>
|
||
|
|
|
||
|
|
<verification>
|
||
|
|
- `npx vitest run src/api/` — all tests pass
|
||
|
|
- `grep "generateText\|generateImage\|loadConfig\|checkRateLimit" src/api/gemini.ts src/api/config.ts` shows all exports
|
||
|
|
</verification>
|
||
|
|
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<output>
|
||
|
|
After completion, create `.planning/phases/02-gemini-integration-asset-pipeline/02-01-SUMMARY.md`
|
||
|
|
</output>
|