Research Agents: Lets users define research agents that answer chat questions from SEC filings, uploaded documents, and the web, with every figure cited.
| 1 | import type { AgentSearch } from './agents.ts' |
| 2 | |
| 3 | // One search as the store is asked it: the agent's own hold, then what the model asked within it. |
| 4 | // The agent's documents are its scope; a company the model names narrows the search to it. Pure, so the |
| 5 | // merge is tested. |
| 6 | |
| 7 | /** What the model may ask of a search, beside its words. */ |
| 8 | export interface Asked { |
| 9 | ticker?: string |
| 10 | /** One document by its id, like doc:notes.pdf. */ |
| 11 | document?: string |
| 12 | form?: string |
| 13 | fiscal_year?: number |
| 14 | fiscal_period?: string |
| 15 | section?: string |
| 16 | filed_after?: string |
| 17 | filed_before?: string |
| 18 | } |
| 19 | |
| 20 | export type Merged = { args: Record<string, unknown> } | { refused: string } |
| 21 | |
| 22 | export function mergeSearch(query: string, pinned: AgentSearch, asked: Asked): Merged { |
| 23 | const args: Record<string, unknown> = { query } |
| 24 | if (pinned.documents.length) args['scope_documents'] = pinned.documents |
| 25 | if (pinned.origins.length) args['origins'] = pinned.origins |
| 26 | |
| 27 | const ticker = asked.ticker?.trim().toUpperCase() |
| 28 | if (ticker) args['tickers'] = [ticker] |
| 29 | const document = asked.document?.trim() |
| 30 | if (document) args['documents'] = [document] |
| 31 | const form = asked.form?.trim().toUpperCase() |
| 32 | if (form) { |
| 33 | if (pinned.forms.length && !pinned.forms.includes(form)) { |
| 34 | return { refused: `${form} is not one of the forms you read: ${pinned.forms.join(', ')}.` } |
| 35 | } |
| 36 | args['forms'] = [form] |
| 37 | } else if (pinned.forms.length) { |
| 38 | args['forms'] = pinned.forms |
| 39 | } |
| 40 | if (typeof asked.fiscal_year === 'number' && Number.isInteger(asked.fiscal_year)) |
| 41 | args['fiscal_years'] = [asked.fiscal_year] |
| 42 | if (asked.fiscal_period?.trim()) args['fiscal_period'] = asked.fiscal_period.trim().toUpperCase() |
| 43 | if (asked.section?.trim()) args['section'] = asked.section.trim() |
| 44 | const after = [pinned.since, asked.filed_after?.trim()] |
| 45 | .filter((d): d is string => Boolean(d)) |
| 46 | .sort() |
| 47 | .at(-1) |
| 48 | if (after) args['filed_after'] = after |
| 49 | if (asked.filed_before?.trim()) args['filed_before'] = asked.filed_before.trim() |
| 50 | return { args } |
| 51 | } |
| 52 | |
| 53 | /** What the model asked, read the forgiving way: a number given as text is a number. */ |
| 54 | export function askedOf(input: Record<string, unknown>): Asked { |
| 55 | const text = (key: string): string | undefined => |
| 56 | typeof input[key] === 'string' ? (input[key] as string) : undefined |
| 57 | const year = |
| 58 | typeof input['fiscal_year'] === 'number' ? input['fiscal_year'] : Number.parseInt(text('fiscal_year') ?? '', 10) |
| 59 | return { |
| 60 | ticker: text('ticker'), |
| 61 | document: text('document'), |
| 62 | form: text('form'), |
| 63 | fiscal_year: Number.isFinite(year) ? (year as number) : undefined, |
| 64 | fiscal_period: text('fiscal_period'), |
| 65 | section: text('section'), |
| 66 | filed_after: text('filed_after'), |
| 67 | filed_before: text('filed_before'), |
| 68 | } |
| 69 | } |