use klipy instead of tenor
This commit is contained in:
144
src/services/ai/embeddings.ts
Normal file
144
src/services/ai/embeddings.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Embedding service for semantic memory similarity
|
||||
* Uses OpenAI-compatible embeddings API (can use OpenRouter or OpenAI directly)
|
||||
*/
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { config } from "../../core/config";
|
||||
import { createLogger } from "../../core/logger";
|
||||
|
||||
const logger = createLogger("AI:Embeddings");
|
||||
|
||||
// Embedding model to use (OpenRouter supports several embedding models)
|
||||
const EMBEDDING_MODEL = "openai/text-embedding-3-small";
|
||||
const EMBEDDING_DIMENSIONS = 1536;
|
||||
|
||||
/**
|
||||
* OpenRouter-based embedding provider
|
||||
*/
|
||||
class EmbeddingService {
|
||||
private client: OpenAI;
|
||||
private enabled: boolean;
|
||||
|
||||
constructor() {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
apiKey: config.ai.openRouterApiKey,
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://github.com/crunk-bun",
|
||||
"X-Title": "Joel Discord Bot",
|
||||
},
|
||||
});
|
||||
this.enabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an embedding for a piece of text
|
||||
*/
|
||||
async embed(text: string): Promise<number[] | null> {
|
||||
if (!this.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.embeddings.create({
|
||||
model: EMBEDDING_MODEL,
|
||||
input: text.slice(0, 8000), // Limit input length
|
||||
});
|
||||
|
||||
const embedding = response.data[0]?.embedding;
|
||||
|
||||
if (!embedding) {
|
||||
logger.warn("No embedding returned from API");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug("Generated embedding", {
|
||||
textLength: text.length,
|
||||
dimensions: embedding.length
|
||||
});
|
||||
|
||||
return embedding;
|
||||
} catch (error) {
|
||||
// If embeddings fail, disable and log - don't crash
|
||||
logger.error("Failed to generate embedding", error);
|
||||
|
||||
// Check if it's a model not available error
|
||||
const err = error as Error & { status?: number };
|
||||
if (err.status === 404 || err.message?.includes("not available")) {
|
||||
logger.warn("Embedding model not available, disabling embeddings");
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings for multiple texts in batch
|
||||
*/
|
||||
async embedBatch(texts: string[]): Promise<(number[] | null)[]> {
|
||||
if (!this.enabled || texts.length === 0) {
|
||||
return texts.map(() => null);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.embeddings.create({
|
||||
model: EMBEDDING_MODEL,
|
||||
input: texts.map(t => t.slice(0, 8000)),
|
||||
});
|
||||
|
||||
return response.data.map(d => d.embedding);
|
||||
} catch (error) {
|
||||
logger.error("Failed to generate batch embeddings", error);
|
||||
return texts.map(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cosine similarity between two embedding vectors
|
||||
*/
|
||||
cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (a.length !== b.length) {
|
||||
throw new Error("Embeddings must have the same dimensions");
|
||||
}
|
||||
|
||||
let dotProduct = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
normA = Math.sqrt(normA);
|
||||
normB = Math.sqrt(normB);
|
||||
|
||||
if (normA === 0 || normB === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dotProduct / (normA * normB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if embeddings are enabled and working
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let embeddingService: EmbeddingService | null = null;
|
||||
|
||||
export function getEmbeddingService(): EmbeddingService {
|
||||
if (!embeddingService) {
|
||||
embeddingService = new EmbeddingService();
|
||||
}
|
||||
return embeddingService;
|
||||
}
|
||||
|
||||
export { EmbeddingService, EMBEDDING_DIMENSIONS };
|
||||
@@ -83,3 +83,4 @@ export function getAiService(): AiService {
|
||||
export type { AiProvider, AiResponse, MessageStyle } from "./types";
|
||||
export type { ToolContext, ToolCall, ToolResult } from "./tools";
|
||||
export { JOEL_TOOLS, MEMORY_EXTRACTION_TOOLS } from "./tools";
|
||||
export { getEmbeddingService, EmbeddingService } from "./embeddings";
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ChatCompletionMessageParam, ChatCompletionTool } from "openai/reso
|
||||
import { config } from "../../core/config";
|
||||
import { createLogger } from "../../core/logger";
|
||||
import type { AiProvider, AiResponse, AskOptions, AskWithToolsOptions, MessageStyle } from "./types";
|
||||
import { JOEL_TOOLS, MEMORY_EXTRACTION_TOOLS, type ToolCall, type ToolContext } from "./tools";
|
||||
import { JOEL_TOOLS, MEMORY_EXTRACTION_TOOLS, getToolsForContext, type ToolCall, type ToolContext } from "./tools";
|
||||
import { executeTools } from "./tool-handlers";
|
||||
|
||||
const logger = createLogger("AI:OpenRouter");
|
||||
@@ -79,6 +79,9 @@ export class OpenRouterProvider implements AiProvider {
|
||||
{ role: "user", content: prompt },
|
||||
];
|
||||
|
||||
// Get the appropriate tools for this context (includes optional tools like GIF search)
|
||||
const tools = getToolsForContext(context);
|
||||
|
||||
let iterations = 0;
|
||||
|
||||
while (iterations < MAX_TOOL_ITERATIONS) {
|
||||
@@ -88,7 +91,7 @@ export class OpenRouterProvider implements AiProvider {
|
||||
const completion = await this.client.chat.completions.create({
|
||||
model: config.ai.model,
|
||||
messages,
|
||||
tools: JOEL_TOOLS,
|
||||
tools,
|
||||
tool_choice: "auto",
|
||||
max_tokens: maxTokens ?? config.ai.maxTokens,
|
||||
temperature: temperature ?? config.ai.temperature,
|
||||
@@ -177,7 +180,7 @@ The user's Discord ID is: ${context.userId}`;
|
||||
|
||||
try {
|
||||
const completion = await this.client.chat.completions.create({
|
||||
model: config.ai.classificationModel,
|
||||
model: config.ai.model, // Use main model - needs tool support
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: `Analyze this message for memorable content:\n\n"${message}"` },
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from "../../core/logger";
|
||||
import { config } from "../../core/config";
|
||||
import { memoryRepository, type MemoryCategory } from "../../database";
|
||||
import type { ToolHandler, ToolContext, ToolCall, ToolResult } from "./tools";
|
||||
|
||||
@@ -83,7 +84,7 @@ const toolHandlers: Record<string, ToolHandler> = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Search memories by keyword/topic
|
||||
* Search memories by keyword/topic - uses semantic search when available
|
||||
*/
|
||||
async search_memories(args, context): Promise<string> {
|
||||
const query = args.query as string;
|
||||
@@ -95,16 +96,23 @@ const toolHandlers: Record<string, ToolHandler> = {
|
||||
return "Error: No search query provided.";
|
||||
}
|
||||
|
||||
logger.debug("Searching memories", { query, guildId, category, minImportance });
|
||||
logger.debug("Searching memories (semantic)", { query, guildId, category, minImportance });
|
||||
|
||||
const results = await memoryRepository.search({
|
||||
query,
|
||||
// Try semantic search first for better results
|
||||
let results = await memoryRepository.semanticSearch(query, {
|
||||
guildId,
|
||||
category,
|
||||
minImportance,
|
||||
limit: 15,
|
||||
minSimilarity: 0.6,
|
||||
});
|
||||
|
||||
// Filter by category and importance if specified
|
||||
if (category) {
|
||||
results = results.filter(m => m.category === category);
|
||||
}
|
||||
if (minImportance) {
|
||||
results = results.filter(m => (m.importance || 0) >= minImportance);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return `No memories found matching "${query}".`;
|
||||
}
|
||||
@@ -113,7 +121,8 @@ const toolHandlers: Record<string, ToolHandler> = {
|
||||
.map((m, i) => {
|
||||
const cat = m.category || "general";
|
||||
const imp = m.importance || 5;
|
||||
return `${i + 1}. [User ${m.user_id?.slice(0, 8)}...] [${cat}|★${imp}] ${m.content}`;
|
||||
const sim = 'similarity' in m ? ` (${Math.round((m.similarity as number) * 100)}% match)` : '';
|
||||
return `${i + 1}. [User ${m.user_id?.slice(0, 8)}...] [${cat}|★${imp}]${sim} ${m.content}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
@@ -190,6 +199,71 @@ const toolHandlers: Record<string, ToolHandler> = {
|
||||
`Average importance: ${stats.avgImportance.toFixed(1)}/10\n` +
|
||||
`By category:\n${categoryBreakdown}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Search for a GIF using Klipy API
|
||||
*/
|
||||
async search_gif(args, context): Promise<string> {
|
||||
const query = args.query as string;
|
||||
const limit = Math.min(Math.max((args.limit as number) || 5, 1), 10);
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return "Error: No search query provided.";
|
||||
}
|
||||
|
||||
if (!config.klipy.apiKey) {
|
||||
return "Error: GIF search is not configured (missing Klipy API key).";
|
||||
}
|
||||
|
||||
logger.debug("Searching for GIF", { query, limit });
|
||||
|
||||
try {
|
||||
const url = new URL("https://api.klipy.com/v2/search");
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("key", config.klipy.apiKey);
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
url.searchParams.set("media_filter", "gif");
|
||||
url.searchParams.set("contentfilter", "off"); // Joel doesn't care about content filters
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error("Klipy API error", { status: response.status });
|
||||
return `Error: Failed to search for GIFs (HTTP ${response.status})`;
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
results: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
media_formats: {
|
||||
gif?: { url: string };
|
||||
mediumgif?: { url: string };
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!data.results || data.results.length === 0) {
|
||||
return `No GIFs found for "${query}". Try a different search term.`;
|
||||
}
|
||||
|
||||
// Pick a random GIF from the results
|
||||
const randomIndex = Math.floor(Math.random() * data.results.length);
|
||||
const gif = data.results[randomIndex];
|
||||
const gifUrl = gif.media_formats.gif?.url || gif.media_formats.mediumgif?.url;
|
||||
|
||||
if (!gifUrl) {
|
||||
return `Found GIFs but couldn't get URL. Try again.`;
|
||||
}
|
||||
|
||||
logger.info("Found GIF", { query, gifUrl, title: gif.title });
|
||||
|
||||
return `GIF found! Include this URL in your response to show it: ${gifUrl}`;
|
||||
} catch (error) {
|
||||
logger.error("GIF search failed", error);
|
||||
return `Error searching for GIFs: ${(error as Error).message}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface ToolContext {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
authorName: string;
|
||||
/** Optional: enable GIF search for this context */
|
||||
gifSearchEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,6 +168,46 @@ export const JOEL_TOOLS: ChatCompletionTool[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* GIF search tool - only enabled when gif_search_enabled is true for the guild
|
||||
*/
|
||||
export const GIF_SEARCH_TOOL: ChatCompletionTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_gif",
|
||||
description: "Search for a funny GIF to send in the chat. Use this when you want to express yourself with a GIF, react to something funny, or just be chaotic. The GIF URL will be included in your response.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Search query for the GIF. Be creative and funny with your searches!",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Number of GIFs to get back (1-10). Default is 5, then a random one is picked.",
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get tools based on context settings
|
||||
* Returns the base tools plus any optional tools that are enabled
|
||||
*/
|
||||
export function getToolsForContext(context: ToolContext): ChatCompletionTool[] {
|
||||
const tools = [...JOEL_TOOLS];
|
||||
|
||||
// Add GIF search tool if enabled for this guild
|
||||
if (context.gifSearchEnabled) {
|
||||
tools.push(GIF_SEARCH_TOOL);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of tools for memory extraction (lightweight)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user