GiftContext and Followup Context Building System
Agentic RAG Architecture - Augmentation Layer
This document describes the Context Orchestration system, which serves as the Query Augmentation Layer in our Agentic RAG (Retrieval-Augmented Generation) architecture for gift recommendations.
Position in Agentic RAG Pipeline
Augmentation Layer Responsibilities
The Context Orchestration system transforms raw user queries into structured, enriched context that enables semantic retrieval:
1. Query Understanding (Intent → Structure)
Raw: "näita rohkem raamatuid"
↓
Structured GiftContext:
- intent: "show_more_products"
- productType: "Raamat"
- categoryHints: ["Ilukirjandus", "Teaduskirjandus"]
- language: "et"
2. Context Enrichment (Memory → Enhanced Query)
Conversation Memory:
- previousProducts: [Book A, Book B]
- authors: ["J.R.R. Tolkien"]
- lastAuthor: "Tolkien"
↓
Enriched Query:
- Exclude previously shown products
- Preserve author/category taxonomy
- Resolve pronouns ("tema" → "Tolkien")
3. Disambiguation (Ambiguity → Clarity)
Ambiguous: "näita veel tema raamatuid"
↓
Resolved:
- Multiple authors? → Use primaryAuthor
- Single author? → Resolve immediately
- Needs clarification? → Request user input
4. Followup Continuity (Session → Coherent Context)
Previous Search:
- productType: "Raamat"
- isPopular: true
- categoryHints: ["Ilukirjandus"]
↓
"näita rohkem" preserves:
- Same productType
- Same categoryHints
- Same isPopular flag
- Extended exclude list
Why Augmentation Before Retrieval?
Without Augmentation:
Query: "näita veel" → Search failure (no context)
Query: "tema raamatuid" → Ambiguous (which author?)
Query: "odavamaid" → Missing budget constraint
With Augmentation:
Query: "näita veel"
→ Augmented: show_more + preserved taxonomy → Relevant results
Query: "tema raamatuid"
→ Augmented: author="Tolkien" (resolved) → Accurate search
Query: "odavamaid"
→ Augmented: budget.max reduced 30% → Cheaper alternatives
Integration with Search Pipeline
The augmented GiftContext flows directly into the SearchOrchestrator (Retrieval Layer):
// AUGMENTATION OUTPUT (this system)
interface GiftContext {
intent: string // e.g., "show_more_products"
productType: string // e.g., "Raamat"
categoryHints: string[] // ["Ilukirjandus", "Fantaasia"]
authorName?: string // "J.R.R. Tolkien"
budget: { min?, max? } // { max: 35 } (reduced from 50)
excludeProductIds: string[] // [id1, id2, ...] (avoid duplicates)
language: 'et' | 'en' // Language preference
}
// RETRIEVAL INPUT (SearchOrchestrator)
→ Uses GiftContext to generate semantic queries
→ Filters by budget/constraints
→ Excludes shown products
→ Ranks by relevance to augmented context
Agentic Behavior
This augmentation layer exhibits agentic characteristics:
- Memory: Tracks conversation history, shown products, authors
- Reasoning: Resolves pronouns, disambiguates references, detects context switches
- Planning: Preserves or resets context based on intent continuity
- Tool Use: Queries Convex DB, LLM fallbacks for disambiguation
- Error Recovery: Validates author names, handles missing context gracefully
Executive Summary
The GiftContext system is a sophisticated multi-layered context orchestration architecture that powers conversational product recommendations. It combines real-time LLM context extraction, persistent storage, conversation memory, and intelligent followup resolution to maintain coherent multi-turn conversations about gift recommendations.
Key Capabilities:
- Extract structured context from natural language queries
- Preserve context across conversation turns
- Resolve ambiguous references (pronouns, product mentions)
- Intelligently reset or preserve exclude lists based on context switches
- Maintain author/book memory for book-related queries
- Support complex followup intents (show more, cheaper alternatives, author searches)
System Architecture Overview
GiftContext Data Structure
The GiftContext interface is the core data structure that represents extracted user intent and context:
interface GiftContext {
// INTENT & CONFIDENCE
intent: string // e.g., 'product_search', 'author_search', 'show_more_products'
confidence: number // 0-1, LLM extraction confidence
// RECIPIENT CONTEXT
occasion?: string // e.g., 'sünnipäev', 'christmas'
recipient?: string // e.g., 'ema', 'sõber', 'kolleeg'
recipientGender?: 'male' | 'female' | 'unisex' | 'unknown'
ageGroup?: 'child' | 'teen' | 'adult' | 'elderly' | 'unknown'
ageBracket?: AgeBracket // More granular: 'infant', 'toddler', etc.
recipientAge?: number // Exact age if provided
recipientAgeRange?: { min?: number; max?: number }
// BUDGET
budget?: {
min?: number
max?: number
hint?: string // Original budget expression
}
// TAXONOMY (Product Classification)
category?: string // e.g., 'Ilukirjandus', 'Tehnika'
productType?: string // e.g., 'Raamat', 'Kinkekaart'
categoryHints?: string[] // Alternative categories considered
productTypeHints?: string[] // Alternative product types
// PRODUCT-SPECIFIC
constraints?: string[] // e.g., ['EXCLUDE_BOOKS']
bookLanguage?: 'et' | 'en' // For book searches
authorName?: string // For author searches
isPopularQuery?: boolean // Flag for popular products
// METADATA
language: 'et' | 'en' | 'mixed' // Query language
timestamp?: number
meta?: GiftContextMeta // Extraction metadata (see below)
// PRODUCT INQUIRY
productInquiry?: {
productId?: string
productName?: string
product?: StoredProductSummary | null
}
}
GiftContextMeta
Extended metadata about context extraction:
interface GiftContextMeta {
// EXTRACTION METRICS
classifierUsed?: boolean
classifierConfidence?: number
classifierDurationMs?: number
fallbackTriggered?: boolean
hierarchicalRefinementMs?: number
extractionDurationMs?: number
confidence?: number
confidenceMissing?: boolean
hierarchicalUsed?: boolean
hierarchicalSkipped?: boolean
parallelMode?: boolean
// AGE DETECTION METADATA
ageKeywords?: string[]
ageKeywordUnknown?: string[]
ageKeywordBracketScores?: Partial<Record<AgeBracket, number>>
ageKeywordTimingMs?: number
// PRODUCT INQUIRY
productInquiryDetected?: boolean
productInquiryMatch?: string
// FALLBACK METADATA
budgetRelaxation?: {
applied: boolean
directions: ('over_max' | 'under_min')[]
tolerancePercent: number
}
materialConstraintFallback?: {
applied: boolean
constraints: string[]
}
bookLanguageFallback?: {
applied: boolean
requested: 'en' | 'et'
}
bookFallbackApplied?: {
reason: string
stage: 'pre-filter' | 'post-filter'
}
// REFERENCE RESOLUTION
referenceResolution?: {
source: 'memory'
reason?: string
authorName?: string
productName?: string
productId?: string
invalidAuthorBlocked?: boolean
}
// AUTHOR DETECTION
authorDetection?: {
method: 'pattern' | 'memory' | 'llm-memory'
authorName: string
}
authorClarificationNeeded?: boolean
availableAuthors?: string[]
}
Note: This is the COMPLETE interface as of the current implementation. The metadata is primarily used for debugging, telemetry, and understanding how context was extracted/resolved.
Detailed Component Breakdown
1. Context Extraction (extractContext)
Location: app/api/chat/orchestrators/context-orchestrator/extract-context.ts
Purpose: Convert natural language user message into structured GiftContext
Flow:
- Fast Classifier (optional): Quick pattern-based intent detection
- LLM Extraction: Use
ContextUnderstandingServiceto extract structured data - Precomputed Context: Accept frontend-precomputed context if available
- Post-processing:
- Sanitize taxonomy signals
- Apply gender affinity hints
- Normalize product type aliases
- Handle book exclusion constraints
Key Features:
- Caching: LLM responses cached by message hash for performance
- Conversation History: Last 3 non-budget messages included for context
- Fallback: If LLM fails, returns default context
- Book Constraint Logic: Detects gift intents and adds
EXCLUDE_BOOKSconstraint
Output:
{
giftContext: GiftContext,
intentTime: number // Extraction duration in ms
}
2. Stored Context Fetching (fetchStoredContext)
Location: app/api/chat/orchestrators/context-orchestrator/fetch-stored-context.ts
Purpose: Retrieve and merge persistent conversation context from Convex database
Database Query:
await convexClient.query(
api.queries.getConversationContext.getConversationContextByHexIdOptimized,
{ hexId: conversationId }
)
Retrieved Fields:
language,occasion,recipientbudget,productType,categoryshownProductIds(for exclusion)shownProducts(product summaries with authors)preferences(e.g.,bookLanguage)isPopularQuery
Merge Logic:
mergedExcludeIds = Array.from(new Set([
...clientExcludeIds, // From frontend
...storedContext.shownProductIds // From database
]))
Context Restoration:
- Budget: Restore if missing in current extraction
- Occasion/Recipient: Restore if missing
- Product Type/Category: Conditional restoration via
shouldRestoreProductContext()- Restores for:
show_more_products,author_search,cheaper_alternatives - Skips if new meaningful product type detected
- Restores for budget-related queries
- Restores for:
- Book Language: Restore but validate query language consistency
- Popular Flag: Restore if present in storage
Author Inference:
- Use
inferAuthorFromMessage()to detect author mentions in stored products - Upgrade intent to
author_searchif author detected
3. Exclude List Management
Location: app/api/chat/orchestrators/context-orchestrator/exclude-reset.ts
Purpose: Intelligently manage product exclusion list to prevent re-showing products while allowing fresh results on context switches
When to Reset Exclude List
Function: shouldResetExcludeList(previousIntent, currentIntent, userMessage, debug, previousProductType, currentProductType)
Reset Triggers:
-
Product Type Switch (most important):
// Reset if BOTH are specific AND different
// Example: "raamatud" → "kinkekaardid"
if (!isGeneric(prev) && !isGeneric(curr) && prev !== curr) {
return true;
} -
Recipient Change:
- Patterns:
sõbrale,emale,kolleeg,laps, etc.
- Patterns:
-
Occasion Change:
- Patterns:
sünnipäev,sissekolimine,tänu,jõulud
- Patterns:
Never Reset When:
currentIntent === 'show_more_products'- User message matches: "näita rohkem", "show more", "veel"
- Budget-only message: "umbes 30 euro"
Exclude List Pruning
Purpose: Prevent permanent pool exhaustion in long conversations
const MAX_EXCLUDE_LIST_SIZE = 30;
if (finalExcludeIds.length > MAX_EXCLUDE_LIST_SIZE) {
// Keep most recent 30 (FIFO)
finalExcludeIds = finalExcludeIds.slice(-MAX_EXCLUDE_LIST_SIZE);
}
4. Context Preservation (Followup Intents)
Location: app/api/chat/orchestrators/context-orchestrator/orchestrate.ts (lines 113-298)
Purpose: Preserve taxonomy and search parameters for followup queries that should inherit previous context
Preservation Intents
const preservationIntents = new Set([
'show_more_products',
'cheaper_alternatives',
'budget_alternatives'
]);
Preservation Sources (Priority Order)
IMPORTANT: The priority varies by field type:
For categoryHints and isPopular:
-
lastSearchParams (from frontend) - HIGHEST PRIORITY
- Contains actual search parameters from last product fetch
- Most accurate reflection of what user saw
-
storedContext.giftContext (database)
-
storedContext (legacy fields)
For productType, category, productTypeHints, authorName, budget:
- storedContext.giftContext (database) - ONLY SOURCE
- storedContext (legacy fields) - Fallback
Note: lastSearchParams is NOT used for productType/category preservation, only for categoryHints and isPopular flag.
Preserved Fields
{
// From storedContext ONLY:
productType: string // e.g., 'Raamat'
category: string // e.g., 'Ilukirjandus'
productTypeHints: string[]
authorName: string
budget: { min?, max?, hint? }
// Prioritize lastSearchParams, fallback to storedContext:
categoryHints: string[] // ⚠️ lastSearchParams first!
isPopularQuery: boolean // ⚠️ lastSearchParams first!
}
Preservation Logic
// Force preservation for show_more
const shouldForcePreservation = giftContext.intent === 'show_more_products';
// Apply productType if:
// - show_more OR current is missing OR current is generic
if (preservedProductType &&
(shouldForcePreservation || !currentProductType || currentTypeIsGeneric)) {
giftContext.productType = preservedProductType;
}
// Apply category if:
// - show_more OR current is missing
if (preservedCategory &&
(shouldForcePreservation || !currentCategory)) {
giftContext.category = preservedCategory;
}
// Always apply hints if available
if (preservedCategoryHints?.length) {
giftContext.categoryHints = preservedCategoryHints;
}
Critical Edge Case:
The system prioritizes lastSearchParams.categoryHints over stored context to ensure "show more" returns products from the same taxonomy as what the user actually saw (not what was stored, which might differ due to fallbacks).
5. Refinement Signals
Location: app/api/chat/orchestrators/context-orchestrator/refinement-signals.ts
Purpose: Apply pattern-based refinements to enrich context (does NOT modify intent)
Critical Note: This function NEVER modifies giftContext.intent. It only adjusts contextual fields based on detected patterns in the user message.
What It Modifies
-
Constraints:
- Add "väldi beebitooteid" if baby products should be avoided
-
Age Context:
- Adjust
recipientAge,ageGroup,ageBracketwhen baby products constraint applied
- Adjust
-
Taxonomy:
- Override
productTypeif preferred type detected and current is generic - Replace
productTypeHintswith refined preferences - Override
categoryif preferred category detected - Replace
categoryHintswith refined preferences
- Override
-
Book Language:
- Apply
bookLanguagepreference if detected (e.g., "inglisekeelsed raamatud")
- Apply
-
Budget:
- Set budget fields if pattern-matched (provides fallback when LLM misses budget)
- Reduce budget by 30% if "cheaper" requested (e.g., "odavamaid")
What It Does NOT Modify
giftContext.intent- Never changes the intentrecipientoroccasion- Exclude lists
- Author name
- Product inquiry
Key Patterns Detected
interface RefinementSignals {
avoidBabyProducts: boolean // "ei soovi beebitooteid"
preferredProductTypes: string[] // Specific product type mentions
preferredCategories: string[] // Category refinements
preferredBookLanguage?: 'et' | 'en' // "inglisekeelsed raamatud"
budgetMax?: number // "kuni 50 euro"
budgetMin?: number // "üle 20 euro"
budgetHint?: string // Original budget expression
cheaperRequested: boolean // "odavamaid", "soodsamaid"
}
Implementation Example
export function applyRefinementSignals(
giftContext: GiftContext,
userMessage: string,
debug: boolean
): void {
const signals = detectRefinementSignals(userMessage);
// Apply constraints
if (signals.avoidBabyProducts) {
giftContext.constraints = mergeUniqueStrings(
giftContext.constraints,
['väldi beebitooteid']
);
giftContext.recipientAge = 8;
giftContext.ageGroup = 'child';
giftContext.ageBracket = 'school_age';
}
// Apply taxonomy refinements
if (signals.preferredProductTypes.length > 0) {
giftContext.productType = signals.preferredProductTypes[0];
giftContext.productTypeHints = signals.preferredProductTypes;
}
if (signals.preferredCategories.length > 0) {
giftContext.category = signals.preferredCategories[0];
giftContext.categoryHints = signals.preferredCategories;
}
// Apply book language
if (signals.preferredBookLanguage) {
giftContext.bookLanguage = signals.preferredBookLanguage;
}
// Apply budget
if (signals.budgetMax || signals.budgetMin) {
if (!giftContext.budget) giftContext.budget = {};
if (signals.budgetMax) giftContext.budget.max = signals.budgetMax;
if (signals.budgetMin) giftContext.budget.min = signals.budgetMin;
if (signals.budgetHint) giftContext.budget.hint = signals.budgetHint;
}
// Handle "cheaper" requests
if (signals.cheaperRequested && giftContext.budget?.max) {
giftContext.budget.max = Math.floor(giftContext.budget.max * 0.7);
}
// NOTE: Intent is NEVER modified!
}
Why It Doesn't Touch Intent:
Intent determination is handled by the LLM in extractContext() and memory resolution in applyConversationMemoryResolutions(). Refinement signals only provide supplementary pattern-based adjustments to make the context more specific.
6. Conversation Memory System
Location: app/api/chat/context/conversation-memory.ts
Purpose: Build in-memory representation of conversation history for reference resolution
ConversationMemory Structure
interface ConversationMemory {
authors: string[] // All mentioned authors (validated)
books: string[] // All shown book titles
lastAuthor?: string // Most recently mentioned author
primaryAuthor?: string // Author from user's query (highest priority)
lastBook?: string // Most recently shown book
lastProductId?: string // Product ID of last book
}
Building Memory
Sources (in priority order):
-
GiftContext (current query):
if (giftContext.authorName) {
addAuthor(giftContext.authorName, 'gift-context');
memory.primaryAuthor = giftContext.authorName; // Highest priority!
} -
Stored Products (historical recommendations):
storedProducts.forEach(product => {
addBook(product.title, product.id);
extractAuthorsFromProduct(product).forEach(author =>
addAuthor(author, 'stored-product')
);
}); -
Assistant Messages (ONLY as fallback when authors.length === 0):
// CRITICAL: Only used if NO authors extracted from steps 1-2
if (memory.authors.length === 0) {
const assistantAuthors = extractAuthorsFromAssistantMessages(clientMessages, debug);
assistantAuthors.forEach(author =>
addAuthor(author, 'assistant-message')
);
}Note: Assistant message parsing is NOT always executed—it's a last-resort fallback when the conversation has no author context from the query or shown products.
Author Validation
All authors are validated before adding to memory:
function addAuthor(author: string, source: AuthorSource) {
const normalized = sanitizeAuthor(author); // Normalize whitespace/diacritics
if (!isValidAuthorName(normalized)) {
// Block pronouns: "tema", "see autor", etc.
// Block invalid names: single letters, numbers, etc.
return;
}
pushUnique(memory.authors, normalized);
memory.lastAuthor = normalized;
}
7. Followup Reference Resolution
Location: app/api/chat/context/conversation-memory.ts
Purpose: Resolve ambiguous references (pronouns, partial names) to actual entities from memory
Resolution Flow
Author Pronoun Patterns
export const AUTHOR_PRONOUN_REGEX =
/\b(tema|teda|temalt|temale|talle|
selle\s+autori|selle\s+kirjaniku|
tema\s+teoseid?|tema\s+raamatuid?|
sellelt\s+autorilt|sama\s+autorilt|
that\s+author|this\s+author|
the\s+same\s+author|his\s+works?|her\s+works?)\b/i;
Multi-Author Resolution Priority
When multiple authors exist and a pronoun is used:
-
Primary Author (from user's original query) - Confidence: 0.95
if (memory.primaryAuthor) {
console.log('Using primaryAuthor from query:', memory.primaryAuthor);
return { authorName: memory.primaryAuthor, reason: 'primary-author-from-query' };
} -
Last Author (most recent interaction) - Confidence: 0.7
if (memory.lastAuthor) {
console.log('Preferring lastAuthor as fallback:', memory.lastAuthor);
return { authorName: memory.lastAuthor, reason: 'ambiguous-pronoun-prefer-last-author' };
} -
Clarification Request
return {
needsClarification: true,
availableAuthors: Array.from(uniqueAuthors.values()),
reason: 'multiple-authors-needs-clarification'
};
Book Pronoun Resolution
const BOOK_PRONOUN_REGEX =
/\b(see\s+raamat|see\s+teos|sama\s+raamat|
that\s+book|this\s+book|the\s+same\s+book)\b/i;
if (BOOK_PRONOUN_REGEX.test(message) && memory.lastBook) {
return {
productName: memory.lastBook,
productId: memory.lastProductId,
intentOverride: 'product_inquiry',
reason: 'referential-book-pronoun'
};
}
8. Author Workflow
Location: app/api/chat/orchestrators/context-orchestrator/author-workflow.ts
Purpose: Three-stage author detection, validation, and resolution pipeline
Stage 1: Explicit Author Detection
Function: applyExplicitAuthorDetection()
Method: Pattern-based detection using detectExplicitAuthorName()
Patterns:
- Estonian:
"autorilt X","autori X","kirjanik X" - English:
"by X","from author X"
Actions:
if (explicitAuthor) {
// 1. Normalize and validate
const normalized = normalizeAuthorCandidate(explicitAuthor);
if (isValidAuthorName(normalized)) {
// 2. Set author context
giftContext.authorName = normalized;
giftContext.intent = 'author_search';
giftContext.productType = 'Raamat';
// 3. Add metadata
giftContext.meta.authorDetection = {
method: 'pattern',
authorName: normalized
};
// 4. Update memory
memory.authors.push(normalized);
memory.lastAuthor = normalized;
}
}
Stage 2: Author Name Validation
Function: validateAndCleanAuthorName()
Purpose: Filter out invalid author names extracted by LLM
Validation Rules:
- Normalize: Remove diacritics, extra spaces, honorifics
- Check pronouns: Block "tema", "see autor", etc.
- Validate format:
- Minimum 2 characters
- Not purely numeric
- Contains at least one letter
- Not a single letter followed by period only
function isValidAuthorName(name: string): boolean {
if (isLikelyAuthorPronoun(name)) return false;
// Real validation logic from author-validation.ts
const trimmed = name.trim();
if (trimmed.length < 2) return false;
if (/^\d+$/.test(trimmed)) return false;
if (!/[a-zA-ZÀ-ž]/.test(trimmed)) return false;
return true;
}
Action:
if (!isValidAuthorName(normalizedAuthor)) {
console.log('🧹 CLEARING INVALID AUTHOR NAME:', { extracted, reason });
giftContext.authorName = undefined;
}
Stage 3: Memory-Based Resolution
Function: applyConversationMemoryResolutions()
Flow:
-
Call
resolveFollowupReferences()(see section 6)- Returns:
{ authorName?, productName?, needsClarification?, ... }
- Returns:
-
Handle Clarification Request:
if (resolution.needsClarification && resolution.availableAuthors) {
giftContext.intent = 'question';
giftContext.meta.authorClarificationNeeded = true;
giftContext.meta.availableAuthors = resolution.availableAuthors;
return; // Assistant will ask user to clarify
} -
LLM Fallback (if pronoun but no direct match):
if (!resolution && AUTHOR_PRONOUN_REGEX.test(userMessage)) {
const llmResolution = await resolveAuthorWithLLM({
userMessage,
candidateAuthors: memory.authors,
lastAssistantMessage,
contextSummary
});
if (llmResolution?.authorName) {
resolution = {
authorName: llmResolution.authorName,
intentOverride: 'author_search',
reason: 'llm-author-resolution'
};
}
} -
Apply Resolution:
if (resolution.authorName) {
giftContext.authorName = resolution.authorName;
giftContext.intent = 'author_search';
giftContext.productType = 'Raamat';
giftContext.meta.referenceResolution = {
source: 'memory',
reason: resolution.reason,
authorName: resolution.authorName
};
}
9. Product Inquiry Resolution
Location: app/api/chat/orchestrators/context-orchestrator/product-inquiry.ts
Purpose: Detect when user asks about a specific previously shown product
Detection Logic
Function: resolveProductInquiryIntent()
Requirements:
- Stored products exist (user has seen products in this conversation)
- Inquiry signals present:
- Question mark:
"Kas see sobib?" - Keywords:
milleks,mis,kuidas,kas,sobib,kirjelda, etc.
- Question mark:
- Product mention detected: Title tokens match user message
- NOT a search signal: Blocks
alternatiiv,veel rohkem,odavam, etc.
Product Matching Algorithm
function messageMentionsProduct(
sanitizedMessage: string,
product: StoredProductSummary
): boolean {
const tokens = sanitizedTitle.split(' ');
const significantTokens = tokens.filter(token => token.length > 3);
// Strategy 1: Full title match
if (sanitizedMessage.includes(sanitizedTitle)) return true;
// Strategy 2: Single-word title (must be >4 chars)
if (tokens.length === 1 && tokens[0].length > 4) {
return sanitizedMessage.includes(tokens[0]);
}
// Strategy 3: Match 2+ significant tokens
let matchCount = 0;
for (const token of significantTokens) {
if (sanitizedMessage.includes(token)) matchCount++;
}
if (matchCount >= Math.min(2, significantTokens.length)) return true;
// Strategy 4: First two tokens as phrase
if (significantTokens.length >= 2) {
const firstTwo = `${significantTokens[0]} ${significantTokens[1]}`;
if (sanitizedMessage.includes(firstTwo)) return true;
}
return false;
}
Intent Override
if (inquiry) {
giftContext.intent = 'product_inquiry';
giftContext.productInquiry = {
productId: matched.id,
productName: matched.title,
product: matched // Full product object
};
giftContext.meta.productInquiryDetected = true;
}
10. Context Persistence (Storage)
Location: app/api/chat/orchestrators/context-orchestrator/persist-context.ts
Purpose: Save enriched context to Convex database after successful product recommendation
Stored Fields
const contextPayload = {
conversationId: conversation._id,
language: giftContext.language,
occasion: giftContext.occasion,
recipient: giftContext.recipient,
budget: {
min: giftContext.budget?.min,
max: giftContext.budget?.max,
currency: 'EUR'
},
shownProductIds: selectedProductIds, // For exclusion
shownProducts: productSummaries, // Full summaries with authors
productType: giftContext.productType,
category: giftContext.category,
isPopularQuery: giftContext.isPopularQuery,
preferences: {
bookLanguage: giftContext.bookLanguage
}
};
Database Schema (Convex)
Table: conversation_contexts
Indexes:
byConversationId: For fetching context by internal IDbyHexId: For fast lookup by conversation hexId (used in orchestrator)
Merge Strategy:
if (existing) {
// Merge product IDs (deduplicate)
const mergedProductIds = Array.from(new Set([
...existing.shownProductIds,
...new.shownProductIds
]));
// Merge product summaries (by ID)
const mergedShownProducts = mergeProductSummaries(
existing.shownProducts,
new.shownProducts
);
// Update with latest context
await ctx.db.patch(existing._id, {
language: new.language,
occasion: new.occasion,
// ... (overwrites with latest)
shownProductIds: mergedProductIds,
shownProducts: mergedShownProducts,
updatedAt: now
});
}
Product Summary Structure:
interface StoredProductSummary {
id: string
title: string
authors?: string // Comma-separated author names
category?: string
productType?: string
}
Complete Orchestration Flow (Code Level)
Entry Point: orchestrate()
Location: app/api/chat/orchestrators/context-orchestrator/orchestrate.ts
export async function orchestrate(options: ContextOrchestrationOptions):
Promise<ContextOrchestrationResult> {
const { request, clientMessages, convexClient, debug } = options;
const userMessage = request.messages[request.messages.length - 1]?.content || '';
// ═══════════════════════════════════════════════════════════
// STEP 1: EXTRACT CONTEXT FROM USER MESSAGE
// ═══════════════════════════════════════════════════════════
const { giftContext, intentTime } = await extractContext({
userMessage,
clientMessages,
precomputedContext: request.precomputedContext,
debug
});
// ═══════════════════════════════════════════════════════════
// STEP 2: FETCH STORED CONTEXT (PHASE 5)
// ═══════════════════════════════════════════════════════════
const clientExcludeIds = request.excludeProductIds || [];
const PHASE5_ENABLED = process.env.PHASE5_CONTEXT_ENABLE !== 'false';
const { storedContext, mergedExcludeIds } = await fetchStoredContext({
conversationId: request.conversationId,
convexClient,
clientMessages,
clientExcludeIds,
giftContext,
enabled: PHASE5_ENABLED,
debug
});
// ═══════════════════════════════════════════════════════════
// STEP 3: EXCLUDE LIST MANAGEMENT
// ═══════════════════════════════════════════════════════════
const previousIntent = storedContext?.giftContext?.intent ??
storedContext?.intent;
const previousProductType = storedContext?.giftContext?.productType ??
storedContext?.productType;
const shouldReset = shouldResetExcludeList(
previousIntent,
giftContext.intent,
userMessage,
debug,
previousProductType,
giftContext.productType
);
let finalExcludeIds = shouldReset ? [] : mergedExcludeIds;
// Prune if too large
const MAX_EXCLUDE_LIST_SIZE = 30;
if (finalExcludeIds.length > MAX_EXCLUDE_LIST_SIZE) {
finalExcludeIds = finalExcludeIds.slice(-MAX_EXCLUDE_LIST_SIZE);
}
// ═══════════════════════════════════════════════════════════
// STEP 4: CONTEXT PRESERVATION (for followup intents)
// ═══════════════════════════════════════════════════════════
const preservationIntents = new Set([
'show_more_products',
'cheaper_alternatives',
'budget_alternatives'
]);
if (preservationIntents.has(giftContext.intent) &&
(storedContext || request.lastSearchParams)) {
// CRITICAL: Preservation sources differ by field type
// - productType, category: ONLY from storedContext (NOT lastSearchParams)
// - categoryHints, isPopular: Prioritize lastSearchParams > storedContext
const preservedProductType = storedContext?.giftContext?.productType ??
storedContext?.productType;
const preservedCategory = storedContext?.giftContext?.category ??
storedContext?.category;
// ONLY these two fields prioritize lastSearchParams:
const preservedCategoryHints = request.lastSearchParams?.categoryHints ??
storedContext?.giftContext?.categoryHints ??
storedContext?.categoryHints;
const preservedIsPopular = request.lastSearchParams?.isPopular === true ||
storedContext?.giftContext?.isPopularQuery === true;
const shouldForcePreservation = giftContext.intent === 'show_more_products';
// Apply preservation logic (see section 4)
if (shouldApplyProductType(preservedProductType, shouldForcePreservation)) {
giftContext.productType = preservedProductType;
}
if (shouldApplyCategory(preservedCategory, shouldForcePreservation)) {
giftContext.category = preservedCategory;
}
if (preservedCategoryHints?.length) {
giftContext.categoryHints = preservedCategoryHints;
}
if (preservedIsPopular) {
giftContext.isPopularQuery = true;
}
// ... (budget, author, etc.)
}
// ═══════════════════════════════════════════════════════════
// STEP 5: REFINEMENT SIGNALS (adjusts constraints/taxonomy/budget, NOT intent)
// ═══════════════════════════════════════════════════════════
applyRefinementSignals(giftContext, userMessage, debug);
// ═══════════════════════════════════════════════════════════
// STEP 6: PRODUCT INQUIRY RESOLUTION
// ═══════════════════════════════════════════════════════════
resolveProductInquiryIntent(
giftContext,
userMessage,
storedContext,
clientMessages,
debug
);
// ═══════════════════════════════════════════════════════════
// STEP 7: BUILD CONVERSATION MEMORY
// ═══════════════════════════════════════════════════════════
const storedProducts = normalizeStoredProducts(storedContext?.shownProducts, debug);
const conversationMemory = buildConversationMemory({
storedProducts,
giftContext,
clientMessages,
debug
});
// ═══════════════════════════════════════════════════════════
// STEP 8: AUTHOR WORKFLOW (3 stages)
// ═══════════════════════════════════════════════════════════
try {
// Stage 1: Explicit pattern-based detection
applyExplicitAuthorDetection({
giftContext,
userMessage,
memory: conversationMemory,
debug
});
// Stage 2: Validate and clean author names
validateAndCleanAuthorName({
giftContext,
userMessage,
debug
});
// Stage 3: Memory-based resolution (pronouns, LLM fallback)
await applyConversationMemoryResolutions({
giftContext,
userMessage,
memory: conversationMemory,
clientMessages,
debug
});
} catch (authorWorkflowError) {
console.error(' AUTHOR WORKFLOW ERROR:', authorWorkflowError);
}
// ═══════════════════════════════════════════════════════════
// STEP 9: RETURN ORCHESTRATION RESULT
// ═══════════════════════════════════════════════════════════
return {
giftContext, // Enriched with all resolutions
intentTime, // Extraction time
mergedExcludeIds: finalExcludeIds, // Final exclude list
storedContext, // Raw stored context
storedProducts // Normalized product summaries
};
}
Visual System Flow Diagram
Edge Cases and Special Behaviors
1. Popular Query + Show More
Scenario: User asks for popular products, then "näita rohkem"
Challenge: isPopularQuery flag must be preserved across requests
Solution:
// Step 1: Frontend tracks lastSearchParams
lastSearchParamsRef.current = {
categoryHints: searchParams.categoryHints,
isPopular: searchParams.isPopular
};
// Step 2: Backend preserves from lastSearchParams (highest priority)
const preservedIsPopular = request.lastSearchParams?.isPopular === true
? true
: storedGiftContext?.isPopularQuery === true
? true
: undefined;
if (preservedIsPopular) {
giftContext.isPopularQuery = true;
}
Why it works: Frontend captures exact search state, backend prioritizes it over database (which might be stale due to async persistence).
2. Multi-Author Conversation with Pronouns
Scenario:
- User: "Näita raamatuid Tolkienilt ja Lewiselt"
- Assistant: Shows books from both
- User: "Näita veel tema raamatuid" ← Ambiguous!
Resolution Priority:
// 1. Primary Author (from original query) - Highest confidence
if (memory.primaryAuthor === "J.R.R. Tolkien") {
return { authorName: "J.R.R. Tolkien", confidence: 0.95 };
}
// 2. Last Author (recency) - Medium confidence
else if (memory.lastAuthor === "C.S. Lewis") {
return { authorName: "C.S. Lewis", confidence: 0.7 };
}
// 3. Clarification - Last resort
else {
return {
needsClarification: true,
availableAuthors: ["J.R.R. Tolkien", "C.S. Lewis"]
};
}
Assistant Response (clarification):
"Milliselt autorilt soovid veel raamatuid näha?
- J.R.R. Tolkien
- C.S. Lewis"
3. Context Switch with Exclude Reset
Scenario:
- User: "Näita raamatuid" → Shows 5 books
- User: "Näita kinkekaarte" → Different product type
Expected: Reset exclude list (don't exclude books from gift card pool)
Implementation:
shouldResetExcludeList(
previousProductType: 'Raamat',
currentProductType: 'Kinkekaart'
) → true
// Reason: Both are specific (not generic) AND different
finalExcludeIds = []; // Reset!
Counter-example (NO reset):
- User: "Näita kingitusi" → Generic query
- User: "Näita raamatuid" → Specific
shouldResetExcludeList(
previousProductType: 'Kingitus', // Generic
currentProductType: 'Raamat' // Specific
) → false
// Reason: previousProductType is generic (not a real context switch)
finalExcludeIds = [...previous]; // Keep exclusions
4. Long Conversation Exclude Exhaustion
Scenario: User has 50+ interactions, exclude list grows to 80+ items
Problem: Product pool exhausted, no valid results
Solution: Prune to most recent 30
const MAX_EXCLUDE_LIST_SIZE = 30;
if (finalExcludeIds.length > MAX_EXCLUDE_LIST_SIZE) {
// Keep most recent (FIFO - First In, First Out)
finalExcludeIds = finalExcludeIds.slice(-MAX_EXCLUDE_LIST_SIZE);
console.log('✂️ EXCLUDE LIST PRUNED:', {
before: 80,
after: 30,
pruned: 50
});
}
Why FIFO: Recent products are more likely to be relevant to current conversation context.
5. Author Pronoun with Invalid Names
Scenario: LLM extracts "tema" (Estonian pronoun) as author name
Detection:
const AUTHOR_PRONOUNS = new Set([
'tema', 'teda', 'temale', 'talle',
'see autor', 'selle autori',
'this author', 'that author'
]);
function isLikelyAuthorPronoun(name: string): boolean {
const normalized = sanitizeForMatching(name);
return AUTHOR_PRONOUNS.has(normalized);
}
Validation:
if (!isValidAuthorName(giftContext.authorName)) {
console.log('🧹 CLEARING INVALID AUTHOR:', {
value: giftContext.authorName,
reason: isLikelyAuthorPronoun(giftContext.authorName)
? 'pronoun-fragment'
: 'failed-validation'
});
giftContext.authorName = undefined;
}
Protection Layers:
- Extraction: Filter during memory building
- Validation:
validateAndCleanAuthorName()stage - Resolution: Check again in
applyConversationMemoryResolutions() - Storage Sanitization: Clean on retrieval from database
Performance Characteristics
Timing Breakdown (Typical Request)
┌─────────────────────────────────┬──────────┬─────────────┐
│ Phase │ Time (ms)│ % of Total │
├─────────────────────────────────┼──────────┼─────────────┤
│ 1. Context Extraction (LLM) │ 150-300 │ 40-50% │
│ - Fast Classifier │ 10-20 │ │
│ - LLM API Call │ 120-250 │ │
│ - Post-processing │ 20-30 │ │
├─────────────────────────────────┼──────────┼─────────────┤
│ 2. Fetch Stored Context │ 30-60 │ 8-12% │
│ - Convex Query (indexed) │ 25-50 │ │
│ - Merge Logic │ 5-10 │ │
├─────────────────────────────────┼──────────┼─────────────┤
│ 3. Exclude List Management │ 2-5 │ <1% │
├─────────────────────────────────┼──────────┼─────────────┤
│ 4. Context Preservation │ 1-3 │ <1% │
├─────────────────────────────────┼──────────┼─────────────┤
│ 5. Build Conversation Memory │ 10-20 │ 2-4% │
├─────────────────────────────────┼──────────┼─────────────┤
│ 6. Author Workflow │ 20-150 │ 5-30% │
│ - Pattern Detection │ 5-10 │ │
│ - Validation │ 2-5 │ │
│ - Memory Resolution │ 10-20 │ │
│ - LLM Fallback (if needed) │ 0-100 │ (optional) │
├─────────────────────────────────┼──────────┼─────────────┤
│ 7. Product Inquiry Resolution │ 5-15 │ 1-3% │
├─────────────────────────────────┼──────────┼─────────────┤
│ TOTAL ORCHESTRATION │ 220-550 │ 100% │
└─────────────────────────────────┴──────────┴─────────────┘
Optimization Strategies
-
LLM Caching:
- Cache key: Hash of (userMessage + last 3 conversation messages)
- Hit rate: ~15-20% for common queries
- Savings: ~150ms per cache hit
-
Indexed Database Queries:
- Convex index:
byHexId(conversation hexId) - Query time: ~25ms (vs very fast without index)
- Convex index:
-
Parallel Operations (where possible):
- LLM extraction and database fetch could be parallelized
- Currently sequential for simplicity (giftContext needed for restoration logic)
-
Early Returns:
- Clarification requests exit early (skip remaining phases)
- Invalid contexts return default immediately
Testing Scenarios
Critical Test Cases
-
Show More Preservation
- Query: "näita populaarseid raamatuid"
- Followup: "näita rohkem"
- ✓ Assert:
isPopularQuerypreserved, samecategoryHints
-
Multi-Author Resolution
- Setup: Show books from 2+ authors
- Query: "näita veel tema raamatuid"
- ✓ Assert: Uses
primaryAuthorfirst, clarifies if ambiguous
-
Context Switch Reset
- Query: "näita raamatuid" (see 5 books)
- Followup: "näita kinkekaarte"
- ✓ Assert: Exclude list reset, no books excluded from gift cards
-
Budget Refinement Preservation
- Query: "raamatuid alla 20 euro" (see 5 results)
- Followup: "odavamaid"
- ✓ Assert: ProductType preserved, budget adjusted downward
-
Author Pronoun with Single Memory
- Setup: Show 5 books from Tolkien
- Query: "näita veel tema raamatuid"
- ✓ Assert: Resolves to Tolkien immediately
-
Product Inquiry Detection
- Setup: Show product "Hobbit"
- Query: "Kas Hobbit sobib lapsele?"
- ✓ Assert: Intent =
product_inquiry, matched product attached
Monitoring and Debugging
Debug Logging
Enable with debug: true in orchestration options:
orchestrate({
request,
clientMessages,
convexClient,
debug: true // Verbose logging
});
Key Log Markers
EXTRACTION: Context extraction started
STORED CONTEXT FETCHED: Database retrieval complete
MERGED EXCLUSION LIST: Exclude IDs combined
CONTEXT PRESERVATION: Taxonomy preservation applied
CONVERSATION MEMORY BUILT: Authors/books indexed
🕵️ AUTHOR DETECTOR: Explicit author detected
🧹 CLEARING INVALID AUTHOR: Validation failed
AUTHOR MATCHING DEBUG: Reference resolution attempt
AUTHOR MATCH FOUND: Successful resolution
❓ AUTHOR CLARIFICATION NEEDED: Multiple candidates
PRODUCT INQUIRY DETECTED: Product reference matched
✂️ EXCLUDE LIST PRUNED: Size exceeded threshold
Telemetry Metrics
Track in production:
- Context Extraction Time (
intentTime) - Exclude List Size (warn if >30)
- Memory Resolution Success Rate
- LLM Fallback Trigger Rate
- Clarification Request Rate
- Context Switch Frequency
Future Enhancements
Potential Improvements
-
Semantic Author Matching
- Current: Exact name matching (with normalization)
- Future: Fuzzy matching, handle typos ("Tolkin" → "Tolkien")
-
Multi-Turn Disambiguation
- Current: Single clarification question
- Future: Conversation-style narrowing ("Did you mean the fantasy author or the historian?")
-
Context Decay
- Current: All stored context treated equally
- Future: Time-based decay (older context less relevant)
-
Proactive Context Preservation
- Current: Explicit preservation for known intents
- Future: ML model predicts which fields to preserve
-
Cross-Conversation Learning
- Current: Isolated per-conversation
- Future: User preferences persist across conversations
Conclusion
The GiftContext and Followup Context Building System represents a sophisticated orchestration layer that bridges natural language understanding, persistent storage, and conversational memory. Its multi-stage pipeline ensures:
Accurate Context Extraction: LLM-powered with fallback strategies
Intelligent Persistence: Selective preservation based on intent
Robust Reference Resolution: Multi-layer author/product matching
Smart Exclusion Management: Context-aware reset logic
Graceful Error Handling: Validation at every stage
The system's design prioritizes correctness over speed (with optimizations where safe), explicit over implicit (clear precedence rules), and debuggability (comprehensive logging).
Document Version: 1.0
Last Updated: 2025-11-16
Author: AI Analysis of kingisoovitaja Codebase
Related Documents:
architecture/show-more-behavior.mdtests/followup-system/COMPREHENSIVE-FOLLOWUP-TEST-SCENARIOS.mdCOMPREHENSIVE-FOLLOWUP-TEST-BREAKDOWN.md