Add comprehensive tests for query parsing and Reciprocal Rank Fusion

- Implemented tests for the QueryParser class, covering various identifier splitting methods (CamelCase, snake_case, kebab-case), OR expansion, and FTS5 operator preservation.
- Added parameterized tests to validate expected token outputs for different query formats.
- Created edge case tests to ensure robustness against unusual input scenarios.
- Developed tests for the Reciprocal Rank Fusion (RRF) algorithm, including score computation, weight handling, and result ranking across multiple sources.
- Included tests for normalization of BM25 scores and tagging search results with source metadata.
This commit is contained in:
catlog22
2025-12-16 10:20:19 +08:00
parent 35485bbbb1
commit 3da0ef2adb
39 changed files with 6171 additions and 240 deletions

View File

@@ -732,6 +732,215 @@ export function getMemoryStore(projectPath: string): MemoryStore {
return storeCache.get(cacheKey)!;
}
/**
* Get aggregated stats from parent and all child projects
* @param projectPath - Parent project path
* @returns Aggregated statistics from all projects
*/
export function getAggregatedStats(projectPath: string): {
entities: number;
prompts: number;
conversations: number;
total: number;
projects: Array<{ path: string; stats: { entities: number; prompts: number; conversations: number } }>;
} {
const { scanChildProjects } = require('../config/storage-paths.js');
const childProjects = scanChildProjects(projectPath);
const projectStats: Array<{ path: string; stats: { entities: number; prompts: number; conversations: number } }> = [];
let totalEntities = 0;
let totalPrompts = 0;
let totalConversations = 0;
// Get parent stats
try {
const parentStore = getMemoryStore(projectPath);
const db = (parentStore as any).db;
const entityCount = (db.prepare('SELECT COUNT(*) as count FROM entities').get() as { count: number }).count;
const promptCount = (db.prepare('SELECT COUNT(*) as count FROM prompt_history').get() as { count: number }).count;
const conversationCount = (db.prepare('SELECT COUNT(*) as count FROM conversations').get() as { count: number }).count;
projectStats.push({
path: projectPath,
stats: { entities: entityCount, prompts: promptCount, conversations: conversationCount }
});
totalEntities += entityCount;
totalPrompts += promptCount;
totalConversations += conversationCount;
} catch (error) {
if (process.env.DEBUG) {
console.error(`[Memory Store] Failed to get stats for parent ${projectPath}:`, error);
}
}
// Get child stats
for (const child of childProjects) {
try {
const childStore = getMemoryStore(child.projectPath);
const db = (childStore as any).db;
const entityCount = (db.prepare('SELECT COUNT(*) as count FROM entities').get() as { count: number }).count;
const promptCount = (db.prepare('SELECT COUNT(*) as count FROM prompt_history').get() as { count: number }).count;
const conversationCount = (db.prepare('SELECT COUNT(*) as count FROM conversations').get() as { count: number }).count;
projectStats.push({
path: child.relativePath,
stats: { entities: entityCount, prompts: promptCount, conversations: conversationCount }
});
totalEntities += entityCount;
totalPrompts += promptCount;
totalConversations += conversationCount;
} catch (error) {
if (process.env.DEBUG) {
console.error(`[Memory Store] Failed to get stats for child ${child.projectPath}:`, error);
}
}
}
return {
entities: totalEntities,
prompts: totalPrompts,
conversations: totalConversations,
total: totalEntities + totalPrompts + totalConversations,
projects: projectStats
};
}
/**
* Get aggregated entities from parent and all child projects
* @param projectPath - Parent project path
* @param options - Query options
* @returns Combined entities from all projects with source information
*/
export function getAggregatedEntities(
projectPath: string,
options: { type?: string; limit?: number; offset?: number } = {}
): Array<HotEntity & { sourceProject?: string }> {
const { scanChildProjects } = require('../config/storage-paths.js');
const childProjects = scanChildProjects(projectPath);
const limit = options.limit || 50;
const offset = options.offset || 0;
const allEntities: Array<HotEntity & { sourceProject?: string }> = [];
// Get parent entities - apply LIMIT at SQL level
try {
const parentStore = getMemoryStore(projectPath);
const db = (parentStore as any).db;
let query = 'SELECT * FROM entities';
const params: any[] = [];
if (options.type) {
query += ' WHERE type = ?';
params.push(options.type);
}
query += ' ORDER BY last_seen_at DESC LIMIT ?';
params.push(limit);
const stmt = db.prepare(query);
const parentEntities = stmt.all(...params) as Entity[];
allEntities.push(...parentEntities.map((e: Entity) => ({ ...e, stats: {} as EntityStats, sourceProject: projectPath })));
} catch (error) {
if (process.env.DEBUG) {
console.error(`[Memory Store] Failed to get entities for parent ${projectPath}:`, error);
}
}
// Get child entities - apply LIMIT to each child
for (const child of childProjects) {
try {
const childStore = getMemoryStore(child.projectPath);
const db = (childStore as any).db;
let query = 'SELECT * FROM entities';
const params: any[] = [];
if (options.type) {
query += ' WHERE type = ?';
params.push(options.type);
}
query += ' ORDER BY last_seen_at DESC LIMIT ?';
params.push(limit);
const stmt = db.prepare(query);
const childEntities = stmt.all(...params) as Entity[];
allEntities.push(...childEntities.map((e: Entity) => ({ ...e, stats: {} as EntityStats, sourceProject: child.relativePath })));
} catch (error) {
if (process.env.DEBUG) {
console.error(`[Memory Store] Failed to get entities for child ${child.projectPath}:`, error);
}
}
}
// Sort by last_seen_at and apply final limit with offset
allEntities.sort((a, b) => {
const aTime = a.last_seen_at ? new Date(a.last_seen_at).getTime() : 0;
const bTime = b.last_seen_at ? new Date(b.last_seen_at).getTime() : 0;
return bTime - aTime;
});
return allEntities.slice(offset, offset + limit);
}
/**
* Get aggregated prompts from parent and all child projects
* @param projectPath - Parent project path
* @param limit - Maximum number of prompts to return
* @returns Combined prompts from all projects with source information
*/
export function getAggregatedPrompts(
projectPath: string,
limit: number = 50
): Array<PromptHistory & { sourceProject?: string }> {
const { scanChildProjects } = require('../config/storage-paths.js');
const childProjects = scanChildProjects(projectPath);
const allPrompts: Array<PromptHistory & { sourceProject?: string }> = [];
// Get parent prompts - use direct SQL query with LIMIT
try {
const parentStore = getMemoryStore(projectPath);
const db = (parentStore as any).db;
const stmt = db.prepare('SELECT * FROM prompt_history ORDER BY timestamp DESC LIMIT ?');
const parentPrompts = stmt.all(limit) as PromptHistory[];
allPrompts.push(...parentPrompts.map((p: PromptHistory) => ({ ...p, sourceProject: projectPath })));
} catch (error) {
if (process.env.DEBUG) {
console.error(`[Memory Store] Failed to get prompts for parent ${projectPath}:`, error);
}
}
// Get child prompts - apply LIMIT to each child to reduce memory footprint
for (const child of childProjects) {
try {
const childStore = getMemoryStore(child.projectPath);
const db = (childStore as any).db;
const stmt = db.prepare('SELECT * FROM prompt_history ORDER BY timestamp DESC LIMIT ?');
const childPrompts = stmt.all(limit) as PromptHistory[];
allPrompts.push(...childPrompts.map((p: PromptHistory) => ({ ...p, sourceProject: child.relativePath })));
} catch (error) {
if (process.env.DEBUG) {
console.error(`[Memory Store] Failed to get prompts for child ${child.projectPath}:`, error);
}
}
}
// Sort by timestamp and apply final limit
allPrompts.sort((a, b) => {
const aTime = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const bTime = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return bTime - aTime;
});
return allPrompts.slice(0, limit);
}
/**
* Close all store instances
*/

