feat: add markdown formatting and session compacts to search results

This commit is contained in:
Manoj 2025-10-21 12:27:20 +05:30 committed by Harshith Mullapudi
parent 8a6b06383e
commit 33bec831c6
4 changed files with 192 additions and 139 deletions

View File

@ -20,6 +20,7 @@ export const SearchBodyRequest = z.object({
scoreThreshold: z.number().optional(), scoreThreshold: z.number().optional(),
minResults: z.number().optional(), minResults: z.number().optional(),
adaptiveFiltering: z.boolean().optional(), adaptiveFiltering: z.boolean().optional(),
structured: z.boolean().optional(),
}); });
const searchService = new SearchService(); const searchService = new SearchService();
@ -47,6 +48,7 @@ const { action, loader } = createHybridActionApiRoute(
minResults: body.minResults, minResults: body.minResults,
spaceIds: body.spaceIds, spaceIds: body.spaceIds,
adaptiveFiltering: body.adaptiveFiltering, adaptiveFiltering: body.adaptiveFiltering,
structured: body.structured,
}, },
); );
return json(results); return json(results);

View File

@ -25,15 +25,20 @@ export class SearchService {
* @param query The search query * @param query The search query
* @param userId The user ID for personalization * @param userId The user ID for personalization
* @param options Search options * @param options Search options
* @returns Array of relevant statements * @returns Markdown formatted context (default) or structured JSON (if structured: true)
*/ */
public async search( public async search(
query: string, query: string,
userId: string, userId: string,
options: SearchOptions = {}, options: SearchOptions = {},
source?: string, source?: string,
): Promise<{ ): Promise<string | {
episodes: {content: string; createdAt: Date; spaceIds: string[]}[]; episodes: {
content: string;
createdAt: Date;
spaceIds: string[];
isCompact?: boolean;
}[];
facts: { facts: {
fact: string; fact: string;
validAt: Date; validAt: Date;
@ -57,6 +62,7 @@ export class SearchService {
minResults: options.minResults || 10, minResults: options.minResults || 10,
spaceIds: options.spaceIds || [], spaceIds: options.spaceIds || [],
adaptiveFiltering: options.adaptiveFiltering || false, adaptiveFiltering: options.adaptiveFiltering || false,
structured: options.structured || false,
}; };
const queryVector = await this.getEmbedding(query); const queryVector = await this.getEmbedding(query);
@ -107,21 +113,28 @@ export class SearchService {
filteredResults.map((item) => item.statement), filteredResults.map((item) => item.statement),
); );
return { // Replace session episodes with compacts automatically
episodes: episodes.map((episode) => ({ const unifiedEpisodes = await this.replaceWithCompacts(episodes, userId);
content: episode.originalContent,
createdAt: episode.createdAt, const factsData = filteredResults.map((statement) => ({
spaceIds: episode.spaceIds || [],
})),
facts: filteredResults.map((statement) => ({
fact: statement.statement.fact, fact: statement.statement.fact,
validAt: statement.statement.validAt, validAt: statement.statement.validAt,
invalidAt: statement.statement.invalidAt || null, invalidAt: statement.statement.invalidAt || null,
relevantScore: statement.score, relevantScore: statement.score,
})), }));
// Return markdown by default, structured JSON if requested
if (opts.structured) {
return {
episodes: unifiedEpisodes,
facts: factsData,
}; };
} }
// Return markdown formatted context
return this.formatAsMarkdown(unifiedEpisodes, factsData);
}
/** /**
* Apply adaptive filtering to ranked results * Apply adaptive filtering to ranked results
* Uses a minimum quality threshold to filter out low-quality results * Uses a minimum quality threshold to filter out low-quality results
@ -363,114 +376,175 @@ export class SearchService {
} }
/** /**
* Enhanced search that includes compacted sessions * Format search results as markdown for agent consumption
* This method searches both regular episodes/statements AND compacted sessions
* Compacted sessions are preferred when they have high confidence matches
*/ */
public async searchWithCompacts( private formatAsMarkdown(
query: string, episodes: Array<{
userId: string, content: string;
options: SearchOptions = {}, createdAt: Date;
source?: string, spaceIds: string[];
): Promise<ExtendedSearchResult> { isCompact?: boolean;
const startTime = Date.now(); }>,
facts: Array<{
fact: string;
validAt: Date;
invalidAt: Date | null;
relevantScore: number;
}>,
): string {
const sections: string[] = [];
// First, run the standard search for episodes and facts // Add episodes/compacts section
const standardResults = await this.search(query, userId, options, source); if (episodes.length > 0) {
sections.push("## Recalled Relevant Context\n");
// Search compacted sessions episodes.forEach((episode, index) => {
try { const date = episode.createdAt.toLocaleString("en-US", {
const queryVector = await this.getEmbedding(query); month: "short",
const { searchCompactedSessionsByEmbedding } = await import( day: "numeric",
"~/services/graphModels/compactedSession" year: "numeric",
); hour: "2-digit",
minute: "2-digit",
const compactResults = await searchCompactedSessionsByEmbedding(
queryVector,
userId,
options.limit || 10,
options.scoreThreshold || 0.7,
);
logger.info(`Found ${compactResults.length} matching compacted sessions`, {
query,
userId,
}); });
// Format compact results if (episode.isCompact) {
const formattedCompacts = compactResults.map((result) => ({ sections.push(`### 📦 Session Compact`);
summary: result.compact.summary, sections.push(`**Created**: ${date}\n`);
sessionId: result.compact.sessionId, sections.push(episode.content);
episodeCount: result.compact.episodeCount, sections.push(""); // Empty line
confidence: result.compact.confidence, } else {
relevantScore: result.score, sections.push(`### Episode ${index + 1}`);
})); sections.push(`**Created**: ${date}`);
if (episode.spaceIds.length > 0) {
// Log compact recall sections.push(`**Spaces**: ${episode.spaceIds.join(", ")}`);
await this.logCompactRecallAsync(
query,
userId,
compactResults,
Date.now() - startTime,
source,
).catch((error) => {
logger.error("Failed to log compact recall event:", error);
});
return {
...standardResults,
compacts: formattedCompacts,
};
} catch (error) {
logger.error("Error searching compacted sessions:", { error });
// Return standard results if compact search fails
return {
...standardResults,
compacts: [],
};
} }
sections.push(""); // Empty line before content
sections.push(episode.content);
sections.push(""); // Empty line after
}
});
}
// Add facts section
if (facts.length > 0) {
sections.push("## Key Facts\n");
facts.forEach((fact) => {
const validDate = fact.validAt.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const invalidInfo = fact.invalidAt
? ` → Invalidated ${fact.invalidAt.toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric" })}`
: "";
sections.push(`- ${fact.fact}`);
sections.push(` *Valid from ${validDate}${invalidInfo}*`);
});
sections.push(""); // Empty line after facts
}
// Handle empty results
if (episodes.length === 0 && facts.length === 0) {
sections.push("*No relevant memories found.*\n");
}
return sections.join("\n");
} }
/** /**
* Log recall event for compacted sessions * Replace session episodes with their compacted sessions
* Returns unified array with both regular episodes and compacts
*/ */
private async logCompactRecallAsync( private async replaceWithCompacts(
query: string, episodes: EpisodicNode[],
userId: string, userId: string,
compacts: Array<{ compact: any; score: number }>, ): Promise<Array<{
responseTime: number, content: string;
source?: string, createdAt: Date;
): Promise<void> { spaceIds: string[];
try { isCompact?: boolean;
const averageScore = }>> {
compacts.length > 0 // Group episodes by sessionId
? compacts.reduce((sum, c) => sum + c.score, 0) / compacts.length const sessionEpisodes = new Map<string, EpisodicNode[]>();
: 0; const nonSessionEpisodes: EpisodicNode[] = [];
await prisma.recallLog.create({ for (const episode of episodes) {
data: { // Skip episodes with documentId (these are document chunks, not session episodes)
accessType: "search", if (episode.metadata?.documentUuid) {
query, nonSessionEpisodes.push(episode);
targetType: "compacted_session", continue;
searchMethod: "vector_similarity", }
resultCount: compacts.length,
similarityScore: averageScore, // Episodes with sessionId - group them
context: JSON.stringify({ if (episode.sessionId) {
compactedSessionSearch: true, if (!sessionEpisodes.has(episode.sessionId)) {
}), sessionEpisodes.set(episode.sessionId, []);
source: source ?? "search_with_compacts", }
responseTimeMs: responseTime, sessionEpisodes.get(episode.sessionId)!.push(episode);
userId, } else {
}, // No sessionId - keep as regular episode
nonSessionEpisodes.push(episode);
}
}
// Build unified result array
const result: Array<{
content: string;
createdAt: Date;
spaceIds: string[];
isCompact?: boolean;
}> = [];
// Add non-session episodes first
for (const episode of nonSessionEpisodes) {
result.push({
content: episode.originalContent,
createdAt: episode.createdAt,
spaceIds: episode.spaceIds || [],
});
}
// Check each session for compacts
const { getCompactedSessionBySessionId } = await import(
"~/services/graphModels/compactedSession"
);
const sessionIds = Array.from(sessionEpisodes.keys());
for (const sessionId of sessionIds) {
const sessionEps = sessionEpisodes.get(sessionId)!;
const compact = await getCompactedSessionBySessionId(sessionId, userId);
if (compact) {
// Compact exists - add compact as episode, skip original episodes
result.push({
content: compact.summary,
createdAt: compact.startTime, // Use session start time
spaceIds: [], // Compacts don't have spaceIds directly
isCompact: true,
}); });
logger.debug( logger.info(`Replaced ${sessionEps.length} episodes with compact`, {
`Logged compact recall event for user ${userId}: ${compacts.length} compacts in ${responseTime}ms`, sessionId,
); episodeCount: sessionEps.length,
} catch (error) { });
logger.error("Error creating compact recall log entry:", { error }); } else {
// No compact - add original episodes
for (const episode of sessionEps) {
result.push({
content: episode.originalContent,
createdAt: episode.createdAt,
spaceIds: episode.spaceIds || [],
});
} }
} }
}
return result;
}
} }
/** /**
@ -489,24 +563,5 @@ export interface SearchOptions {
minResults?: number; minResults?: number;
spaceIds?: string[]; // Filter results by specific spaces spaceIds?: string[]; // Filter results by specific spaces
adaptiveFiltering?: boolean; adaptiveFiltering?: boolean;
} structured?: boolean; // Return structured JSON instead of markdown (default: false)
/**
* Extended search result that includes compacted sessions
*/
export interface ExtendedSearchResult {
episodes: { content: string; createdAt: Date; spaceIds: string[] }[];
facts: {
fact: string;
validAt: Date;
invalidAt: Date | null;
relevantScore: number;
}[];
compacts?: {
summary: string;
sessionId: string;
episodeCount: number;
confidence: number;
relevantScore: number;
}[];
} }

