core/apps/webapp/app/services/deepSearch.server.ts
2025-10-14 16:10:48 +05:30

602 lines
16 KiB
TypeScript

import { logger } from "./logger.service";
import { SearchService } from "./search.server";
import { makeModelCall } from "~/lib/model.server";
/**
* Request interface for deep search
*/
export interface DeepSearchRequest {
content: string;
intentOverride?: string;
metadata?: {
source?: "chrome" | "obsidian" | "mcp";
url?: string;
pageTitle?: string;
};
}
/**
* Content analysis result from Phase 1
*/
interface ContentAnalysis {
intent: string;
reasoning: string;
entities: string[];
temporal: string[];
actions: string[];
topics: string[];
priority: string[];
}
/**
* Agent decision from Phase 3
*/
interface AgentDecision {
shouldContinue: boolean;
confidence: number;
reasoning: string;
followUpQueries: string[];
}
/**
* Response interface for deep search
*/
export interface DeepSearchResponse {
synthesis: string;
episodes: Array<{
content: string;
createdAt: Date;
spaceIds: string[];
}>;
}
/**
* Deep Search Service
*
* Implements a 4-phase intelligent document search pipeline:
* 1. Content Analysis - Infer intent and decompose content
* 2. Parallel Broad Search - Fire multiple queries simultaneously
* 3. Agent Deep Dive - Evaluate and follow up on promising leads
* 4. Synthesis - Generate intent-aware context summary
*/
export class DeepSearchService {
constructor(private searchService: SearchService) {}
/**
* Main entry point for deep search
*/
async deepSearch(
request: DeepSearchRequest,
userId: string
): Promise<DeepSearchResponse> {
const startTime = Date.now();
const { content, intentOverride, metadata } = request;
logger.info("Deep search started", { userId, contentLength: content.length });
try {
// Phase 1: Analyze content and infer intent
const analysis = intentOverride
? await this.createAnalysisFromOverride(content, intentOverride)
: await this.analyzeContent(content, this.getIntentHints(metadata));
logger.info("Phase 1 complete", { intent: analysis.intent });
// Extract spaceIds from metadata if available
const spaceIds: string[] = [];
// Phase 2: Parallel broad search
const { episodes: broadEpisodes } = await this.performBroadSearch(
analysis,
userId,
spaceIds
);
logger.info("Phase 2 complete", { episodesCount: broadEpisodes.length });
// Phase 3: Agent-driven deep dive (using episodes for richer context)
const { episodes: deepDiveEpisodes } = await this.performDeepDive(
content,
analysis,
broadEpisodes,
userId,
spaceIds
);
logger.info("Phase 3 complete", {
deepDiveEpisodes: deepDiveEpisodes.length,
});
// Combine and deduplicate episodes
const allEpisodes = [...broadEpisodes, ...deepDiveEpisodes];
const episodeMap = new Map<string, any>();
allEpisodes.forEach((ep) => {
const key = `${ep.content}-${new Date(ep.createdAt).toISOString()}`;
if (!episodeMap.has(key)) {
episodeMap.set(key, ep);
}
});
const episodes = Array.from(episodeMap.values());
// Phase 4: Synthesize results using episodes (richer context than facts)
const synthesis = await this.synthesizeResults(
content,
analysis,
episodes
);
logger.info("Phase 4 complete", {
duration: Date.now() - startTime,
totalEpisodes: episodes.length,
});
return {
synthesis,
episodes,
};
} catch (error) {
logger.error("Deep search error", { error });
throw error;
}
}
/**
* Phase 1: Analyze content and infer intent
*/
private async analyzeContent(
content: string,
contextHints: string
): Promise<ContentAnalysis> {
const prompt = `
Analyze this content holistically and determine the user's intent.
CONTENT:
${content}
${contextHints}
YOUR TASK:
1. INFER INTENT: What is the user trying to do with this content?
Examples: reading email, writing blog post, preparing for meeting,
researching topic, tracking tasks, reviewing changes, etc.
Be specific and descriptive.
2. EXTRACT KEY ELEMENTS:
- Entities: People, places, organizations, objects (e.g., "John Doe", "Project Phoenix")
- Temporal: Dates, times, recurring events (e.g., "Wednesday standup", "last month")
- Actions: Verbs, action items, tasks (e.g., "follow up", "review", "fix bug")
- Topics: Themes, subjects, domains (e.g., "car maintenance", "API design")
3. PRIORITIZE: Which elements are most important to search first?
Return array like ["entities", "temporal", "topics"] ordered by importance.
RESPONSE FORMAT (JSON):
{
"intent": "specific intent description",
"reasoning": "why this intent was inferred",
"entities": ["entity1", "entity2"],
"temporal": ["temporal1", "temporal2"],
"actions": ["action1", "action2"],
"topics": ["topic1", "topic2"],
"priority": ["entities", "temporal", "topics"]
}
`;
let responseText = "";
await makeModelCall(
false,
[{ role: "user", content: prompt }],
(text) => {
responseText = text;
},
{},
"high"
);
return JSON.parse(responseText);
}
/**
* Create analysis from explicit intent override
*/
private async createAnalysisFromOverride(
content: string,
intentOverride: string
): Promise<ContentAnalysis> {
const prompt = `
The user has specified their intent as: "${intentOverride}"
CONTENT:
${content}
YOUR TASK:
Extract key elements from this content:
- Entities: People, places, organizations, objects
- Temporal: Dates, times, recurring events
- Actions: Verbs, action items, tasks
- Topics: Themes, subjects, domains
Prioritize elements based on the specified intent.
RESPONSE FORMAT (JSON):
{
"intent": "${intentOverride}",
"reasoning": "user-specified intent",
"entities": ["entity1", "entity2"],
"temporal": ["temporal1", "temporal2"],
"actions": ["action1", "action2"],
"topics": ["topic1", "topic2"],
"priority": ["entities", "temporal", "topics"]
}
`;
let responseText = "";
await makeModelCall(
false,
[{ role: "user", content: prompt }],
(text) => {
responseText = text;
},
{},
"high"
);
return JSON.parse(responseText);
}
/**
* Phase 2: Perform parallel broad search
*/
private async performBroadSearch(
analysis: ContentAnalysis,
userId: string,
spaceIds: string[]
): Promise<{ facts: any[]; episodes: any[] }> {
// Build query list based on priority
const queries: string[] = [];
// Add queries based on priority order
for (const category of analysis.priority) {
switch (category) {
case "entities":
queries.push(...analysis.entities.slice(0, 3));
break;
case "temporal":
queries.push(...analysis.temporal.slice(0, 2));
break;
case "topics":
queries.push(...analysis.topics.slice(0, 2));
break;
case "actions":
queries.push(...analysis.actions.slice(0, 2));
break;
}
}
// Ensure we have at least some queries
if (queries.length === 0) {
queries.push(
...analysis.entities.slice(0, 2),
...analysis.topics.slice(0, 2)
);
}
// Cap at 10 queries max
const finalQueries = queries.slice(0, 10);
logger.info(`Broad search: ${finalQueries.length} parallel queries`);
// Fire all searches in parallel
const results = await Promise.all(
finalQueries.map((query) =>
this.searchService.search(query, userId, {
limit: 20,
spaceIds,
})
)
);
// Flatten and deduplicate facts
const allFacts = results.flatMap((r) => r.facts);
const uniqueFacts = Array.from(
new Map(allFacts.map((f) => [f.fact, f])).values()
);
// Flatten and deduplicate episodes
const allEpisodes = results.flatMap((r) => r.episodes);
const uniqueEpisodes = Array.from(
new Map(allEpisodes.map((e) => [`${e.content}-${e.createdAt}`, e])).values()
);
return { facts: uniqueFacts, episodes: uniqueEpisodes };
}
/**
* Phase 3: Perform agent-driven deep dive using episodes
*/
private async performDeepDive(
content: string,
analysis: ContentAnalysis,
broadEpisodes: any[],
userId: string,
spaceIds: string[]
): Promise<{ facts: any[]; episodes: any[] }> {
// Check if we have any results worth evaluating
if (broadEpisodes.length === 0) {
logger.info("No episodes from broad search, skipping deep dive");
return { facts: [], episodes: [] };
}
// Agent decides on follow-up based on episodes
const decision = await this.decideFollowUp(
content,
analysis,
broadEpisodes
);
if (!decision.shouldContinue) {
logger.info(`Agent stopped: ${decision.reasoning}`);
return { facts: [], episodes: [] };
}
logger.info(
`Agent continuing with ${decision.followUpQueries.length} follow-up queries`
);
// Execute follow-up queries sequentially
const deepDiveFacts = [];
const deepDiveEpisodes = [];
for (const query of decision.followUpQueries) {
const result = await this.searchService.search(query, userId, {
limit: 20,
spaceIds,
});
deepDiveFacts.push(...result.facts);
deepDiveEpisodes.push(...result.episodes);
// Stop if we've gathered enough episodes
if (deepDiveEpisodes.length > 20) {
logger.info("Sufficient context gathered, stopping early");
break;
}
}
return { facts: deepDiveFacts, episodes: deepDiveEpisodes };
}
/**
* Agent decides on follow-up queries based on episodes
*/
private async decideFollowUp(
content: string,
analysis: ContentAnalysis,
episodes: any[]
): Promise<AgentDecision> {
const prompt = `
You are analyzing memory search results to decide if deeper investigation is needed.
ORIGINAL CONTENT:
${content}
INFERRED INTENT: ${analysis.intent}
FOUND MEMORIES (${episodes.length} episodes):
${episodes
.map((ep, i) => {
const date = new Date(ep.createdAt).toISOString().split("T")[0];
const preview = ep.content;
return `
--- Memory ${i + 1} (${date}) ---
${preview}
`;
})
.join("\n")}
YOUR TASK:
1. EVALUATE MEMORY RELEVANCE:
- Are these memories directly relevant to the original content?
- Do they provide sufficient context for the intent "${analysis.intent}"?
- What key information or connections are missing?
- Are there entities, topics, or concepts mentioned that warrant deeper exploration?
2. DECIDE ON FOLLOW-UP:
- If memories are highly relevant and complete: STOP, no follow-up needed
- If memories are relevant but incomplete: Continue with 1-2 clarifying queries
- If memories reveal new entities/topics worth exploring: Continue with 2-3 follow-up queries
- If memories are sparse or off-topic: STOP, unlikely to find better results
3. GENERATE FOLLOW-UP QUERIES (if continuing):
- Extract new entities, topics, or connections mentioned in the memories
- Formulate specific, targeted queries based on what's missing
- Focus on enriching context for the "${analysis.intent}" intent
- Maximum 3 queries
RESPONSE FORMAT (JSON):
{
"shouldContinue": true/false,
"confidence": 0.0-1.0,
"reasoning": "explanation of decision based on memory analysis",
"followUpQueries": ["query1", "query2"]
}
`;
let responseText = "";
await makeModelCall(
false,
[{ role: "user", content: prompt }],
(text) => {
responseText = text;
},
{},
"high"
);
return JSON.parse(responseText);
}
/**
* Phase 4: Synthesize results based on intent using episodes
*/
private async synthesizeResults(
content: string,
analysis: ContentAnalysis,
episodes: any[]
): Promise<string> {
if (episodes.length === 0) {
return "No relevant context found in memory.";
}
const prompt = `
You are synthesizing relevant context from the user's memory to help an AI assistant respond more effectively.
CURRENT CONTENT:
${content}
USER INTENT: ${analysis.intent}
RELEVANT MEMORY CONTEXT (${episodes.length} past conversations):
${episodes
.map((ep, i) => {
const date = new Date(ep.createdAt).toISOString().split("T")[0];
const preview = ep.content;
return `
[${date}]
${preview}
`;
})
.join("\n\n")}
SYNTHESIS OBJECTIVE:
${this.getIntentGuidance(analysis.intent)}
OUTPUT REQUIREMENTS:
- Provide clear, actionable context from the memories
- Start directly with relevant information, no meta-commentary
- Present facts, decisions, preferences, and patterns from past conversations
- Connect past context to current content when relevant
- Note any gaps, contradictions, or evolution in thinking
- Keep it factual and concise - this will be used by an AI assistant
- Do not use conversational language like "you said" or "you mentioned"
- Present information in third person or as direct facts
Good examples:
- "Previous discussions on X covered Y and Z. Key decision: ..."
- "From March 2024 conversation: [specific context]"
- "Related work on [project] established that..."
- "Past preferences indicate..."
- "Timeline: [sequence of events/decisions]"
`;
let synthesis = "";
await makeModelCall(
false,
[{ role: "user", content: prompt }],
(text) => {
synthesis = text;
},
{},
"high"
);
return synthesis;
}
/**
* Get synthesis guidance based on intent keywords
*/
private getIntentGuidance(intent: string): string {
const intentLower = intent.toLowerCase();
if (
intentLower.includes("read") ||
intentLower.includes("understand") ||
intentLower.includes("email")
) {
return "Focus on: Who/what is this about? What context should the reader know? Provide recognition and background.";
}
if (
intentLower.includes("writ") ||
intentLower.includes("draft") ||
intentLower.includes("blog") ||
intentLower.includes("post")
) {
return "Focus on: What has been said before on this topic? What's consistent with past statements? What gaps or contradictions exist?";
}
if (
intentLower.includes("meeting") ||
intentLower.includes("prep") ||
intentLower.includes("standup") ||
intentLower.includes("agenda")
) {
return "Focus on: Key discussion topics, recent relevant context, pending action items, what needs to be addressed.";
}
if (
intentLower.includes("research") ||
intentLower.includes("explore") ||
intentLower.includes("learn")
) {
return "Focus on: Patterns across memories, connections between topics, insights and evolution over time.";
}
if (
intentLower.includes("follow") ||
intentLower.includes("task") ||
intentLower.includes("todo") ||
intentLower.includes("action")
) {
return "Focus on: Action items, pending tasks, decisions made, what needs follow-up, deadlines.";
}
if (
intentLower.includes("review") ||
intentLower.includes("change") ||
intentLower.includes("update") ||
intentLower.includes("diff")
) {
return "Focus on: What has changed, what's new information, how things have evolved, timeline of updates.";
}
// Default
return "Focus on: Most relevant context and key insights that would be valuable for understanding this content.";
}
/**
* Generate context hints from metadata
*/
private getIntentHints(
metadata?: DeepSearchRequest["metadata"]
): string {
if (!metadata) return "";
const hints: string[] = [];
// Chrome extension context
if (metadata.source === "chrome") {
if (metadata.url?.includes("mail.google.com")) {
hints.push("Content is from email client (likely reading)");
}
if (metadata.url?.includes("calendar.google.com")) {
hints.push("Content is from calendar (likely meeting_prep)");
}
if (metadata.url?.includes("docs.google.com")) {
hints.push("Content is from document editor (likely writing)");
}
}
// Obsidian context
if (metadata.source === "obsidian") {
hints.push(
"Content is from note editor (could be writing or research)"
);
}
return hints.length > 0
? `\n\nCONTEXT HINTS:\n${hints.join("\n")}`
: "";
}
}