View File

@@ -212,7 +212,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
const status = url.searchParams.get('status') || null;
const category = url.searchParams.get('category') as 'user' | 'internal' | 'insight' | null;
const search = url.searchParams.get('search') || null;
const recursive = url.searchParams.get('recursive') !== 'false';
const recursive = url.searchParams.get('recursive') === 'true';
getExecutionHistoryAsync(projectPath, { limit, tool, status, category, search, recursive })
.then(history => {

View File

@@ -222,21 +222,30 @@ export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
const projectPath = url.searchParams.get('path') || initialPath;
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const search = url.searchParams.get('search') || null;
const recursive = url.searchParams.get('recursive') === 'true';
try {
const memoryStore = getMemoryStore(projectPath);
let prompts;
if (search) {
prompts = memoryStore.searchPrompts(search, limit);
// Recursive mode: aggregate prompts from parent and child projects
if (recursive && !search) {
const { getAggregatedPrompts } = await import('../memory-store.js');
prompts = getAggregatedPrompts(projectPath, limit);
} else {
// Get all recent prompts (we'll need to add this method to MemoryStore)
const stmt = memoryStore['db'].prepare(`
SELECT * FROM prompt_history
ORDER BY timestamp DESC
LIMIT ?
`);
prompts = stmt.all(limit);
// Non-recursive mode or search mode: query only current project
const memoryStore = getMemoryStore(projectPath);
if (search) {
prompts = memoryStore.searchPrompts(search, limit);
} else {
// Get all recent prompts (we'll need to add this method to MemoryStore)
const stmt = memoryStore['db'].prepare(`
SELECT * FROM prompt_history
ORDER BY timestamp DESC
LIMIT ?
`);
prompts = stmt.all(limit);
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -506,8 +515,23 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
const projectPath = url.searchParams.get('path') || initialPath;
const filter = url.searchParams.get('filter') || 'all'; // today, week, all
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
const recursive = url.searchParams.get('recursive') === 'true';
try {
// If requesting aggregated stats, use the aggregated function
if (url.searchParams.has('aggregated') || recursive) {
const { getAggregatedStats } = await import('../memory-store.js');
const aggregatedStats = getAggregatedStats(projectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
stats: aggregatedStats,
aggregated: true
}));
return true;
}
// Original hotspot statistics (non-recursive)
const memoryStore = getMemoryStore(projectPath);
const hotEntities = memoryStore.getHotEntities(limit * 4);