feat: implement BM25 search with context from last 3 user messages

- Implement proper BM25 scoring algorithm in /api/tools/search
  - Term frequency with saturation (k1 = 1.5)
  - Length normalization (b = 0.75)
  - Inverse document frequency (IDF)
- Accept recent messages via 'messages' query param for better context
- Update search-registry tool to:
  - Use /api/tools/search endpoint (not /api/tools)
  - Pass last 3 user messages for contextual search
  - Include recentMessages in tool input schema
- Update chat API to extract and pass last 3 user messages to search

BM25 formula: Σ IDF(qi) * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * |D| / avgdl))

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-12-04 13:59:22 +10:00
parent 9f85898937
commit d339637ebf
3 changed files with 125 additions and 52 deletions

View file

@ -74,7 +74,7 @@ export async function POST(request: NextRequest) {
execute: typeof searchTpmjsToolsTool.execute,
});
// 2. Extract user query from last message for tool search
// 2. Extract user query and last 3 user messages for tool search
const lastMessage = messages[messages.length - 1];
let userQuery = '';
if (lastMessage?.role === 'user') {
@ -88,7 +88,24 @@ export async function POST(request: NextRequest) {
}
}
// Get last 3 user messages for context
const recentUserMessages = messages
.filter((msg) => msg.role === 'user')
.slice(-3)
.map((msg) => {
// Extract text from parts
const parts = (msg as any).parts || [];
for (const part of parts) {
if (part.type === 'text') {
return part.text;
}
}
return '';
})
.filter(Boolean);
console.log(`💬 User query: "${userQuery}"`);
console.log(`📝 Recent messages: ${recentUserMessages.length}`);
// 3. Automatically search for relevant tools based on the user's message
if (userQuery && userQuery.trim().length > 0) {
@ -100,6 +117,7 @@ export async function POST(request: NextRequest) {
{
query: userQuery,
limit: 5, // Get top 5 relevant tools
recentMessages: recentUserMessages,
},
{} as any
);

View file

@ -5,23 +5,51 @@ export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
// Simple text-based search scoring (fallback until BM25 is fixed)
function calculateTextScore(query: string, document: string): number {
const queryTokens = query.toLowerCase().split(/\s+/);
const docLower = document.toLowerCase();
// BM25 parameters
const k1 = 1.5; // term frequency saturation parameter
const b = 0.75; // length normalization parameter
// Tokenize text into words
function tokenize(text: string): string[] {
return text
.toLowerCase()
.replace(/[^\w\s]/g, ' ')
.split(/\s+/)
.filter((t) => t.length > 0);
}
// Calculate term frequency
function termFrequency(term: string, tokens: string[]): number {
return tokens.filter((t) => t === term).length;
}
// Calculate BM25 score
function calculateBM25(
query: string,
document: string,
avgDocLength: number,
totalDocs: number,
docFrequencies: Map<string, number>
): number {
const queryTokens = tokenize(query);
const docTokens = tokenize(document);
const docLength = docTokens.length;
let score = 0;
for (const token of queryTokens) {
// Exact match in document
if (docLower.includes(token)) {
score += 1;
}
for (const term of queryTokens) {
const tf = termFrequency(term, docTokens);
if (tf === 0) continue;
// Boost if token appears in beginning (likely more relevant)
if (docLower.startsWith(token)) {
score += 0.5;
}
// IDF calculation
const docFreq = docFrequencies.get(term) || 0;
const idf = Math.log((totalDocs - docFreq + 0.5) / (docFreq + 0.5) + 1);
// BM25 formula
const numerator = tf * (k1 + 1);
const denominator = tf + k1 * (1 - b + b * (docLength / avgDocLength));
score += idf * (numerator / denominator);
}
return score;
@ -36,7 +64,13 @@ export async function GET(request: Request) {
const category = searchParams.get('category');
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '10'), 50);
console.log(`🔎 [SEARCH API] Query: "${query}", Category: ${category}, Limit: ${limit}`);
// Get recent messages for context (passed as JSON in 'messages' param)
const messagesParam = searchParams.get('messages');
const recentMessages = messagesParam ? JSON.parse(messagesParam) : [];
console.log(
`🔎 [SEARCH API] Query: "${query}", Category: ${category}, Limit: ${limit}, Messages: ${recentMessages.length}`
);
// Fetch all tools with package info
const tools = await prisma.tool.findMany({
@ -50,20 +84,47 @@ export async function GET(request: Request) {
console.log(`📊 [SEARCH API] Found ${tools.length} tools in database`);
// Build searchable documents and calculate scores
const scoredResults = tools.map((tool) => {
const document = [
// Combine query with recent messages for better context
const fullQuery = [query, ...recentMessages].filter(Boolean).join(' ');
console.log(`🔍 [SEARCH API] Full search context: "${fullQuery.slice(0, 100)}..."`);
// Build all documents first
const documents = tools.map((tool) => ({
tool,
text: [
tool.description,
tool.exportName,
tool.package.npmPackageName,
tool.package.npmDescription || '',
...(tool.package.npmKeywords || []),
].join(' ');
].join(' '),
}));
const textScore = calculateTextScore(query, document);
const qualityBoost = Number.parseFloat(tool.qualityScore || '0') * 0.5;
// Calculate document frequencies (IDF)
const docFrequencies = new Map<string, number>();
const queryTokens = tokenize(fullQuery);
for (const term of queryTokens) {
let count = 0;
for (const doc of documents) {
const docTokens = tokenize(doc.text);
if (docTokens.includes(term)) {
count++;
}
}
docFrequencies.set(term, count);
}
// Calculate average document length
const totalTokens = documents.reduce((sum, doc) => sum + tokenize(doc.text).length, 0);
const avgDocLength = totalTokens / documents.length;
// Calculate BM25 scores
const scoredResults = documents.map(({ tool, text }) => {
const bm25Score = calculateBM25(fullQuery, text, avgDocLength, tools.length, docFrequencies);
const qualityBoost = Number(tool.qualityScore ?? 0) * 0.5;
const downloadBoost = Math.log10((tool.package.npmDownloadsLastMonth || 0) + 1) * 0.1;
const finalScore = textScore + qualityBoost + downloadBoost;
const finalScore = bm25Score + qualityBoost + downloadBoost;
return { tool, score: finalScore };
});

View file

@ -7,6 +7,7 @@ type SearchTpmjsToolsInput = {
query: string;
category?: string;
limit?: number;
recentMessages?: string[];
};
/**
@ -51,17 +52,30 @@ export const searchTpmjsToolsTool = tool({
minimum: 1,
maximum: 20,
},
recentMessages: {
type: 'array',
description: 'Recent user messages for context (optional)',
items: {
type: 'string',
},
},
},
required: ['query'],
additionalProperties: false,
}),
async execute({ query, category, limit = 10 }) {
console.log('🔍 searchTpmjsTools.execute() called with:', { query, category, limit });
async execute({ query, category, limit = 10, recentMessages = [] }) {
console.log('🔍 searchTpmjsTools.execute() called with:', {
query,
category,
limit,
recentMessages: recentMessages.length,
});
const params = new URLSearchParams({
q: query,
limit: String(limit),
...(category && { category }),
...(recentMessages.length > 0 && { messages: JSON.stringify(recentMessages) }),
});
// Use environment variable, or default to production API (falls back to localhost in dev)
@ -69,9 +83,8 @@ export const searchTpmjsToolsTool = tool({
process.env.TPMJS_API_URL ||
(process.env.NODE_ENV === 'production' ? 'https://tpmjs.com' : 'http://localhost:3000');
// Note: /api/tools/search endpoint exists in local dev but not deployed yet
// Using /api/tools as fallback with client-side filtering for now
const url = `${baseUrl}/api/tools?${params}`;
// Use the /api/tools/search endpoint for BM25 scoring with context
const url = `${baseUrl}/api/tools/search?${params}`;
console.log(`🌐 Fetching: ${url}`);
@ -83,37 +96,18 @@ export const searchTpmjsToolsTool = tool({
throw new Error(`Search failed: ${response.statusText}`);
}
// biome-ignore lint/suspicious/noExplicitAny: API response types vary
const data = (await response.json()) as any;
console.log('📦 Search response data:', JSON.stringify(data, null, 2));
// Handle both /api/tools (deployed) and /api/tools/search (local dev) responses
const toolsArray = data.results?.tools || data.data || [];
// Client-side filtering if query is provided (since deployed /api/tools doesn't support search yet)
let filteredTools = toolsArray;
if (query?.trim()) {
const queryLower = query.toLowerCase();
filteredTools = toolsArray.filter((tool: any) => {
const searchableText = [
tool.description,
tool.exportName,
tool.package?.npmPackageName,
tool.package?.npmDescription,
...(tool.package?.npmKeywords || []),
]
.join(' ')
.toLowerCase();
return searchableText.includes(queryLower);
});
}
// Apply limit
const limitedTools = filteredTools.slice(0, limit);
// Handle /api/tools/search response structure
const toolsArray = data.results?.tools || [];
return {
query,
matchCount: filteredTools.length,
tools: limitedTools.map((tool: any) => ({
matchCount: toolsArray.length,
// biome-ignore lint/suspicious/noExplicitAny: Tool types from API vary
tools: toolsArray.map((tool: any) => ({
toolId: tool.id,
packageName: tool.package.npmPackageName,
exportName: tool.exportName,