/** * Pro AI feature pack * ------------------- * * Three jobs every analyst wishes a data grid could do: * * 1. Natural-language filter / sort (`aiFilter`) * "show me deals closing this quarter over $50k owned by Sasha" * -> { filters: [...], sort: [...] } * * 2. Smart fill (`aiSmartFill`) * User edits two cells in a column with example values; the * Grid proposes the rest. Excel "Flash Fill" pattern. * * 3. Summarise rows (`aiSummarize`) * Single row, the current selection, a group key, or the whole * filtered view -> a one-paragraph summary. * * The grid stays headless / model-agnostic. The consumer supplies an * `AIProvider` that knows how to call OpenAI / Anthropic / a local * model / a server-side proxy. We never bundle a model client. * * Built-in + free: the AI helpers live in `@svgrid/grid`. They ship no model * client and do nothing until you register a provider with `setAIProvider(...)`, * so they add nothing to the base bundle unless you actually use them. */ import type { RowData, SvGridApi, TableFeatures } from './index' import { getExportProvider } from './export-provider' // Export formats an AI export can plan. WRITING the enterprise formats (xlsx / // pdf / html / xml / md) needs the export engine from `@svgrid/enterprise` // (registered via the export-provider seam); csv / tsv / json are also handled // by that engine when present. The union is declared locally so the free grid // carries no dependency on the enterprise export module. type ExportFormat = 'xlsx' | 'xls' | 'pdf' | 'csv' | 'tsv' | 'html' | 'json' | 'xml' | 'md' // --------------------------------------------------------------------------- // Provider contract // --------------------------------------------------------------------------- /** * Shape every consumer-provided model adapter must implement. Keeping this * tiny (one async call, two response formats) lets the same adapter drive * OpenAI's `chat.completions`, Anthropic's `messages`, a self-hosted * llama.cpp endpoint, or a server-side proxy. The grid only cares that the * provider eventually returns a string we can parse. */ export type AIProvider = (request: AIRequest) => Promise export type AIRequest = { /** Full prompt the grid built for the model. Already includes column * schema and any sampled rows where applicable. */ prompt: string /** When 'json', the provider should ask the model to return strict * JSON only - no prose. We parse the response with JSON.parse and * throw a typed error on failure. */ responseFormat?: 'text' | 'json' /** Honored if the underlying transport supports cancellation. */ signal?: AbortSignal /** Free-form tag for telemetry / logging. One of: 'filter', * 'smart-fill', 'summarize', 'classify'. */ task: AITask /** Soft hint to the provider about how many tokens we expect back. * Useful for routing small jobs to a cheaper model. */ maxOutputTokens?: number } export type AITask = 'filter' | 'smart-fill' | 'summarize' | 'classify' | 'export' | 'anomaly' | 'chart' let provider: AIProvider | null = null /** * Register the model adapter every AI call will route through. Call once * at app boot. Passing `null` clears the provider and AI calls revert to * throwing "no provider" errors. */ export function setAIProvider(p: AIProvider | null): void { provider = p } export function getAIProvider(): AIProvider | null { return provider } export function hasAIProvider(): boolean { return provider != null } class NoProviderError extends Error { constructor() { super( '@svgrid/grid ai: no AI provider registered. Call setAIProvider(fn) ' + 'with an adapter that talks to OpenAI / Anthropic / your proxy.', ) this.name = 'NoProviderError' } } class BadJsonError extends Error { constructor(raw: string) { super( '@svgrid/grid ai: provider returned non-JSON for a json-format request. ' + `First 200 chars: ${raw.slice(0, 200)}`, ) this.name = 'BadJsonError' } } async function callProvider(req: AIRequest): Promise { if (!provider) throw new NoProviderError() return provider(req) } async function callJSON(req: AIRequest): Promise { const raw = await callProvider({ ...req, responseFormat: 'json' }) // Models occasionally wrap JSON in markdown code fences even when asked // for raw JSON. Strip a single fenced block if present, then parse. const stripped = raw.trim().replace(/^```(?:json)?\s*|\s*```$/g, '') try { return JSON.parse(stripped) as T } catch { throw new BadJsonError(raw) } } // --------------------------------------------------------------------------- // Helpers - build column schema + row sample from the grid API // --------------------------------------------------------------------------- type ColumnSchemaEntry = { field: string type: 'string' | 'number' | 'boolean' | 'date' | 'unknown' sampleValues: unknown[] } /** * Inspect the first N rows the grid is holding to infer a typed schema + * a handful of sample values per column. This is the context every AI * task needs and the most expensive thing to build, so the helpers cache * the result inside one task call rather than calling api.getData() * repeatedly. */ function buildColumnSchema< TFeatures extends TableFeatures, TData extends RowData, >(api: SvGridApi, sampleSize = 50): ColumnSchemaEntry[] { const data = api.getData() if (data.length === 0) return [] const slice = data.slice(0, Math.min(data.length, sampleSize)) const fields = new Set() for (const row of slice) { for (const k of Object.keys(row as object)) fields.add(k) } const out: ColumnSchemaEntry[] = [] for (const field of fields) { const values: unknown[] = [] for (const row of slice) { const v = (row as Record)[field] if (v != null) values.push(v) if (values.length >= 5) break } out.push({ field, type: inferType(values), sampleValues: values, }) } return out } function inferType(samples: unknown[]): ColumnSchemaEntry['type'] { if (samples.length === 0) return 'unknown' const v = samples[0] if (typeof v === 'number') return 'number' if (typeof v === 'boolean') return 'boolean' if (typeof v === 'string') { // Cheap ISO-date sniff so we route date-style filters correctly. if (/^\d{4}-\d{2}-\d{2}/.test(v)) return 'date' return 'string' } return 'unknown' } function schemaToPromptBlock(schema: ColumnSchemaEntry[]): string { return schema .map( (c) => `- ${c.field} (${c.type}): e.g. ${c.sampleValues .slice(0, 3) .map((v) => JSON.stringify(v)) .join(', ')}`, ) .join('\n') } // --------------------------------------------------------------------------- // 1. Natural-language filter / sort // --------------------------------------------------------------------------- export type AIFilterClause = { field: string operator: 'contains' | 'equals' | 'startsWith' | 'greaterThan' | 'lessThan' | 'isBlank' value?: string } export type AISortClause = { field: string; desc: boolean } export type AIFilterResult = { filters: AIFilterClause[] sort: AISortClause[] /** Plain-English explanation of how the model interpreted the query. * Surface this in the UI so the user can confirm or undo. */ rationale: string } export type AIFilterOptions = { /** * When true, the helper not only RETURNS the plan but also applies it * to the grid via `api.setFilter` / `api.setSort`. Defaults to false * so callers can show a preview before committing. */ apply?: boolean signal?: AbortSignal } /** * Translate a natural-language query into a filter+sort plan against * the current grid's columns. The grid passes the column schema (names, * types, sample values) to the model so it can pick the right field * names without hallucinating. */ export async function aiFilter< TFeatures extends TableFeatures, TData extends RowData, >( api: SvGridApi, query: string, opts: AIFilterOptions = {}, ): Promise { const schema = buildColumnSchema(api) const prompt = `You are a data-grid filter planner. Translate the user's natural-language ` + `query into a strict-JSON plan that the grid can apply.\n\n` + `Columns:\n${schemaToPromptBlock(schema)}\n\n` + `Output JSON schema:\n` + `{ "filters": [{"field": "", ` + `"operator": "contains"|"equals"|"startsWith"|"greaterThan"|"lessThan"|"isBlank", ` + `"value": ""}], ` + `"sort": [{"field": "", "desc": true|false}], ` + `"rationale": "" }\n\n` + `User query: ${query}\n\n` + `Return JSON only, no prose.` const result = await callJSON({ prompt, task: 'filter', signal: opts.signal, maxOutputTokens: 400, }) // Defensive: drop anything that doesn't match a real column. Models love // to invent plausible-sounding field names; we'd rather skip a clause // than throw at runtime. const valid = new Set(schema.map((c) => c.field)) result.filters = (result.filters ?? []).filter((f) => valid.has(f.field)) result.sort = (result.sort ?? []).filter((s) => valid.has(s.field)) if (opts.apply) { api.clearAllFilters() api.clearSort() for (const f of result.filters) { api.setFilter(f.field, { operator: f.operator, value: f.value ?? '' }) } // Multiple sort clauses get applied last-wins because the public // `setSort` is single-key. Multi-sort is on the roadmap. const last = result.sort[result.sort.length - 1] if (last) api.setSort(last.field, last.desc ? 'desc' : 'asc') } return result } // --------------------------------------------------------------------------- // 2. Smart fill // --------------------------------------------------------------------------- export type AISmartFillExample = { input: Record; output: unknown } export type AISmartFillResult = { field: string predictions: Array<{ rowIndex: number; value: TValue; confidence: number }> rationale: string } export type AISmartFillOptions = { /** Target column - the one whose values we want filled. */ field: string /** Index of rows the model should propose values for. If omitted, every * row whose current `field` value is `null`, `undefined` or `''` is * selected automatically. */ targetRowIndices?: number[] /** * Worked examples the user has already filled in. Required - the * model needs at least one to know the pattern, two or more to lock * the schema. We don't pull these from the grid automatically because * "edited" vs "untouched" isn't a state SvGrid exposes today. */ examples: AISmartFillExample[] signal?: AbortSignal } /** * Given a column and a few user-provided examples, propose values for the * untouched rows. Returns predictions only - the caller chooses whether to * commit them via `api.setCellValue`. This is the right shape for an * accept-per-cell UX with confidence-coloured highlights. */ export async function aiSmartFill< TFeatures extends TableFeatures, TData extends RowData, TValue = unknown, >( api: SvGridApi, opts: AISmartFillOptions, ): Promise> { if (opts.examples.length === 0) { throw new Error('@svgrid/grid ai: aiSmartFill requires at least one example.') } const data = api.getData() const targets = opts.targetRowIndices ?? data .map((row, i) => { const v = (row as Record)[opts.field] return v == null || v === '' ? i : -1 }) .filter((i) => i >= 0) if (targets.length === 0) { return { field: opts.field, predictions: [], rationale: 'No empty cells to fill.' } } const examplesBlock = opts.examples .map((ex, i) => `Example ${i + 1}: input=${JSON.stringify(ex.input)} -> output=${JSON.stringify(ex.output)}`) .join('\n') const targetRowsBlock = targets .map((i) => `Row ${i}: ${JSON.stringify(data[i])}`) .join('\n') const prompt = `You are filling in a single column of a data grid based on user-provided ` + `examples. Match the pattern in the examples exactly.\n\n` + `Target column: ${opts.field}\n\n` + `Examples:\n${examplesBlock}\n\n` + `Rows to fill:\n${targetRowsBlock}\n\n` + `Return strict JSON only:\n` + `{ "predictions": [{"rowIndex": , "value": , ` + `"confidence": <0..1>}], "rationale": "" }` const result = await callJSON<{ predictions: Array<{ rowIndex: number; value: TValue; confidence: number }> rationale: string }>({ prompt, task: 'smart-fill', signal: opts.signal, maxOutputTokens: 600, }) return { field: opts.field, predictions: result.predictions ?? [], rationale: result.rationale ?? '', } } // --------------------------------------------------------------------------- // 3. Summarise // --------------------------------------------------------------------------- export type AISummarizeTarget = | { kind: 'row'; rowIndex: number } | { kind: 'all' } | { kind: 'selection'; rowIndices: number[] } | { kind: 'group'; field: string; value: unknown } export type AISummary = { text: string bullets: string[] /** Field names the model thinks are the most load-bearing for the * story it just told. UI can highlight those columns. */ highlightedFields: string[] } export type AISummarizeOptions = { target: AISummarizeTarget /** Optional question the user is trying to answer. Helps the model * bias the summary toward the relevant columns. */ question?: string signal?: AbortSignal } /** * Ask the model to summarise a slice of the grid. Caps the row sample at * a model-friendly size; for huge selections it samples uniformly so the * summary stays representative without blowing the context window. */ export async function aiSummarize< TFeatures extends TableFeatures, TData extends RowData, >( api: SvGridApi, opts: AISummarizeOptions, ): Promise { const all = api.getData() const rows: TData[] = (() => { const t = opts.target if (t.kind === 'row') { const r = all[t.rowIndex] return r ? [r] : [] } if (t.kind === 'selection') { return t.rowIndices.map((i) => all[i]).filter((r): r is TData => r != null) } if (t.kind === 'group') { return all.filter((r) => (r as Record)[t.field] === t.value) } return all.slice() })() if (rows.length === 0) { return { text: 'No rows in scope.', bullets: [], highlightedFields: [] } } // Uniform sample to stay inside a sensible context budget. const MAX_SAMPLE = 25 const sample = rows.length <= MAX_SAMPLE ? rows : Array.from({ length: MAX_SAMPLE }, (_, i) => rows[Math.floor((i / MAX_SAMPLE) * rows.length)]!) const schema = buildColumnSchema(api, 30) const prompt = `You are summarising a slice of a business data grid for a busy analyst.\n\n` + `Columns:\n${schemaToPromptBlock(schema)}\n\n` + `Slice size: ${rows.length} row(s). Sampled rows shown below.\n` + `${sample.map((r) => JSON.stringify(r)).join('\n')}\n\n` + (opts.question ? `User's question: ${opts.question}\n\n` : '') + `Return strict JSON only:\n` + `{ "text": "", ` + `"bullets": ["<3-5 punchy bullets>"], ` + `"highlightedFields": [""] }` const result = await callJSON({ prompt, task: 'summarize', signal: opts.signal, maxOutputTokens: 600, }) return { text: result.text ?? '', bullets: result.bullets ?? [], highlightedFields: result.highlightedFields ?? [], } } // --------------------------------------------------------------------------- // 4. Classify (free-text -> bucketed value) // --------------------------------------------------------------------------- export type AIClassifyOptions = { /** Column whose free-text we're classifying. */ inputField: string /** Target column the model should write to. */ outputField: string /** Allowed values. The model is constrained to pick one. */ classes: string[] /** Optional one-line description of each class (acts as a labeling rubric). */ classDescriptions?: Record /** Rows to classify. Defaults to all. */ targetRowIndices?: number[] signal?: AbortSignal } export type AIClassifyResult = { inputField: string outputField: string predictions: Array<{ rowIndex: number; value: string; confidence: number }> } /** * Bucket free-text cells into one of a known set of classes. The model * is constrained to pick from `opts.classes`; predictions outside the set * are dropped so the caller can rely on the output being clean enum * values it can write straight back into the grid. */ export async function aiClassify< TFeatures extends TableFeatures, TData extends RowData, >( api: SvGridApi, opts: AIClassifyOptions, ): Promise { const data = api.getData() const targets = opts.targetRowIndices ?? data.map((_, i) => i) const rubric = opts.classDescriptions ? Object.entries(opts.classDescriptions) .map(([k, v]) => `- ${k}: ${v}`) .join('\n') : opts.classes.map((c) => `- ${c}`).join('\n') const rowsBlock = targets .map((i) => `Row ${i}: ${JSON.stringify((data[i] as Record)?.[opts.inputField] ?? '')}`) .join('\n') const prompt = `You are a strict text classifier. For each row, pick exactly one ` + `label from the list. If unsure, pick the closest match and lower the ` + `confidence.\n\n` + `Allowed labels (with optional rubric):\n${rubric}\n\n` + `Rows to classify (text from column "${opts.inputField}"):\n${rowsBlock}\n\n` + `Return strict JSON only:\n` + `{ "predictions": [{"rowIndex": , "value": "