import type { SupabaseClient } from "@supabase/supabase-js";
import OpenAI from "openai";
// ============================================================================
// EMBEDDINGS — Semantic vector search via OpenAI + pgvector
// Actions: embed, search, index_products, delete, stats
// ============================================================================
const EMBEDDING_MODEL = "text-embedding-3-small";
const MAX_CHARS = 32_000; // ~8000 tokens
const BATCH_SIZE = 100; // OpenAI max per request
/** Truncate text to stay within token limits */
function truncateText(text: string): string {
return text.length > MAX_CHARS ? text.slice(0, MAX_CHARS) : text;
}
/** Get an OpenAI client by decrypting the stored API key */
async function getOpenAIClient(sb: SupabaseClient, storeId: string): Promise<OpenAI> {
const { data: apiKey, error } = await sb.rpc("decrypt_secret", {
p_name: "OPENAI_API_KEY",
p_store_id: storeId,
});
if (error || !apiKey) {
throw new Error("OpenAI API key not found. Add OPENAI_API_KEY to your tool secrets.");
}
return new OpenAI({ apiKey });
}
/** Generate embeddings for one or more texts (batched) */
async function generateEmbeddings(
openai: OpenAI,
texts: string[]
): Promise<number[][]> {
const results: number[][] = [];
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE).map(truncateText);
const response = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: batch,
});
for (const item of response.data) {
results.push(item.embedding);
}
}
return results;
}
/** Build a text representation of a product for embedding */
function productToText(product: Record<string, unknown>): string {
const parts: string[] = [];
if (product.name) parts.push(String(product.name));
if (product.short_description) parts.push(String(product.short_description));
if (product.description) parts.push(String(product.description));
if (product.field_values && typeof product.field_values === "object") {
const fv = product.field_values as Record<string, unknown>;
for (const [key, val] of Object.entries(fv)) {
if (val !== null && val !== undefined && val !== "") {
parts.push(`${key}: ${String(val)}`);
}
}
}
return parts.join(". ");
}
// ============================================================================
// MAIN HANDLER
// ============================================================================
export async function handleEmbeddings(
sb: SupabaseClient,
args: Record<string, unknown>,
storeId?: string
) {
const sid = storeId as string;
const action = args.action as string;
switch (action) {
// ======================== EMBED ========================
case "embed": {
const content = args.content as string;
if (!content) return { success: false, error: "content is required for embed action" };
const openai = await getOpenAIClient(sb, sid);
const [embedding] = await generateEmbeddings(openai, [content]);
const sourceType = (args.source_type as string) || "text";
const sourceId = args.source_id as string | undefined;
const metadata = (args.metadata as Record<string, unknown>) || {};
// Upsert: delete existing embedding for same source_id if provided
if (sourceId) {
await sb.from("embeddings")
.delete()
.eq("store_id", sid)
.eq("source_type", sourceType)
.eq("source_id", sourceId);
}
const { data, error } = await sb.from("embeddings").insert({
store_id: sid,
content: truncateText(content),
embedding: JSON.stringify(embedding),
metadata,
source_type: sourceType,
source_id: sourceId || null,
}).select("id, source_type, source_id, created_at").single();
if (error) return { success: false, error: error.message };
return { success: true, data };
}
// ======================== SEARCH ========================
case "search": {
const query = args.query as string;
if (!query) return { success: false, error: "query is required for search action" };
const matchCount = (args.match_count as number) || 10;
const sourceType = (args.source_type as string) || null;
const threshold = (args.similarity_threshold as number) || 0.5;
const openai = await getOpenAIClient(sb, sid);
const [queryEmbedding] = await generateEmbeddings(openai, [query]);
const { data, error } = await sb.rpc("search_embeddings", {
p_store_id: sid,
p_query_embedding: JSON.stringify(queryEmbedding),
p_match_count: matchCount,
p_source_type: sourceType,
p_similarity_threshold: threshold,
});
if (error) return { success: false, error: error.message };
return { success: true, count: data?.length ?? 0, data };
}
// ======================== INDEX PRODUCTS ========================
case "index_products": {
const { data: products, error: fetchErr } = await sb
.from("products")
.select("id, name, description, short_description, field_values")
.eq("store_id", sid);
if (fetchErr) return { success: false, error: fetchErr.message };
if (!products || products.length === 0) {
return { success: true, data: { indexed: 0, message: "No products found to index." } };
}
const openai = await getOpenAIClient(sb, sid);
const texts = products.map(productToText);
const embeddings = await generateEmbeddings(openai, texts);
// Delete all existing product embeddings for this store
await sb.from("embeddings")
.delete()
.eq("store_id", sid)
.eq("source_type", "product");
// Insert in batches
let inserted = 0;
const rows = products.map((p, i) => ({
store_id: sid,
content: truncateText(texts[i]),
embedding: JSON.stringify(embeddings[i]),
metadata: { name: p.name },
source_type: "product" as const,
source_id: p.id,
}));
for (let i = 0; i < rows.length; i += 500) {
const batch = rows.slice(i, i + 500);
const { error: insErr } = await sb.from("embeddings").insert(batch);
if (insErr) return { success: false, error: insErr.message, data: { indexed: inserted } };
inserted += batch.length;
}
return {
success: true,
data: { indexed: inserted, total_products: products.length },
};
}
// ======================== DELETE ========================
case "delete": {
const sourceType = args.source_type as string | undefined;
const sourceId = args.source_id as string | undefined;
if (!sourceType && !sourceId) {
return { success: false, error: "Provide source_type and/or source_id to delete embeddings." };
}
let q = sb.from("embeddings").delete().eq("store_id", sid);
if (sourceType) q = q.eq("source_type", sourceType);
if (sourceId) q = q.eq("source_id", sourceId);
const { error } = await q;
if (error) return { success: false, error: error.message };
return { success: true, data: { deleted: true, source_type: sourceType, source_id: sourceId } };
}
// ======================== STATS ========================
case "stats": {
const { data, error } = await sb
.from("embeddings")
.select("source_type")
.eq("store_id", sid);
if (error) return { success: false, error: error.message };
const counts: Record<string, number> = {};
let total = 0;
for (const row of data || []) {
const st = (row.source_type as string) || "unknown";
counts[st] = (counts[st] || 0) + 1;
total++;
}
return { success: true, data: { total, by_source_type: counts } };
}
default:
return {
success: false,
error: `Unknown embeddings action: ${action}. Valid actions: embed, search, index_products, delete, stats`,
};
}
}