diff --git a/app/api/incidents/[id]/similar/route.ts b/app/api/incidents/[id]/similar/route.ts index af34058..8f00ea3 100644 --- a/app/api/incidents/[id]/similar/route.ts +++ b/app/api/incidents/[id]/similar/route.ts @@ -18,7 +18,7 @@ export async function GET( if (!profile || !['hse', 'admin'].includes(profile.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const voyageKey = await getApiKey(supabase, 'VOYAGE_API_KEY') + const googleAiKey = await getApiKey(supabase, 'GOOGLE_AI_API_KEY') const { data: incident } = await supabase .from('incidents') @@ -34,7 +34,7 @@ export async function GET( if (inc.embedding) { embeddingVec = JSON.parse(inc.embedding) as number[] } else { - embeddingVec = await embedText(inc.description, voyageKey) + embeddingVec = await embedText(inc.description, googleAiKey) // Closed incidents are locked at the DB level — the trigger would reject // this backfill. The vector still serves the similarity query below. if (inc.status !== 'closed') { diff --git a/app/api/incidents/route.ts b/app/api/incidents/route.ts index 4eade5c..61dc9a6 100644 --- a/app/api/incidents/route.ts +++ b/app/api/incidents/route.ts @@ -182,9 +182,9 @@ export async function POST(request: Request) { // Embed description asynchronously for future similarity search const supabaseForEmbed = supabase import('@/lib/settings').then(({ getApiKey }) => - getApiKey(supabaseForEmbed, 'VOYAGE_API_KEY').then(voyageKey => + getApiKey(supabaseForEmbed, 'GOOGLE_AI_API_KEY').then(googleAiKey => import('@/lib/claude/embed').then(({ embedText }) => - embedText(input.description.trim(), voyageKey).then(embedding => + embedText(input.description.trim(), googleAiKey).then(embedding => supabase.from('incidents').update({ embedding: `[${embedding.join(',')}]` as unknown as string, }).eq('id', incident.id) diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 11ef811..1d432f9 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -6,7 +6,7 @@ import { createClient } from '@/lib/supabase/server' const ALLOWED_KEYS = [ 'ANTHROPIC_API_KEY', 'DEEPSEEK_API_KEY', - 'VOYAGE_API_KEY', + 'GOOGLE_AI_API_KEY', 'META_WHATSAPP_PHONE_NUMBER_ID', 'META_WHATSAPP_ACCESS_TOKEN', ] as const diff --git a/components/settings/api-key-form.tsx b/components/settings/api-key-form.tsx index 8f1e0d6..7b51684 100644 --- a/components/settings/api-key-form.tsx +++ b/components/settings/api-key-form.tsx @@ -10,13 +10,13 @@ interface Props { const KEY_LABELS: Record = { DEEPSEEK_API_KEY: 'DeepSeek API Key (AI)', - VOYAGE_API_KEY: 'Voyage AI API Key (Embeddings)', + GOOGLE_AI_API_KEY: 'Google AI API Key (Embeddings)', } export function ApiKeyForm({ settings }: Props) { const [values, setValues] = useState>({ DEEPSEEK_API_KEY: '', - VOYAGE_API_KEY: '', + GOOGLE_AI_API_KEY: '', }) const [saving, setSaving] = useState>({}) const [results, setResults] = useState>({}) diff --git a/lib/claude/embed.ts b/lib/claude/embed.ts index 8fa6470..ec830ae 100644 --- a/lib/claude/embed.ts +++ b/lib/claude/embed.ts @@ -1,15 +1,18 @@ export async function embedText(text: string, apiKey?: string): Promise { - const key = apiKey ?? process.env.VOYAGE_API_KEY - if (!key) throw new Error('VOYAGE_API_KEY is not set') - const res = await fetch('https://api.voyageai.com/v1/embeddings', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${key}`, - }, - body: JSON.stringify({ input: [text], model: 'voyage-3-lite' }), - }) - if (!res.ok) throw new Error(`Voyage embed failed: ${res.status}`) - const json = await res.json() as { data: Array<{ embedding: number[] }> } - return json.data[0].embedding + const key = apiKey ?? process.env.GOOGLE_AI_API_KEY + if (!key) throw new Error('GOOGLE_AI_API_KEY is not set') + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=${key}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'models/text-embedding-004', + content: { parts: [{ text }] }, + }), + } + ) + if (!res.ok) throw new Error(`Gemini embed failed: ${res.status}`) + const json = await res.json() as { embedding: { values: number[] } } + return json.embedding.values } diff --git a/supabase/migrations/20260713000001_gemini_embeddings.sql b/supabase/migrations/20260713000001_gemini_embeddings.sql new file mode 100644 index 0000000..c2a0b9f --- /dev/null +++ b/supabase/migrations/20260713000001_gemini_embeddings.sql @@ -0,0 +1,49 @@ +-- Migrate embeddings from Voyage AI (1024-dim) to Gemini text-embedding-004 (768-dim) + +-- Drop dependent objects first +drop index if exists incidents_embedding_idx; +drop function if exists match_incidents; + +-- Replace column (dimension change requires drop+add) +alter table incidents drop column if exists embedding; +alter table incidents add column embedding vector(768); + +-- Recreate index +create index incidents_embedding_idx + on incidents using ivfflat (embedding vector_cosine_ops) + with (lists = 10); + +-- Recreate similarity function at 768-dim +create or replace function match_incidents( + query_embedding vector(768), + exclude_id uuid, + match_count int default 5 +) +returns table ( + id uuid, + reference_no text, + incident_type text, + description text, + severity int, + similarity float +) +language sql +security definer +as $$ + select + i.id, + i.reference_no, + i.incident_type, + i.description, + i.severity, + 1 - (i.embedding <=> query_embedding) as similarity + from incidents i + where i.id != exclude_id + and i.embedding is not null + order by i.embedding <=> query_embedding + limit match_count; +$$; + +-- Register Gemini key slot, remove Voyage slot +insert into app_settings (key, value) values ('GOOGLE_AI_API_KEY', '') on conflict (key) do nothing; +delete from app_settings where key = 'VOYAGE_API_KEY'; diff --git a/tests/lib/claude/embed.test.ts b/tests/lib/claude/embed.test.ts index c4c4b00..7949374 100644 --- a/tests/lib/claude/embed.test.ts +++ b/tests/lib/claude/embed.test.ts @@ -2,36 +2,36 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' describe('embedText', () => { beforeEach(() => { - process.env.VOYAGE_API_KEY = 'test-key' + process.env.GOOGLE_AI_API_KEY = 'test-key' }) afterEach(() => { vi.restoreAllMocks() }) - it('returns 1024-element embedding array', async () => { - const mockEmbedding = Array.from({ length: 1024 }, (_, i) => i / 1024) + it('returns 768-element embedding array', async () => { + const mockEmbedding = Array.from({ length: 768 }, (_, i) => i / 768) vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ data: [{ embedding: mockEmbedding }] }), + json: () => Promise.resolve({ embedding: { values: mockEmbedding } }), })) const { embedText } = await import('@/lib/claude/embed') const result = await embedText('forklift hit racking in zone B') - expect(result).toHaveLength(1024) + expect(result).toHaveLength(768) expect(result[0]).toBeCloseTo(0) - expect(result[1023]).toBeCloseTo(1023 / 1024) + expect(result[767]).toBeCloseTo(767 / 768) }) it('throws on non-ok response', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401 })) const { embedText } = await import('@/lib/claude/embed') - await expect(embedText('test')).rejects.toThrow('Voyage embed failed: 401') + await expect(embedText('test')).rejects.toThrow('Gemini embed failed: 401') }) - it('throws when VOYAGE_API_KEY is missing', async () => { - delete process.env.VOYAGE_API_KEY + it('throws when GOOGLE_AI_API_KEY is missing', async () => { + delete process.env.GOOGLE_AI_API_KEY vi.resetModules() const { embedText } = await import('@/lib/claude/embed') - await expect(embedText('test')).rejects.toThrow('VOYAGE_API_KEY') + await expect(embedText('test')).rejects.toThrow('GOOGLE_AI_API_KEY') }) })