mirror of
https://github.com/kennethnym/aris.git
synced 2026-06-13 11:01:18 +01:00
feat: add exa web search source (#124)
This commit is contained in:
14
packages/freya-source-web-search/package.json
Normal file
14
packages/freya-source-web-search/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@freya/source-web-search",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@freya/core": "workspace:*",
|
||||
"arktype": "^2.1.0"
|
||||
}
|
||||
}
|
||||
97
packages/freya-source-web-search/src/exa-client.test.ts
Normal file
97
packages/freya-source-web-search/src/exa-client.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { ExaSearchClient } from "./exa-client.ts"
|
||||
|
||||
describe("ExaSearchClient", () => {
|
||||
test("maps request and response", async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let requestUrl = ""
|
||||
let requestHeaders: Headers
|
||||
let requestBody: unknown
|
||||
|
||||
globalThis.fetch = (async (
|
||||
input: Parameters<typeof fetch>[0],
|
||||
init?: Parameters<typeof fetch>[1],
|
||||
) => {
|
||||
requestUrl = String(input)
|
||||
requestHeaders = new Headers(init?.headers)
|
||||
requestBody = JSON.parse(String(init?.body))
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
requestId: "exa-request-1",
|
||||
results: [
|
||||
{
|
||||
id: "result-1",
|
||||
url: "https://example.com",
|
||||
title: "Example",
|
||||
publishedDate: "2026-01-01T00:00:00.000Z",
|
||||
author: "Author",
|
||||
image: "https://example.com/image.png",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
highlights: ["A useful passage"],
|
||||
highlightScores: [0.7],
|
||||
summary: "Summary",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
const client = new ExaSearchClient("api-key", "https://api.example.test")
|
||||
const result = await client.search({
|
||||
query: "test query",
|
||||
numResults: 3,
|
||||
includeDomains: ["example.com"],
|
||||
highlights: false,
|
||||
})
|
||||
|
||||
expect(requestUrl).toBe("https://api.example.test/search")
|
||||
expect(requestHeaders!.get("x-api-key")).toBe("api-key")
|
||||
expect(requestBody).toEqual({
|
||||
query: "test query",
|
||||
numResults: 3,
|
||||
includeDomains: ["example.com"],
|
||||
contents: { highlights: false },
|
||||
})
|
||||
expect(result).toEqual({
|
||||
query: "test query",
|
||||
requestId: "exa-request-1",
|
||||
results: [
|
||||
{
|
||||
id: "result-1",
|
||||
url: "https://example.com",
|
||||
title: "Example",
|
||||
publishedDate: "2026-01-01T00:00:00.000Z",
|
||||
author: "Author",
|
||||
image: "https://example.com/image.png",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
text: null,
|
||||
highlights: ["A useful passage"],
|
||||
highlightScores: [0.7],
|
||||
summary: "Summary",
|
||||
},
|
||||
],
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("throws on non-ok response", async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("nope", { status: 401, statusText: "Unauthorized" })) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
const client = new ExaSearchClient("bad-key")
|
||||
await expect(client.search({ query: "test" })).rejects.toThrow(
|
||||
"Exa API error: 401 Unauthorized",
|
||||
)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
})
|
||||
124
packages/freya-source-web-search/src/exa-client.ts
Normal file
124
packages/freya-source-web-search/src/exa-client.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { type } from "arktype"
|
||||
|
||||
import type {
|
||||
WebSearchClient,
|
||||
WebSearchRequest,
|
||||
WebSearchResponse,
|
||||
WebSearchResult,
|
||||
} from "./types.ts"
|
||||
|
||||
const EXA_API_BASE = "https://api.exa.ai"
|
||||
const DEFAULT_NUM_RESULTS = 10
|
||||
|
||||
const ExaSearchResult = type({
|
||||
id: "string",
|
||||
url: "string",
|
||||
"title?": "string | null",
|
||||
"publishedDate?": "string | null",
|
||||
"author?": "string | null",
|
||||
"image?": "string | null",
|
||||
"favicon?": "string | null",
|
||||
"text?": "string | null",
|
||||
"highlights?": "string[]",
|
||||
"highlightScores?": "number[]",
|
||||
"summary?": "string | null",
|
||||
})
|
||||
|
||||
const ExaSearchResponse = type({
|
||||
results: ExaSearchResult.array(),
|
||||
"requestId?": "string",
|
||||
})
|
||||
|
||||
interface ExaSearchBody {
|
||||
query: string
|
||||
numResults?: number
|
||||
includeDomains?: string[]
|
||||
excludeDomains?: string[]
|
||||
startCrawlDate?: string
|
||||
endCrawlDate?: string
|
||||
startPublishedDate?: string
|
||||
endPublishedDate?: string
|
||||
type?: WebSearchRequest["type"]
|
||||
category?: string
|
||||
userLocation?: string
|
||||
moderation?: boolean
|
||||
contents: {
|
||||
highlights: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export class ExaSearchClient implements WebSearchClient {
|
||||
private readonly apiKey: string
|
||||
private readonly baseUrl: string
|
||||
|
||||
constructor(apiKey: string, baseUrl = EXA_API_BASE) {
|
||||
this.apiKey = apiKey
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
async search(request: WebSearchRequest): Promise<WebSearchResponse> {
|
||||
const response = await fetch(new URL("/search", this.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": this.apiKey,
|
||||
},
|
||||
body: JSON.stringify(toExaSearchBody(request)),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Exa API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const parsed = ExaSearchResponse(data)
|
||||
if (parsed instanceof type.errors) {
|
||||
throw new Error(`Invalid Exa API response: ${parsed.summary}`)
|
||||
}
|
||||
|
||||
return {
|
||||
query: request.query,
|
||||
requestId: parsed.requestId ?? null,
|
||||
results: parsed.results.map(toWebSearchResult),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toExaSearchBody(request: WebSearchRequest): ExaSearchBody {
|
||||
const body: ExaSearchBody = {
|
||||
query: request.query,
|
||||
numResults: request.numResults ?? DEFAULT_NUM_RESULTS,
|
||||
contents: {
|
||||
highlights: request.highlights ?? true,
|
||||
},
|
||||
}
|
||||
|
||||
if (request.includeDomains) body.includeDomains = request.includeDomains
|
||||
if (request.excludeDomains) body.excludeDomains = request.excludeDomains
|
||||
if (request.startCrawlDate) body.startCrawlDate = request.startCrawlDate
|
||||
if (request.endCrawlDate) body.endCrawlDate = request.endCrawlDate
|
||||
if (request.startPublishedDate) body.startPublishedDate = request.startPublishedDate
|
||||
if (request.endPublishedDate) body.endPublishedDate = request.endPublishedDate
|
||||
if (request.type) body.type = request.type
|
||||
if (request.category) body.category = request.category
|
||||
if (request.userLocation) body.userLocation = request.userLocation
|
||||
if (request.moderation !== undefined) body.moderation = request.moderation
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
function toWebSearchResult(result: typeof ExaSearchResult.infer): WebSearchResult {
|
||||
return {
|
||||
id: result.id,
|
||||
url: result.url,
|
||||
title: result.title ?? null,
|
||||
publishedDate: result.publishedDate ?? null,
|
||||
author: result.author ?? null,
|
||||
image: result.image ?? null,
|
||||
favicon: result.favicon ?? null,
|
||||
text: result.text ?? null,
|
||||
highlights: result.highlights ?? [],
|
||||
highlightScores: result.highlightScores ?? [],
|
||||
summary: result.summary ?? null,
|
||||
}
|
||||
}
|
||||
11
packages/freya-source-web-search/src/index.ts
Normal file
11
packages/freya-source-web-search/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { ExaSearchClient } from "./exa-client.ts"
|
||||
export { WebSearchSource } from "./web-search-source.ts"
|
||||
export {
|
||||
WebSearchAction,
|
||||
WebSearchType,
|
||||
type WebSearchClient,
|
||||
type WebSearchRequest,
|
||||
type WebSearchResponse,
|
||||
type WebSearchResult,
|
||||
type WebSearchSourceOptions,
|
||||
} from "./types.ts"
|
||||
61
packages/freya-source-web-search/src/types.ts
Normal file
61
packages/freya-source-web-search/src/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export const WebSearchAction = {
|
||||
Search: "search",
|
||||
} as const
|
||||
|
||||
export type WebSearchAction = (typeof WebSearchAction)[keyof typeof WebSearchAction]
|
||||
|
||||
export const WebSearchType = {
|
||||
Instant: "instant",
|
||||
Fast: "fast",
|
||||
Auto: "auto",
|
||||
DeepLite: "deep-lite",
|
||||
Deep: "deep",
|
||||
DeepReasoning: "deep-reasoning",
|
||||
} as const
|
||||
|
||||
export type WebSearchType = (typeof WebSearchType)[keyof typeof WebSearchType]
|
||||
|
||||
export interface WebSearchRequest {
|
||||
query: string
|
||||
numResults?: number
|
||||
includeDomains?: string[]
|
||||
excludeDomains?: string[]
|
||||
startCrawlDate?: string
|
||||
endCrawlDate?: string
|
||||
startPublishedDate?: string
|
||||
endPublishedDate?: string
|
||||
type?: WebSearchType
|
||||
category?: string
|
||||
userLocation?: string
|
||||
moderation?: boolean
|
||||
highlights?: boolean
|
||||
}
|
||||
|
||||
export interface WebSearchResult extends Record<string, unknown> {
|
||||
id: string
|
||||
url: string
|
||||
title: string | null
|
||||
publishedDate: string | null
|
||||
author: string | null
|
||||
image: string | null
|
||||
favicon: string | null
|
||||
text: string | null
|
||||
highlights: string[]
|
||||
highlightScores: number[]
|
||||
summary: string | null
|
||||
}
|
||||
|
||||
export interface WebSearchResponse extends Record<string, unknown> {
|
||||
query: string
|
||||
requestId: string | null
|
||||
results: WebSearchResult[]
|
||||
}
|
||||
|
||||
export interface WebSearchClient {
|
||||
search(request: WebSearchRequest): Promise<WebSearchResponse>
|
||||
}
|
||||
|
||||
export interface WebSearchSourceOptions {
|
||||
apiKey?: string
|
||||
client?: WebSearchClient
|
||||
}
|
||||
123
packages/freya-source-web-search/src/web-search-source.test.ts
Normal file
123
packages/freya-source-web-search/src/web-search-source.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Context } from "@freya/core"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { WebSearchClient, WebSearchRequest, WebSearchResponse } from "./types.ts"
|
||||
|
||||
import { WebSearchAction } from "./types.ts"
|
||||
import { WebSearchSource } from "./web-search-source.ts"
|
||||
|
||||
class RecordingSearchClient implements WebSearchClient {
|
||||
requests: WebSearchRequest[] = []
|
||||
|
||||
async search(request: WebSearchRequest): Promise<WebSearchResponse> {
|
||||
this.requests.push(request)
|
||||
return {
|
||||
query: request.query,
|
||||
requestId: "request-1",
|
||||
results: [
|
||||
{
|
||||
id: "https://example.com/a",
|
||||
url: "https://example.com/a",
|
||||
title: "Example result",
|
||||
publishedDate: "2026-01-01T00:00:00.000Z",
|
||||
author: "Example Author",
|
||||
image: null,
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
text: null,
|
||||
highlights: ["Relevant excerpt"],
|
||||
highlightScores: [0.8],
|
||||
summary: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("WebSearchSource", () => {
|
||||
test("has correct id", () => {
|
||||
const source = new WebSearchSource({ client: new RecordingSearchClient() })
|
||||
|
||||
expect(source.id).toBe("freya.web-search")
|
||||
})
|
||||
|
||||
test("does not provide context or feed items", async () => {
|
||||
const source = new WebSearchSource({ client: new RecordingSearchClient() })
|
||||
|
||||
expect("fetchItems" in source).toBe(false)
|
||||
expect(await source.fetchContext(new Context())).toBeNull()
|
||||
})
|
||||
|
||||
test("lists search action", async () => {
|
||||
const source = new WebSearchSource({ client: new RecordingSearchClient() })
|
||||
const actions = await source.listActions()
|
||||
|
||||
expect(actions[WebSearchAction.Search]).toBeDefined()
|
||||
expect(actions[WebSearchAction.Search]!.id).toBe(WebSearchAction.Search)
|
||||
expect(actions[WebSearchAction.Search]!.input).toBeDefined()
|
||||
})
|
||||
|
||||
test("executes search action with normalized params", async () => {
|
||||
const client = new RecordingSearchClient()
|
||||
const source = new WebSearchSource({ client })
|
||||
|
||||
const result = await source.executeAction(WebSearchAction.Search, {
|
||||
query: " latest personal assistant research ",
|
||||
includeDomains: ["exa.ai"],
|
||||
type: "fast",
|
||||
userLocation: "gb",
|
||||
moderation: true,
|
||||
})
|
||||
|
||||
expect(result.requestId).toBe("request-1")
|
||||
expect(result.results).toHaveLength(1)
|
||||
expect(client.requests).toEqual([
|
||||
{
|
||||
query: "latest personal assistant research",
|
||||
numResults: 10,
|
||||
includeDomains: ["exa.ai"],
|
||||
type: "fast",
|
||||
userLocation: "GB",
|
||||
moderation: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("allows per-call numResults override", async () => {
|
||||
const client = new RecordingSearchClient()
|
||||
const source = new WebSearchSource({ client })
|
||||
|
||||
await source.executeAction(WebSearchAction.Search, {
|
||||
query: "freya",
|
||||
numResults: 2,
|
||||
})
|
||||
|
||||
expect(client.requests[0]!.numResults).toBe(2)
|
||||
})
|
||||
|
||||
test("throws for invalid action", async () => {
|
||||
const source = new WebSearchSource({ client: new RecordingSearchClient() })
|
||||
|
||||
await expect(source.executeAction("missing", {})).rejects.toThrow("Unknown action")
|
||||
})
|
||||
|
||||
test("throws for invalid search params", async () => {
|
||||
const source = new WebSearchSource({ client: new RecordingSearchClient() })
|
||||
|
||||
await expect(
|
||||
source.executeAction(WebSearchAction.Search, {
|
||||
query: "",
|
||||
}),
|
||||
).rejects.toThrow("query must not be empty")
|
||||
|
||||
await expect(
|
||||
source.executeAction(WebSearchAction.Search, {
|
||||
query: "x",
|
||||
numResults: 101,
|
||||
}),
|
||||
).rejects.toThrow("numResults must be an integer")
|
||||
})
|
||||
|
||||
test("throws if neither client nor apiKey is provided", () => {
|
||||
expect(() => new WebSearchSource({})).toThrow("Either client or apiKey must be provided")
|
||||
})
|
||||
})
|
||||
121
packages/freya-source-web-search/src/web-search-source.ts
Normal file
121
packages/freya-source-web-search/src/web-search-source.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { ActionDefinition, Context, ContextEntry, FeedSource } from "@freya/core"
|
||||
|
||||
import { UnknownActionError } from "@freya/core"
|
||||
import { type } from "arktype"
|
||||
|
||||
import type {
|
||||
WebSearchClient,
|
||||
WebSearchRequest,
|
||||
WebSearchResponse,
|
||||
WebSearchSourceOptions,
|
||||
} from "./types.ts"
|
||||
|
||||
import { ExaSearchClient } from "./exa-client.ts"
|
||||
import { WebSearchAction, WebSearchType } from "./types.ts"
|
||||
|
||||
const DEFAULT_NUM_RESULTS = 10
|
||||
const MIN_NUM_RESULTS = 1
|
||||
const MAX_NUM_RESULTS = 100
|
||||
|
||||
const SearchInput = type({
|
||||
"+": "reject",
|
||||
query: "string",
|
||||
"numResults?": "number",
|
||||
"includeDomains?": "string[]",
|
||||
"excludeDomains?": "string[]",
|
||||
"startCrawlDate?": "string.date.iso",
|
||||
"endCrawlDate?": "string.date.iso",
|
||||
"startPublishedDate?": "string.date.iso",
|
||||
"endPublishedDate?": "string.date.iso",
|
||||
"type?": "'instant' | 'fast' | 'auto' | 'deep-lite' | 'deep' | 'deep-reasoning'",
|
||||
"category?": "string",
|
||||
"userLocation?": "string",
|
||||
"moderation?": "boolean",
|
||||
"highlights?": "boolean",
|
||||
})
|
||||
|
||||
/**
|
||||
* Action-only FeedSource for web search through Exa.
|
||||
*
|
||||
* It intentionally does not produce feed items. Consumers call the `search`
|
||||
* action and receive structured web results.
|
||||
*/
|
||||
export class WebSearchSource implements FeedSource {
|
||||
readonly id = "freya.web-search"
|
||||
|
||||
private readonly client: WebSearchClient
|
||||
|
||||
constructor(options: WebSearchSourceOptions) {
|
||||
if (!options.client && !options.apiKey) {
|
||||
throw new Error("Either client or apiKey must be provided")
|
||||
}
|
||||
this.client = options.client ?? new ExaSearchClient(options.apiKey!)
|
||||
}
|
||||
|
||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||
return {
|
||||
[WebSearchAction.Search]: {
|
||||
id: WebSearchAction.Search,
|
||||
description: "Search the web and return structured results",
|
||||
input: SearchInput,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async executeAction(actionId: string, params: unknown): Promise<WebSearchResponse> {
|
||||
switch (actionId) {
|
||||
case WebSearchAction.Search:
|
||||
return this.client.search(this.parseSearchInput(params))
|
||||
default:
|
||||
throw new UnknownActionError(actionId)
|
||||
}
|
||||
}
|
||||
|
||||
async fetchContext(_context: Context): Promise<readonly ContextEntry[] | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
private parseSearchInput(params: unknown): WebSearchRequest {
|
||||
const parsed = SearchInput(params)
|
||||
if (parsed instanceof type.errors) {
|
||||
throw new Error(parsed.summary)
|
||||
}
|
||||
|
||||
const query = parsed.query.trim()
|
||||
if (!query) {
|
||||
throw new Error("query must not be empty")
|
||||
}
|
||||
|
||||
const numResults = parsed.numResults ?? DEFAULT_NUM_RESULTS
|
||||
if (
|
||||
!Number.isInteger(numResults) ||
|
||||
numResults < MIN_NUM_RESULTS ||
|
||||
numResults > MAX_NUM_RESULTS
|
||||
) {
|
||||
throw new Error(`numResults must be an integer from ${MIN_NUM_RESULTS} to ${MAX_NUM_RESULTS}`)
|
||||
}
|
||||
|
||||
if (parsed.userLocation && !/^[A-Za-z]{2}$/.test(parsed.userLocation)) {
|
||||
throw new Error("userLocation must be a two-letter ISO country code")
|
||||
}
|
||||
|
||||
const request: WebSearchRequest = {
|
||||
query,
|
||||
numResults,
|
||||
}
|
||||
|
||||
if (parsed.includeDomains) request.includeDomains = parsed.includeDomains
|
||||
if (parsed.excludeDomains) request.excludeDomains = parsed.excludeDomains
|
||||
if (parsed.startCrawlDate) request.startCrawlDate = parsed.startCrawlDate
|
||||
if (parsed.endCrawlDate) request.endCrawlDate = parsed.endCrawlDate
|
||||
if (parsed.startPublishedDate) request.startPublishedDate = parsed.startPublishedDate
|
||||
if (parsed.endPublishedDate) request.endPublishedDate = parsed.endPublishedDate
|
||||
if (parsed.type) request.type = parsed.type as WebSearchType
|
||||
if (parsed.category) request.category = parsed.category
|
||||
if (parsed.userLocation) request.userLocation = parsed.userLocation.toUpperCase()
|
||||
if (parsed.moderation !== undefined) request.moderation = parsed.moderation
|
||||
if (parsed.highlights !== undefined) request.highlights = parsed.highlights
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user