View File

@ -27,7 +27,7 @@ const CompactionResultSchema = z.object({
}); });
const CONFIG = { const CONFIG = {
minEpisodesForCompaction: 3, // Minimum episodes to trigger compaction minEpisodesForCompaction: 5, // Minimum episodes to trigger compaction
compactionThreshold: 1, // Trigger after N new episodes compactionThreshold: 1, // Trigger after N new episodes
maxEpisodesPerBatch: 50, // Process in batches if needed maxEpisodesPerBatch: 50, // Process in batches if needed
}; };
@ -52,10 +52,7 @@ export const sessionCompactionTask = task({
try { try {
// Check if compaction already exists // Check if compaction already exists
// const existingCompact = await getCompactedSessionBySessionId(sessionId, userId); const existingCompact = await getCompactedSessionBySessionId(sessionId, userId);
const existingCompact = {} as CompactedSessionNode;
// Fetch all episodes for this session // Fetch all episodes for this session
const episodes = await getSessionEpisodes(sessionId, userId, existingCompact?.endTime); const episodes = await getSessionEpisodes(sessionId, userId, existingCompact?.endTime);
@ -87,10 +84,9 @@ export const sessionCompactionTask = task({
} }
// Generate or update compaction // Generate or update compaction
const compactionResult = await createCompaction(sessionId, episodes, userId, source) const compactionResult = existingCompact
// const compactionResult = existingCompact ? await updateCompaction(existingCompact, episodes, userId)
// ? await updateCompaction(existingCompact, episodes, userId) : await createCompaction(sessionId, episodes, userId, source);
// : await createCompaction(sessionId, episodes, userId, source);
logger.info(`Session compaction completed`, { logger.info(`Session compaction completed`, {
sessionId, sessionId,

View File

@ -77,7 +77,7 @@ export const memoryTools = [
{ {
name: "memory_search", name: "memory_search",
description: description:
"Search stored memories for past conversations, user preferences, project context, and decisions. USE THIS TOOL: 1) At start of every conversation to find related context, 2) When user mentions past work or projects, 3) Before answering questions that might have previous context. HOW TO USE: Write a simple query describing what to find (e.g., 'user code preferences', 'authentication bugs', 'API setup steps'). Returns: Episodes (past conversations) and Facts (extracted knowledge) as JSON.", "Search stored memories for past conversations, user preferences, project context, and decisions. USE THIS TOOL: 1) At start of every conversation to find related context, 2) When user mentions past work or projects, 3) Before answering questions that might have previous context. HOW TO USE: Write a simple query describing what to find (e.g., 'user code preferences', 'authentication bugs', 'API setup steps'). Returns: Markdown-formatted context optimized for LLM consumption, including session compacts, episodes, and key facts with temporal metadata.",
inputSchema: SearchParamsSchema, inputSchema: SearchParamsSchema,
}, },
{ {