|
| 1 | +/** |
| 2 | + * Widget Builder — generates widgets.json for the OpenBB Workspace frontend. |
| 3 | + * |
| 4 | + * Maps to: openbb_platform/extensions/platform_api/openbb_platform_api/utils/widgets.py |
| 5 | + * |
| 6 | + * The Python version parses the OpenAPI spec (auto-generated from Pydantic models). |
| 7 | + * In TypeScript we skip OpenAPI and directly walk: |
| 8 | + * - Router command map → routes, model names, descriptions |
| 9 | + * - Registry → which providers support each model |
| 10 | + * - Schema registry → Zod schemas for query params and data columns |
| 11 | + */ |
| 12 | + |
| 13 | +import type { Router } from '../app/router.js' |
| 14 | +import type { Registry } from '../provider/registry.js' |
| 15 | +import { SCHEMA_REGISTRY } from './schema-registry.js' |
| 16 | +import { zodSchemaToWidgetParams, zodSchemaToColumnDefs } from './zod-to-widget.js' |
| 17 | +import type { WidgetParam } from './zod-to-widget.js' |
| 18 | + |
| 19 | +/** Provider name display mapping (matches Python's provider_map in widgets.py). */ |
| 20 | +const PROVIDER_DISPLAY: Record<string, string> = { |
| 21 | + fmp: 'FMP', |
| 22 | + yfinance: 'yFinance', |
| 23 | + fred: 'FRED', |
| 24 | + sec: 'SEC', |
| 25 | + tmx: 'TMX', |
| 26 | + ecb: 'ECB', |
| 27 | + econdb: 'EconDB', |
| 28 | + eia: 'EIA', |
| 29 | + oecd: 'OECD', |
| 30 | + finra: 'FINRA', |
| 31 | + imf: 'IMF', |
| 32 | + bls: 'BLS', |
| 33 | + cftc: 'CFTC', |
| 34 | + wsj: 'WSJ', |
| 35 | + deribit: 'Deribit', |
| 36 | + cboe: 'CBOE', |
| 37 | + multpl: 'Multpl', |
| 38 | + intrinio: 'Intrinio', |
| 39 | + federal_reserve: 'Federal Reserve', |
| 40 | + stub: 'Stub', |
| 41 | +} |
| 42 | + |
| 43 | +// Strings that should always be uppercased in widget names |
| 44 | +const TO_CAPS = new Set([ |
| 45 | + 'pe', 'pb', 'ps', 'eps', 'ebitda', 'ebit', 'gdp', 'cpi', 'ipo', |
| 46 | + 'etf', 'sec', 'fred', 'oecd', 'imf', 'ecb', 'bls', 'eia', |
| 47 | + 'sp', 'ny', 'us', 'uk', 'esg', 'sloos', 'fomc', 'pce', 'nonfarm', |
| 48 | +]) |
| 49 | + |
| 50 | +/** |
| 51 | + * Build the widgets.json configuration from registered routes and providers. |
| 52 | + * |
| 53 | + * @param router - The root Router with all commands registered |
| 54 | + * @param registry - The provider Registry |
| 55 | + * @param apiPrefix - The API prefix (default: "/api/v1") |
| 56 | + * @returns Record of widgetId → widget configuration |
| 57 | + */ |
| 58 | +export function buildWidgetsJson( |
| 59 | + router: Router, |
| 60 | + registry: Registry, |
| 61 | + apiPrefix = '/api/v1', |
| 62 | +): Record<string, unknown> { |
| 63 | + const widgets: Record<string, unknown> = {} |
| 64 | + const commands = router.getCommandMap(apiPrefix) |
| 65 | + |
| 66 | + // Build reverse index: modelName → provider names |
| 67 | + const modelToProviders = new Map<string, string[]>() |
| 68 | + for (const [providerName, provider] of registry.providers) { |
| 69 | + for (const modelName of Object.keys(provider.fetcherDict)) { |
| 70 | + const list = modelToProviders.get(modelName) ?? [] |
| 71 | + list.push(providerName) |
| 72 | + modelToProviders.set(modelName, list) |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + for (const [routePath, cmd] of commands) { |
| 77 | + const providers = modelToProviders.get(cmd.model) ?? ['custom'] |
| 78 | + |
| 79 | + // Derive widget_id from route path (strip apiPrefix, convert / to _) |
| 80 | + const routeWithoutPrefix = routePath.replace(apiPrefix, '') |
| 81 | + const baseWidgetId = routeWithoutPrefix.startsWith('/') |
| 82 | + ? routeWithoutPrefix.slice(1).replace(/\//g, '_') |
| 83 | + : routeWithoutPrefix.replace(/\//g, '_') |
| 84 | + |
| 85 | + // Derive category and subcategory from route segments |
| 86 | + const segments = routeWithoutPrefix |
| 87 | + .split('/') |
| 88 | + .filter((s) => s.length > 0) |
| 89 | + const category = segments[0] ? toTitle(segments[0]) : '' |
| 90 | + const subCategory = segments.length > 2 |
| 91 | + ? toTitle(segments[1]) |
| 92 | + : segments.length > 1 |
| 93 | + ? toTitle(segments[1]) |
| 94 | + : undefined |
| 95 | + |
| 96 | + // Derive widget name from route (strip category/subcategory, humanize) |
| 97 | + const name = deriveWidgetName(baseWidgetId, category, subCategory) |
| 98 | + |
| 99 | + // Look up Zod schemas for this model |
| 100 | + const schemas = SCHEMA_REGISTRY[cmd.model] |
| 101 | + |
| 102 | + for (const provider of providers) { |
| 103 | + const widgetId = provider === 'custom' |
| 104 | + ? `${baseWidgetId}_obb` |
| 105 | + : `${baseWidgetId}_${provider}_obb` |
| 106 | + |
| 107 | + // Build params from Zod query schema |
| 108 | + let params: WidgetParam[] = [] |
| 109 | + if (schemas) { |
| 110 | + params = zodSchemaToWidgetParams(schemas.queryParams) |
| 111 | + } |
| 112 | + |
| 113 | + // Add hidden provider param (matches Python behavior) |
| 114 | + if (provider !== 'custom') { |
| 115 | + params.push({ |
| 116 | + paramName: 'provider', |
| 117 | + label: 'Provider', |
| 118 | + description: 'Data source provider.', |
| 119 | + type: 'text', |
| 120 | + value: provider, |
| 121 | + optional: false, |
| 122 | + show: false, |
| 123 | + }) |
| 124 | + } |
| 125 | + |
| 126 | + // Build column definitions from Zod data schema |
| 127 | + let columnsDefs: unknown[] = [] |
| 128 | + if (schemas) { |
| 129 | + columnsDefs = zodSchemaToColumnDefs(schemas.data) |
| 130 | + } |
| 131 | + |
| 132 | + const providerDisplayName = PROVIDER_DISPLAY[provider] ?? toTitle(provider) |
| 133 | + |
| 134 | + const widgetConfig: Record<string, unknown> = { |
| 135 | + name, |
| 136 | + description: cmd.description, |
| 137 | + category: category.replace('Fixedincome', 'Fixed Income'), |
| 138 | + type: 'table', |
| 139 | + searchCategory: category.replace('Fixedincome', 'Fixed Income'), |
| 140 | + widgetId, |
| 141 | + mcp_tool: { |
| 142 | + mcp_server: 'Open Data Platform', |
| 143 | + tool_id: baseWidgetId, |
| 144 | + }, |
| 145 | + params, |
| 146 | + endpoint: routePath, |
| 147 | + runButton: false, |
| 148 | + gridData: { w: 40, h: 15 }, |
| 149 | + data: { |
| 150 | + dataKey: 'results', |
| 151 | + table: { |
| 152 | + showAll: true, |
| 153 | + enableAdvanced: true, |
| 154 | + ...(columnsDefs.length > 0 ? { columnsDefs } : {}), |
| 155 | + }, |
| 156 | + }, |
| 157 | + source: [providerDisplayName], |
| 158 | + } |
| 159 | + |
| 160 | + if (subCategory && segments.length > 2) { |
| 161 | + widgetConfig.subCategory = subCategory |
| 162 | + } |
| 163 | + |
| 164 | + widgets[widgetId] = widgetConfig |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + return widgets |
| 169 | +} |
| 170 | + |
| 171 | +/** Convert a snake_case segment to Title Case, uppercasing known acronyms. */ |
| 172 | +function toTitle(s: string): string { |
| 173 | + return s |
| 174 | + .replace(/_/g, ' ') |
| 175 | + .split(' ') |
| 176 | + .map((w) => { |
| 177 | + const lower = w.toLowerCase() |
| 178 | + if (TO_CAPS.has(lower)) return lower.toUpperCase() |
| 179 | + return lower.charAt(0).toUpperCase() + lower.slice(1) |
| 180 | + }) |
| 181 | + .join(' ') |
| 182 | +} |
| 183 | + |
| 184 | +/** Derive a human-readable widget name from the base widget ID. */ |
| 185 | +function deriveWidgetName(widgetId: string, category: string, subCategory?: string): string { |
| 186 | + let name = widgetId |
| 187 | + .replace(/_/g, ' ') |
| 188 | + .split(' ') |
| 189 | + .map((w) => { |
| 190 | + const lower = w.toLowerCase() |
| 191 | + if (TO_CAPS.has(lower)) return lower.toUpperCase() |
| 192 | + return lower.charAt(0).toUpperCase() + lower.slice(1) |
| 193 | + }) |
| 194 | + .join(' ') |
| 195 | + |
| 196 | + // Remove category and subcategory from name to avoid duplication |
| 197 | + if (category) { |
| 198 | + name = name.replace(new RegExp(`^${escapeRegex(category)}\\s*`, 'i'), '') |
| 199 | + } |
| 200 | + if (subCategory) { |
| 201 | + name = name.replace(new RegExp(`^${escapeRegex(subCategory)}\\s*`, 'i'), '') |
| 202 | + } |
| 203 | + |
| 204 | + return name.trim() || widgetId |
| 205 | +} |
| 206 | + |
| 207 | +function escapeRegex(s: string): string { |
| 208 | + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') |
| 209 | +} |
0 commit comments