import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; import { config } from '../config'; import { cache } from '../services/cache'; import { vespaRequest } from '../services/vespa-https'; const backendApi = new Hono(); // Search request schema const searchQuerySchema = z.object({ query: z.string().min(1).max(500), ranking: z.enum(['hybrid', 'colpali', 'bm25']).optional().default('hybrid'), }); // Main search endpoint - /fetch_results backendApi.get('/fetch_results', async (c) => { try { const query = c.req.query('query'); const ranking = c.req.query('ranking') || 'hybrid'; const validation = searchQuerySchema.safeParse({ query, ranking }); if (!validation.success) { return c.json({ error: 'Invalid request', details: validation.error.issues }, 400); } const validatedData = validation.data; // Check cache const cacheKey = `search:${validatedData.query}:${validatedData.ranking}`; const cachedResult = cache.get(cacheKey); if (cachedResult) { c.header('X-Cache', 'HIT'); return c.json(cachedResult); } // Build YQL query based on ranking let yql = ''; let searchParams: any = { query: validatedData.query, hits: '20' }; switch (validatedData.ranking) { case 'colpali': // Use retrieval-and-rerank profile for ColPali yql = `select * from linqto where userQuery() limit 20`; searchParams.ranking = 'retrieval-and-rerank'; break; case 'bm25': yql = `select * from linqto where userQuery() limit 20`; searchParams.ranking = 'default'; break; case 'hybrid': default: yql = `select * from linqto where userQuery() limit 20`; searchParams.ranking = 'default'; break; } // For ColPali ranking, we need embeddings let body: any = {}; let useNearestNeighbor = false; if (validatedData.ranking === 'colpali') { try { // Call embedding API to get query embeddings const embeddingResponse = await fetch( `http://localhost:7861/embed_query?query=${encodeURIComponent(validatedData.query)}` ); if (embeddingResponse.ok) { const embeddingData = await embeddingResponse.json(); // Create nearestNeighbor query string const numTokens = Object.keys(embeddingData.embeddings.binary).length; const maxTokens = Math.min(numTokens, 20); // Limit to 20 tokens to avoid timeouts const nnClauses = []; // Add individual rq tensors for nearestNeighbor for (let i = 0; i < maxTokens; i++) { body[`input.query(rq${i})`] = embeddingData.embeddings.binary[i.toString()]; nnClauses.push(`({targetHits:10}nearestNeighbor(embedding,rq${i}))`); } // Update YQL for nearestNeighbor search if (nnClauses.length > 0) { yql = `select * from linqto where ${nnClauses.join(' OR ')} limit 20`; useNearestNeighbor = true; } // Add qt and qtb for ranking body["input.query(qt)"] = embeddingData.embeddings.float; body["input.query(qtb)"] = embeddingData.embeddings.binary; body["presentation.timing"] = true; } else { // Fall back to text-only search searchParams.ranking = 'default'; } } catch (error) { console.log('Embedding API not available, falling back to text search'); searchParams.ranking = 'default'; } } // Query Vespa directly const searchUrl = `${config.vespaAppUrl}/search/`; const urlSearchParams = new URLSearchParams({ yql, ...searchParams }); // Use ranking.profile for Vespa instead of ranking if (searchParams.ranking) { urlSearchParams.delete('ranking'); urlSearchParams.set('ranking.profile', searchParams.ranking); } const startTime = Date.now(); let requestOptions: any = {}; // Only use POST with body if we have embeddings if (Object.keys(body).length > 0) { requestOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(body) }; } else { requestOptions = { method: 'GET' }; } console.log('Vespa query URL:', `${searchUrl}?${urlSearchParams}`); console.log('Request options:', requestOptions); const response = await vespaRequest(`${searchUrl}?${urlSearchParams}`, requestOptions); if (!response.ok && response.status !== 504) { const errorText = await response.text(); console.error('Vespa error:', errorText); throw new Error(`Vespa returned ${response.status}: ${errorText}`); } const data = await response.json(); const searchTime = (Date.now() - startTime) / 1000; // Convert to seconds // Generate query_id for sim_map compatibility const queryId = uuidv4(); // Transform to match expected format if (data.root && data.root.children) { data.root.children.forEach((hit: any, idx: number) => { if (!hit.fields) hit.fields = {}; // Add sim_map identifier for compatibility hit.fields.sim_map = `${queryId}_${idx}`; }); } // Add timing information data.timing = { searchtime: searchTime }; // Cache the result cache.set(cacheKey, data); c.header('X-Cache', 'MISS'); return c.json(data); } catch (error) { console.error('Search error:', error); return c.json({ error: 'Search failed', message: error instanceof Error ? error.message : 'Unknown error' }, 500); } }); // Full image endpoint - /full_image backendApi.get('/full_image', async (c) => { try { const docId = c.req.query('doc_id'); // Note: backend expects doc_id, not docId if (!docId) { return c.json({ error: 'doc_id is required' }, 400); } // Check cache const cacheKey = `fullimage:${docId}`; const cachedImage = cache.get<{ base64_image: string }>(cacheKey); if (cachedImage) { c.header('X-Cache', 'HIT'); return c.json(cachedImage); } // Query Vespa for the document const searchUrl = `${config.vespaAppUrl}/search/`; const searchParams = new URLSearchParams({ yql: `select * from linqto where id contains "${docId}"`, hits: '1' }); const response = await vespaRequest(`${searchUrl}?${searchParams}`); if (!response.ok) { throw new Error(`Vespa returned ${response.status}`); } const data = await response.json(); if (data.root?.children?.[0]?.fields) { const fields = data.root.children[0].fields; const base64Image = fields.full_image || fields.image; if (base64Image) { const result = { base64_image: base64Image }; cache.set(cacheKey, result, 86400); // 24 hours c.header('X-Cache', 'MISS'); return c.json(result); } } return c.json({ error: 'Image not found' }, 404); } catch (error) { console.error('Full image error:', error); return c.json({ error: 'Failed to fetch image', message: error instanceof Error ? error.message : 'Unknown error' }, 500); } }); // Query suggestions endpoint - /suggestions backendApi.get('/suggestions', async (c) => { try { const query = c.req.query('query') || ''; // Static suggestions for now const staticSuggestions = [ 'linqto bankruptcy', 'linqto filing date', 'linqto creditors', 'linqto assets', 'linqto liabilities', 'linqto chapter 11', 'linqto docket', 'linqto plan', 'linqto disclosure statement', 'linqto claims', ]; if (!query) { return c.json({ suggestions: staticSuggestions.slice(0, 5) }); } const lowerQuery = query.toLowerCase(); const filtered = staticSuggestions .filter(s => s.startsWith(lowerQuery)) .slice(0, 5); return c.json({ suggestions: filtered }); } catch (error) { console.error('Suggestions error:', error); return c.json({ error: 'Failed to fetch suggestions', suggestions: [] }, 500); } }); // Similarity maps endpoint - /get_sim_map backendApi.get('/get_sim_map', async (c) => { try { const queryId = c.req.query('query_id'); // Note: backend expects query_id const idx = c.req.query('idx'); const token = c.req.query('token'); const tokenIdx = c.req.query('token_idx'); // Note: backend expects token_idx if (!queryId || !idx || !token || !tokenIdx) { return c.json({ error: 'Missing required parameters' }, 400); } // Return placeholder HTML const html = `
Query: ${token}
Document: ${idx}
Similarity map generation requires the ColPali model. This is a placeholder for the demo.