mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-10 08:48:29 +00:00
feat: add markdown formatting and session compacts to search results
This commit is contained in:
parent
8a6b06383e
commit
33bec831c6
@ -20,6 +20,7 @@ export const SearchBodyRequest = z.object({
|
||||
scoreThreshold: z.number().optional(),
|
||||
minResults: z.number().optional(),
|
||||
adaptiveFiltering: z.boolean().optional(),
|
||||
structured: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const searchService = new SearchService();
|
||||
@ -47,6 +48,7 @@ const { action, loader } = createHybridActionApiRoute(
|
||||
minResults: body.minResults,
|
||||
spaceIds: body.spaceIds,
|
||||
adaptiveFiltering: body.adaptiveFiltering,
|
||||
structured: body.structured,
|
||||
},
|
||||
);
|
||||
return json(results);
|
||||
|
||||
@ -25,15 +25,20 @@ export class SearchService {
|
||||
* @param query The search query
|
||||
* @param userId The user ID for personalization
|
||||
* @param options Search options
|
||||
* @returns Array of relevant statements
|
||||
* @returns Markdown formatted context (default) or structured JSON (if structured: true)
|
||||
*/
|
||||
public async search(
|
||||
query: string,
|
||||
userId: string,
|
||||
options: SearchOptions = {},
|
||||
source?: string,
|
||||
): Promise<{
|
||||
episodes: {content: string; createdAt: Date; spaceIds: string[]}[];
|
||||
): Promise<string | {
|
||||
episodes: {
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
spaceIds: string[];
|
||||
isCompact?: boolean;
|
||||
}[];
|
||||
facts: {
|
||||
fact: string;
|
||||
validAt: Date;
|
||||
@ -57,6 +62,7 @@ export class SearchService {
|
||||
minResults: options.minResults || 10,
|
||||
spaceIds: options.spaceIds || [],
|
||||
adaptiveFiltering: options.adaptiveFiltering || false,
|
||||
structured: options.structured || false,
|
||||
};
|
||||
|
||||
const queryVector = await this.getEmbedding(query);
|
||||
@ -107,19 +113,26 @@ export class SearchService {
|
||||
filteredResults.map((item) => item.statement),
|
||||
);
|
||||
|
||||
return {
|
||||
episodes: episodes.map((episode) => ({
|
||||
content: episode.originalContent,
|
||||
createdAt: episode.createdAt,
|
||||
spaceIds: episode.spaceIds || [],
|
||||
})),
|
||||
facts: filteredResults.map((statement) => ({
|
||||
fact: statement.statement.fact,
|
||||
validAt: statement.statement.validAt,
|
||||
invalidAt: statement.statement.invalidAt || null,
|
||||
relevantScore: statement.score,
|
||||
})),
|
||||
};
|
||||
// Replace session episodes with compacts automatically
|
||||
const unifiedEpisodes = await this.replaceWithCompacts(episodes, userId);
|
||||
|
||||
const factsData = filteredResults.map((statement) => ({
|
||||
fact: statement.statement.fact,
|
||||
validAt: statement.statement.validAt,
|
||||
invalidAt: statement.statement.invalidAt || null,
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -363,114 +376,175 @@ export class SearchService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced search that includes compacted sessions
|
||||
* This method searches both regular episodes/statements AND compacted sessions
|
||||
* Compacted sessions are preferred when they have high confidence matches
|
||||
* Format search results as markdown for agent consumption
|
||||
*/
|
||||
public async searchWithCompacts(
|
||||
query: string,
|
||||
userId: string,
|
||||
options: SearchOptions = {},
|
||||
source?: string,
|
||||
): Promise<ExtendedSearchResult> {
|
||||
const startTime = Date.now();
|
||||
private formatAsMarkdown(
|
||||
episodes: Array<{
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
spaceIds: string[];
|
||||
isCompact?: boolean;
|
||||
}>,
|
||||
facts: Array<{
|
||||
fact: string;
|
||||
validAt: Date;
|
||||
invalidAt: Date | null;
|
||||
relevantScore: number;
|
||||
}>,
|
||||
): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
// First, run the standard search for episodes and facts
|
||||
const standardResults = await this.search(query, userId, options, source);
|
||||
// Add episodes/compacts section
|
||||
if (episodes.length > 0) {
|
||||
sections.push("## Recalled Relevant Context\n");
|
||||
|
||||
// Search compacted sessions
|
||||
try {
|
||||
const queryVector = await this.getEmbedding(query);
|
||||
const { searchCompactedSessionsByEmbedding } = await import(
|
||||
"~/services/graphModels/compactedSession"
|
||||
);
|
||||
episodes.forEach((episode, index) => {
|
||||
const date = episode.createdAt.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
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,
|
||||
if (episode.isCompact) {
|
||||
sections.push(`### 📦 Session Compact`);
|
||||
sections.push(`**Created**: ${date}\n`);
|
||||
sections.push(episode.content);
|
||||
sections.push(""); // Empty line
|
||||
} else {
|
||||
sections.push(`### Episode ${index + 1}`);
|
||||
sections.push(`**Created**: ${date}`);
|
||||
if (episode.spaceIds.length > 0) {
|
||||
sections.push(`**Spaces**: ${episode.spaceIds.join(", ")}`);
|
||||
}
|
||||
sections.push(""); // Empty line before content
|
||||
sections.push(episode.content);
|
||||
sections.push(""); // Empty line after
|
||||
}
|
||||
});
|
||||
|
||||
// Format compact results
|
||||
const formattedCompacts = compactResults.map((result) => ({
|
||||
summary: result.compact.summary,
|
||||
sessionId: result.compact.sessionId,
|
||||
episodeCount: result.compact.episodeCount,
|
||||
confidence: result.compact.confidence,
|
||||
relevantScore: result.score,
|
||||
}));
|
||||
|
||||
// Log compact recall
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 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(
|
||||
query: string,
|
||||
private async replaceWithCompacts(
|
||||
episodes: EpisodicNode[],
|
||||
userId: string,
|
||||
compacts: Array<{ compact: any; score: number }>,
|
||||
responseTime: number,
|
||||
source?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const averageScore =
|
||||
compacts.length > 0
|
||||
? compacts.reduce((sum, c) => sum + c.score, 0) / compacts.length
|
||||
: 0;
|
||||
): Promise<Array<{
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
spaceIds: string[];
|
||||
isCompact?: boolean;
|
||||
}>> {
|
||||
// Group episodes by sessionId
|
||||
const sessionEpisodes = new Map<string, EpisodicNode[]>();
|
||||
const nonSessionEpisodes: EpisodicNode[] = [];
|
||||
|
||||
await prisma.recallLog.create({
|
||||
data: {
|
||||
accessType: "search",
|
||||
query,
|
||||
targetType: "compacted_session",
|
||||
searchMethod: "vector_similarity",
|
||||
resultCount: compacts.length,
|
||||
similarityScore: averageScore,
|
||||
context: JSON.stringify({
|
||||
compactedSessionSearch: true,
|
||||
}),
|
||||
source: source ?? "search_with_compacts",
|
||||
responseTimeMs: responseTime,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
for (const episode of episodes) {
|
||||
// Skip episodes with documentId (these are document chunks, not session episodes)
|
||||
if (episode.metadata?.documentUuid) {
|
||||
nonSessionEpisodes.push(episode);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Logged compact recall event for user ${userId}: ${compacts.length} compacts in ${responseTime}ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error creating compact recall log entry:", { error });
|
||||
// Episodes with sessionId - group them
|
||||
if (episode.sessionId) {
|
||||
if (!sessionEpisodes.has(episode.sessionId)) {
|
||||
sessionEpisodes.set(episode.sessionId, []);
|
||||
}
|
||||
sessionEpisodes.get(episode.sessionId)!.push(episode);
|
||||
} 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.info(`Replaced ${sessionEps.length} episodes with compact`, {
|
||||
sessionId,
|
||||
episodeCount: sessionEps.length,
|
||||
});
|
||||
} 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;
|
||||
spaceIds?: string[]; // Filter results by specific spaces
|
||||
adaptiveFiltering?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}[];
|
||||
structured?: boolean; // Return structured JSON instead of markdown (default: false)
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ const CompactionResultSchema = z.object({
|
||||
});
|
||||
|
||||
const CONFIG = {
|
||||
minEpisodesForCompaction: 3, // Minimum episodes to trigger compaction
|
||||
minEpisodesForCompaction: 5, // Minimum episodes to trigger compaction
|
||||
compactionThreshold: 1, // Trigger after N new episodes
|
||||
maxEpisodesPerBatch: 50, // Process in batches if needed
|
||||
};
|
||||
@ -52,10 +52,7 @@ export const sessionCompactionTask = task({
|
||||
|
||||
try {
|
||||
// Check if compaction already exists
|
||||
// const existingCompact = await getCompactedSessionBySessionId(sessionId, userId);
|
||||
const existingCompact = {} as CompactedSessionNode;
|
||||
|
||||
|
||||
const existingCompact = await getCompactedSessionBySessionId(sessionId, userId);
|
||||
|
||||
// Fetch all episodes for this session
|
||||
const episodes = await getSessionEpisodes(sessionId, userId, existingCompact?.endTime);
|
||||
@ -87,10 +84,9 @@ export const sessionCompactionTask = task({
|
||||
}
|
||||
|
||||
// Generate or update compaction
|
||||
const compactionResult = await createCompaction(sessionId, episodes, userId, source)
|
||||
// const compactionResult = existingCompact
|
||||
// ? await updateCompaction(existingCompact, episodes, userId)
|
||||
// : await createCompaction(sessionId, episodes, userId, source);
|
||||
const compactionResult = existingCompact
|
||||
? await updateCompaction(existingCompact, episodes, userId)
|
||||
: await createCompaction(sessionId, episodes, userId, source);
|
||||
|
||||
logger.info(`Session compaction completed`, {
|
||||
sessionId,
|
||||
|
||||
@ -77,7 +77,7 @@ export const memoryTools = [
|
||||
{
|
||||
name: "memory_search",
|
||||
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,
|
||||
},
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user