Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions api/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
normalizeImageResults,
normalizeNewsResults,
normalizeQueryMetadata,
normalizeAnswerBox,
normalizeKnowledgeGraph,
} from '../src/lib/serperNormalizer.js'
import type {
SearchResponse,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand Down
141 changes: 141 additions & 0 deletions src/lib/serperNormalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
const answerBoxRaw = payload.answerBox as Record<string, unknown> | 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<string, unknown> | 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<string, unknown>
const knowledgeGraphRaw = payload.knowledgeGraph as Record<string, unknown> | 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<string, unknown>
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<string, unknown>
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,
}
}

Loading
Loading