UNPKG

askexperts

Version:

AskExperts SDK: build and use AI experts - ask them questions and pay with bitcoin on an open protocol

332 lines (331 loc) 14.7 kB
import { nip19, validateEvent } from "nostr-tools"; import { debugExpert, debugError } from "../common/debug.js"; import { FORMAT_OPENAI, FORMAT_TEXT } from "../common/constants.js"; import { DocstoreToRag, createRagEmbeddings } from "../rag/index.js"; /** * NostrExpert implementation for NIP-174 * Provides an expert that imitates a Nostr user based on their profile and posts */ export class NostrExpert { /** * Creates a new NostrExpert instance * * @param options - Configuration options */ constructor(options) { this.posts = []; this.pubkey = options.pubkey; this.openaiExpert = options.openaiExpert; // Store RAG database this.ragDB = options.ragDB; // Store DocStore components this.docStoreClient = options.docStoreClient; this.docstoreId = options.docstoreId; // Sync from docstore to rag this.docstoreToRag = new DocstoreToRag(this.ragDB, this.docStoreClient); // Set our onAsk to the openaiExpert.server this.openaiExpert.server.onAsk = this.onAsk.bind(this); // Set onGetContext (renamed from onPromptContext) this.openaiExpert.onGetContext = this.onGetContext.bind(this); } pubkeyNickname() { return this.profile?.name || this.pubkey.substring(0, 6); } /** * Starts the expert and crawls the Nostr profile */ async start() { try { debugExpert(`Starting NostrExpert for pubkey: ${this.pubkey}`); // Get docstore to determine model const docstore = await this.docStoreClient.getDocstore(this.docstoreId); if (!docstore) { throw new Error(`Docstore with ID ${this.docstoreId} not found`); } // Use docstore model for embeddings debugExpert(`Using docstore model: ${docstore.model}`); // Create and initialize embeddings with the docstore model this.ragEmbeddings = createRagEmbeddings(docstore.model); await this.ragEmbeddings.start(); debugExpert("RAG embeddings initialized with docstore model"); // Start syncing docs to RAG const collectionName = this.ragCollectionName(); debugExpert(`Starting sync from docstore ${this.docstoreId} to RAG collection ${collectionName}`); // Sync from docstore to RAG await new Promise(async (resolve) => { const syncController = await this.docstoreToRag.sync({ docstore_id: this.docstoreId, collection_name: collectionName, onDoc: async (doc) => { const event = JSON.parse(doc.data); if (!validateEvent(event)) return false; if (event.pubkey !== this.pubkey) return false; switch (event.kind) { case 1: this.posts.push(event); break; case 0: try { this.profile = JSON.parse(event.content); } catch { debugError("Bad profile event content", event.content); } break; default: return false; } return true; }, onEof: () => { debugExpert(`Completed syncing docstore to RAG collection ${collectionName}`); resolve(); }, }); }); // Extract hashtags from profile info const extractedHashtags = await this.extractHashtags(); // this.profileInfo debugExpert(`Extracted hashtags: ${extractedHashtags.sort().join(", ")}`); // Create the system prompt with the profile data const systemPrompt = `You will be given a person's profile in json format below. Also, for every user's message a relevant selection of person's posts will be prepended to user message in this format: ' ### CONTEXT [<person's posts>] ### Message <user message> ' Act like you are that person - when users talk to you, look through the person's profile and posts and reply as if you were that person, preserve their unique style, their opinions and their preferences. ${JSON.stringify(this.profile)}`; // Set hashtags to openaiExpert.server.hashtags this.openaiExpert.server.hashtags = [ this.pubkey, nip19.npubEncode(this.pubkey), ...extractedHashtags, ]; // Set onGetSystemPrompt to return the static systemPrompt this.openaiExpert.onGetSystemPrompt = (_) => Promise.resolve(systemPrompt); // Set nickname and description to openaiExpert.server this.openaiExpert.server.nickname = this.pubkeyNickname() + "_clone"; this.openaiExpert.server.description = await this.getDescription(); // Start the OpenAI expert await this.openaiExpert.start(); debugExpert(`NostrExpert started successfully for pubkey: ${this.pubkey}`); } catch (error) { debugError("Error starting NostrExpert:", error); throw error; } } /** * Handles ask events * * @param ask - The ask event * @returns Promise resolving to a bid if interested, or undefined to ignore */ async onAsk(ask) { try { const tags = ask.hashtags; console.log("ask", ask); // Check if the ask is relevant to this expert // if (!tags.includes(this.pubkey)) { // return undefined; // } debugExpert(`NostrExpert received ask: ${ask.id}`); // Return a bid with our offer return { offer: await this.getDescription(), }; } catch (error) { debugError("Error handling ask in NostrExpert:", error); return undefined; } } async getDescription() { return `I am imitating ${this.pubkeyNickname()} pubkey ${this.pubkey} (${nip19.npubEncode(this.pubkey)}), ask me questions and I can answer like them. Profile description of ${this.pubkeyNickname()}: ${this.profile?.about || "-"}`; } /** * Disposes of resources when the expert is no longer needed */ async [Symbol.asyncDispose]() { debugExpert("Clearing NostrExpert"); this.docstoreToRag[Symbol.dispose](); } /** * Extracts hashtags from profile information using OpenAI * * @returns Promise resolving to an array of hashtags */ async extractHashtags() { try { // Use the OpenAI instance from the provided OpenaiProxyExpertBase const openai = this.openaiExpert.openai; // Create system prompt for hashtag extraction const systemPrompt = `You are an expert at analyzing user profiles and determining what topics they are knowledgeable about. Analyze the provided profile information and identify at least 10 hashtags that represent topics this person would be considered an expert on. Focus on content of their posts, areas where they demonstrate knowledge. Then for each hashtag, come up with 4 additional variations of it and add variations to the hashtag list too. Return ONLY a JSON array of hashtags - english, lowercase, without # symbol, with - instead of spaces, with no explanation or other text. Example response: ["bitcoin", "programming", "javascript", "webapps", "openprotocols"]`; const input = JSON.stringify({ profile: this.profile, // Use latest 100 posts posts: this.posts .sort((a, b) => a.created_at - b.created_at) .slice(-Math.min(this.posts.length, 500)) .map((p) => ({ content: p.content, })), }, null, 2); debugExpert(`Extract hashtags, input size ${input.length} chars`); // Make completion request const quote = await openai.getQuote(this.openaiExpert.model, { model: this.openaiExpert.model, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: input, }, ], temperature: 0.5, }); debugExpert(`Paying ${quote.amountSats} for extractHashtags`); const completion = (await openai.execute(quote.quoteId)); // Extract and parse the hashtags from the response const content = completion.choices[0]?.message?.content || "[]"; // Try to parse the JSON array try { const hashtags = JSON.parse(content); if (Array.isArray(hashtags)) { return hashtags.filter((tag) => typeof tag === "string"); } } catch (error) { debugError("Error parsing hashtags JSON:", error); // If parsing fails, try to extract hashtags using regex const matches = content.match(/["']([^"']+)["']/g); if (matches) { return matches.map((m) => m.replace(/["']/g, "")); } } return []; } catch (error) { debugError("Error extracting hashtags:", error); throw error; } } ragCollectionName() { return `expert_${this.pubkey}`; } /** * Callback for OpenaiExpert to get context for prompts * * @param prompt - The prompt to get context for * @returns Promise resolving to context string */ async onGetContext(prompt) { try { // We will throw this to signal that the expert doesn't // have any relevant knowledge and quote should include this error const notFound = new Error("Expert has no knowledge on the subject"); if (!this.ragEmbeddings || !this.posts.length) { throw notFound; } // Extract text from prompt based on format let promptText = ""; if (prompt.format === FORMAT_OPENAI) { // For OpenAI format, extract text from up to last 10 messages const messages = prompt.content.messages; if (messages && messages.length > 0) { // Get up to last 10 messages (except for system prompt) const userMessages = messages .filter((msg) => msg.role !== "system") .slice(-10); promptText = userMessages .map((msg) => typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)) .join("\n"); } } else if (prompt.format === FORMAT_TEXT) { // For text format, use content directly promptText = prompt.content; } if (!promptText) { throw notFound; } // Generate embeddings for all prompt texts sequentially const embeddings = []; // debugExpert("promptText", promptText); // Process each text sequentially const chunks = await this.ragEmbeddings.embed(promptText); // Extract embeddings from chunks for (const chunk of chunks) { embeddings.push(chunk.embedding); } if (embeddings.length === 0) { throw notFound; } // Take up to 20 most recent chunks const recentEmbeddings = embeddings.slice(-20); // Search for similar content in the RAG database using batch search const batchResults = await this.ragDB.searchBatch(this.ragCollectionName(), recentEmbeddings, 50 // Limit to 50 results per query ); const results = batchResults .flat() .sort((a, b) => a.distance - b.distance); // console.log( // "results", // JSON.stringify(results.map((r) => ({ // d: r.distance, // post: this.profileInfo?.posts.find((p) => p.id === r.metadata.postId), // }))) // ); if (!results.length) { throw notFound; } debugExpert(`Rag search results ${results.length} docs distance ${results[0].distance}:${results[results.length - 1].distance}`); // Collect post IDs from all results const postIds = new Map(); for (const result of results) { if (result.metadata && result.metadata.id) { const postDistance = Math.min(result.distance, postIds.get(result.metadata.id) || result.distance); postIds.set(result.metadata.id, postDistance); } } // Find matching posts in profileInfo const matchingPosts = this.posts .filter((post) => postIds.has(post.id)) .sort((a, b) => postIds.get(b.id) - postIds.get(a.id)); // Nothing? if (!matchingPosts.length) { throw notFound; } // Remove useless fields, return as string const context = matchingPosts.map((p) => { const post = { content: p.content, created_at: p.created_at }; // if (p.in_reply_to) // post.in_reply_to = { // content: p.in_reply_to.content, // pubkey: p.in_reply_to.pubkey, // }; return post; }); // console.log("context", context); return JSON.stringify(context, null, 2); } catch (error) { debugError("Error generating prompt context:", error); throw error; } } } //# sourceMappingURL=NostrExpert.js.map