diff --git a/api/search.ts b/api/search.ts index d14cf1b..c2e9a6f 100644 --- a/api/search.ts +++ b/api/search.ts @@ -7,9 +7,7 @@ import { assertValidStellarConfig, } from '../src/lib/constants' import { consumePaymentPayload, decodePaymentReceipt } from '../src/lib/paymentIntegrity' -import { normalizeOrganicResults } from '../src/lib/serperNormalizer' -import { consumePaymentPayload } from '../src/lib/paymentIntegrity' -import { normalizeOrganicResults, normalizeQueryMetadata } from '../src/lib/serperNormalizer' +import { normalizeOrganicResults, normalizeQueryMetadata, normalizeAnswerBox, normalizeKnowledgeGraph } from '../src/lib/serperNormalizer' import { fetchSerper, CircuitOpenError } from '../src/lib/serperClient' import type { SearchResponse, ApiErrorResponse } from '../src/types/index.js' import { formatConfigurationError, readServerConfig } from '../src/lib/config' @@ -224,6 +222,8 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const results = normalizeOrganicResults(data) const queryMeta = normalizeQueryMetadata(data, cleanQ) + const answerBox = normalizeAnswerBox(data) + const knowledgeGraph = normalizeKnowledgeGraph(data) const responseBody: SearchResponse = { query: cleanQ, @@ -234,6 +234,8 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { currency: 'USDC', txHash, latencyMs, + ...(answerBox && { answerBox }), + ...(knowledgeGraph && { knowledgeGraph }), }; return res.json(responseBody); diff --git a/server/index.ts b/server/index.ts index 7504db2..d3805c7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -39,6 +39,8 @@ import { normalizeImageResults, normalizeNewsResults, normalizeQueryMetadata, + normalizeAnswerBox, + normalizeKnowledgeGraph, } from '../src/lib/serperNormalizer.js' import type { SearchResponse, @@ -505,6 +507,8 @@ app.get('/search', async (req: Request, res: Response) => { const results = normalizeOrganicResults(data) const queryMeta = normalizeQueryMetadata(data, cleanQ) + const answerBox = normalizeAnswerBox(data) + const knowledgeGraph = normalizeKnowledgeGraph(data) // The real tx hash comes from the X-PAYMENT-RESPONSE header set by the facilitator txHash = (req.headers['x-payment-response'] as string) || null @@ -553,6 +557,8 @@ app.get('/search', async (req: Request, res: Response) => { isCorrected: queryMeta.isCorrected, results, count: results.length, + answerBox, + knowledgeGraph, network: NETWORK, paidAmount: AMOUNT_USDC, currency: 'USDC', @@ -899,6 +905,8 @@ app.post('/search/batch', async (req: Request, res: Response) => { if (stats.latencies.length > 200) stats.latencies.shift() const results = normalizeOrganicResults(data) const queryMeta = normalizeQueryMetadata(data, q) + const answerBox = normalizeAnswerBox(data) + const knowledgeGraph = normalizeKnowledgeGraph(data) addRecentReceipt({ id: txHash || `${requestId}-${i}`, query: queryMeta.originalQuery, txHash, amount: AMOUNT_USDC, currency: 'USDC', network: NETWORK, timestamp: new Date().toISOString(), latencyMs, count: results.length }) const evt: BatchJsonlResultEvent = { v: 1, @@ -912,6 +920,8 @@ app.post('/search/batch', async (req: Request, res: Response) => { isCorrected: queryMeta.isCorrected, results, count: results.length, + answerBox, + knowledgeGraph, latencyMs, paidAmount: AMOUNT_USDC, currency: 'USDC', diff --git a/src/lib/serperNormalizer.ts b/src/lib/serperNormalizer.ts index 8f77b8e..4430df6 100644 --- a/src/lib/serperNormalizer.ts +++ b/src/lib/serperNormalizer.ts @@ -357,3 +357,144 @@ export function normalizeQueryMetadata( } } +/** + * Normalizes answer box data from Serper upstream search response. + * Answer boxes contain direct factual answers to queries like "what is X". + */ +export function normalizeAnswerBox(rawData: unknown): import('../types/index.js').AnswerBox | undefined { + if (!rawData || typeof rawData !== 'object') { + return undefined + } + + const payload = rawData as Record + const answerBoxRaw = payload.answerBox as Record | undefined + + if (!answerBoxRaw) { + return undefined + } + + // Extract title + const title = typeof answerBoxRaw.title === 'string' && answerBoxRaw.title.trim() + ? answerBoxRaw.title.trim() + : undefined + + // Extract answer + const answer = typeof answerBoxRaw.answer === 'string' && answerBoxRaw.answer.trim() + ? answerBoxRaw.answer.trim() + : undefined + + // Must have both title and answer + if (!title || !answer) { + return undefined + } + + // Extract source + const sourceRaw = answerBoxRaw.source as Record | undefined + if (!sourceRaw || !isValidHttpUrl(sourceRaw.link)) { + return undefined + } + + const sourceLink = (sourceRaw.link as string).trim() + const sourceTitle = typeof sourceRaw.title === 'string' && sourceRaw.title.trim() + ? sourceRaw.title.trim() + : 'Source' + const sourceDisplayLink = typeof sourceRaw.displayLink === 'string' && sourceRaw.displayLink.trim() + ? sourceRaw.displayLink.trim() + : undefined + + return { + title, + answer, + source: { + title: sourceTitle, + link: sourceLink, + displayLink: sourceDisplayLink, + }, + } +} + +/** + * Normalizes knowledge graph data from Serper upstream search response. + * Knowledge graphs contain structured data about entities (people, places, things). + */ +export function normalizeKnowledgeGraph(rawData: unknown): import('../types/index.js').KnowledgeGraph | undefined { + if (!rawData || typeof rawData !== 'object') { + return undefined + } + + const payload = rawData as Record + const knowledgeGraphRaw = payload.knowledgeGraph as Record | undefined + + if (!knowledgeGraphRaw) { + return undefined + } + + // Extract title (required) + const title = typeof knowledgeGraphRaw.title === 'string' && knowledgeGraphRaw.title.trim() + ? knowledgeGraphRaw.title.trim() + : undefined + + if (!title) { + return undefined + } + + // Extract optional fields + const type = typeof knowledgeGraphRaw.type === 'string' && knowledgeGraphRaw.type.trim() + ? knowledgeGraphRaw.type.trim() + : undefined + + const description = typeof knowledgeGraphRaw.description === 'string' && knowledgeGraphRaw.description.trim() + ? knowledgeGraphRaw.description.trim() + : undefined + + const imageUrl = isValidHttpUrl(knowledgeGraphRaw.imageUrl) + ? (knowledgeGraphRaw.imageUrl as string).trim() + : undefined + + const website = isValidHttpUrl(knowledgeGraphRaw.website) + ? (knowledgeGraphRaw.website as string).trim() + : undefined + + // Extract attributes array + const attributes: import('../types/index.js').KnowledgeGraphAttribute[] = [] + if (Array.isArray(knowledgeGraphRaw.attributes)) { + for (const attr of knowledgeGraphRaw.attributes) { + if (!attr || typeof attr !== 'object') continue + const attrRow = attr as Record + const attrName = typeof attrRow.name === 'string' && attrRow.name.trim() + ? attrRow.name.trim() + : undefined + const attrValue = typeof attrRow.value === 'string' && attrRow.value.trim() + ? attrRow.value.trim() + : undefined + if (attrName && attrValue) { + attributes.push({ name: attrName, value: attrValue }) + } + } + } + + // Extract links array (related entities) + const links: import('../types/index.js').KnowledgeGraphLink[] = [] + if (Array.isArray(knowledgeGraphRaw.links)) { + for (const link of knowledgeGraphRaw.links) { + if (!link || typeof link !== 'object') continue + const linkRow = link as Record + if (!isValidHttpUrl(linkRow.link)) continue + const linkTitle = typeof linkRow.title === 'string' && linkRow.title.trim() + ? linkRow.title.trim() + : 'Link' + links.push({ title: linkTitle, link: (linkRow.link as string).trim() }) + } + } + + return { + title, + type, + description, + imageUrl, + website, + attributes: attributes.length > 0 ? attributes : undefined, + links: links.length > 0 ? links : undefined, + } +} + diff --git a/src/types/index.ts b/src/types/index.ts index 2b47006..a0d7b24 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,9 +1,220 @@ export type { WalletState, StellarTransaction } from '../hooks/useFreighterWallet' -export type { SearchResult, SearchSession } from '../hooks/useSearch' +export type { SearchResult, SearchSession, SearchReceipt } from '../hooks/useSearch' +// ─── Answer Box ──────────────────────────────────────────────────────────── +/** Direct factual answer to a query (e.g., "what is X") */ +export interface AnswerBoxSource { + title: string + link: string + displayLink?: string +} + +export interface AnswerBox { + title: string + answer: string + source: AnswerBoxSource +} + +// ─── Knowledge Graph ─────────────────────────────────────────────────────── +/** Structured data about entities (people, places, things) */ +export interface KnowledgeGraphAttribute { + name: string + value: string +} + +export interface KnowledgeGraphLink { + title: string + link: string +} + +export interface KnowledgeGraph { + title: string + type?: string + description?: string + attributes?: KnowledgeGraphAttribute[] + imageUrl?: string + website?: string + links?: KnowledgeGraphLink[] +} + +// ─── Search Response ─────────────────────────────────────────────────────── +export interface SearchResponse { + query: string + originalQuery?: string + executedQuery?: string + suggestedQuery?: string + isCorrected?: boolean + results: SearchResult[] + count: number + answerBox?: AnswerBox + knowledgeGraph?: KnowledgeGraph + network: string + paidAmount: string + currency: string + txHash?: string | null + latencyMs: number + suggestions?: string[] +} + +// Alias for compatibility +export type WebSearchResponse = SearchResponse + +// ─── Image Search Response ───────────────────────────────────────────────── +export interface ImageResult { + id: string + title: string + imageUrl: string + thumbnailUrl: string + sourceUrl: string + source: string + width?: number + height?: number +} + +export interface ImageSearchResponse { + query: string + results: ImageResult[] + count: number + network: string + paidAmount: string + currency: string + txHash?: string | null + latencyMs: number +} + +// Alias for compatibility +export type ImageResponse = ImageSearchResponse + +// ─── News Search Response ────────────────────────────────────────────────── +export interface NewsResult { + id: string + title: string + url: string + snippet: string + source: string + publishedAt?: string + imageUrl?: string +} + +export interface NewsSearchResponse { + query: string + results: NewsResult[] + count: number + network: string + paidAmount: string + currency: string + txHash?: string | null + latencyMs: number +} + +// Alias for compatibility +export type NewsResponse = NewsSearchResponse + +// ─── API Error Response ──────────────────────────────────────────────────── +export interface ApiErrorResponse { + error: string + credit?: CreditReceipt +} + +// Alias for compatibility +export type ErrorResponse = ApiErrorResponse + +// ─── Credit Receipt ─────────────────────────────────────────────────────── +export interface CreditReceipt { + id: string + amount: string + reason: string +} + +// ─── Search Receipt ─────────────────────────────────────────────────────── +export interface SearchReceipt { + txHash: string + query: string + amount: string + timestamp: string + network: string +} + +// ─── API Stats ───────────────────────────────────────────────────────────── export interface ApiStat { totalQueries: number totalUsdcSettled: string avgLatencyMs: number uptime: string } + +// ─── Batch JSONL Event Types ─────────────────────────────────────────────── +export interface BatchJsonlEvent { + v: 1 + type: string + requestId: string +} + +export interface BatchJsonlQuoteEvent extends BatchJsonlEvent { + type: 'quote' + query: string + totalQueries: number + totalAmount: string + currency: string + network: string +} + +export interface BatchJsonlSettlementEvent extends BatchJsonlEvent { + type: 'settlement' + paymentId: string + txHash: string | null + verified: boolean + settledAt: string +} + +export interface BatchJsonlResultEvent extends BatchJsonlEvent { + type: 'result' + index: number + query: string + originalQuery?: string + executedQuery?: string + suggestedQuery?: string + isCorrected?: boolean + results: SearchResult[] + count: number + answerBox?: AnswerBox + knowledgeGraph?: KnowledgeGraph + latencyMs: number + paidAmount: string + currency: string + network: string + txHash?: string | null +} + +export interface BatchJsonlErrorEvent extends BatchJsonlEvent { + type: 'error' + index?: number + query?: string + error: string + code: string +} + +export interface BatchJsonlDoneEvent extends BatchJsonlEvent { + type: 'done' + succeeded: number + failed: number + totalUsdcSpent: string + aggregateLatencyMs: number + completedAt: string +} + +// ─── Job Types ───────────────────────────────────────────────────────────── +export interface SearchJob { + id: string + query: string + statusUrl: string + status: JobStatus + createdAt: string + completedAt?: string + paymentId?: string + txHash?: string | null + results?: SearchResult[] + error?: string +} + +export type JobStatus = 'queued' | 'processing' | 'completed' | 'failed'