@fusedashlabs/ui9000-workspace
Version:
UI9000 workspace MCP. One tool, show_workspace, draws a chart from your table. Set TYPESAFE_API_KEY to let Jev choose the chart.
404 lines (403 loc) • 15.4 kB
JavaScript
/**
* Data first, then the chart the user asked for.
*
* The profile describes which families the columns support, and may name a
* closer chart. That name is a suggestion. A requested chart this workspace
* can draw is the one that is drawn, including a poor fit. Nothing is
* generated in its place.
*/
import { INTENTS } from '../spec/intent.js';
import { CHART_FAMILIES, CHART_ROLES, chartLabel, chartRole, familiesForChart } from './chart-roles.js';
const INTENT_PREFERENCE = {
spatial: ['spatial'],
comparison: [
'category-magnitude',
'distribution',
'two-measures',
'two-way-magnitude',
'ordered-series',
'many-metrics',
'flow',
],
summary: ['headline'],
form: [],
evidence: [],
graph: ['graph', 'flow'],
};
const LOOSE_NAMES = buildLooseNames();
export function discussChart(input) {
const counts = columnCounts(input.profile, input.columns);
const families = eligibleFamilies(input.profile, counts);
const resolved = resolveChartName(input.requestedChart);
const judged = resolved ? judgeRequest(resolved, families) : undefined;
const proposal = pickProposal(families, input.intent, input.profile);
const drawable = proposal ? firstDrawable(proposal, families, input.profile, input.catalogIds) : undefined;
const suggestionWhy = proposal ? whyFamily(proposal) : 'These columns do not match a chart category.';
const inGroup = judged?.kind === 'main' || judged?.kind === 'alternative';
const askId = proposal && canDraw(proposal.main, input.profile, input.catalogIds) ? proposal.main : drawable;
const suggestion = askId && askId !== resolved ? askId : undefined;
const base = {
dataFamilies: families.map((family) => family.id),
...(resolved ? { requestedChart: resolved } : {}),
};
if (resolved && canDraw(resolved, input.profile, input.catalogIds)) {
const family = inGroup ? judged?.family : undefined;
const chartWhy = family
? whyInFamily(resolved, family)
: whyDrawnAnyway(resolved, suggestionWhy);
return {
...base,
...(!inGroup && suggestion ? { suggestion } : {}),
suggestionWhy: !inGroup && suggestion ? suggestionWhy : chartWhy,
...(family ? {} : { poorFit: true }),
drawId: resolved,
awaitingUser: false,
message: chartWhy,
chartWhy,
};
}
if (inGroup && resolved && judged.family) {
const chartWhy = whyInFamily(resolved, judged.family);
return {
...base,
suggestionWhy: chartWhy,
keepRequested: true,
awaitingUser: true,
chartWhy: '',
message: [
`You selected ${chartLabel(resolved)}.`,
`It fits this data: ${judged.family.question} It stays ${chartLabel(resolved)}.`,
'This workspace cannot draw it yet.',
'I am not switching it to another chart. Do not call again.',
].join(' '),
};
}
return {
...base,
...(suggestion ? { suggestion } : {}),
suggestionWhy,
awaitingUser: true,
chartWhy: '',
message: cannotDrawMessage({
raw: input.requestedChart,
resolved,
suggestion,
suggestionWhy,
}),
};
}
/** Component id, chartType key, or a short name such as "pie" / "bar chart". */
export function resolveChartName(name) {
const trimmed = name.trim();
if (!trimmed)
return undefined;
if (trimmed in CHART_ROLES)
return trimmed;
const hit = LOOSE_NAMES.get(normalizeName(trimmed));
return hit && hit in CHART_ROLES ? hit : undefined;
}
/** Stems this short are ordinary English ("area", "line", "bar", "map"). */
const SHORT_CHART_STEM = 4;
const CHART_ARTICLES = new Set(['a', 'an', 'the']);
/** "the area" is a place, not an area chart. "the map" and "the line" are charts. */
const THE_STEM_BLOCK = new Set(['area']);
/**
* True when the user's words name this chart. A model-invented id does not count.
* Aliases are whole tokens ("bar chart", "sankey"), not substrings ("bargain").
* A stem of 4 letters or fewer counts after "a", "an", or "the" ("a bar", "the map").
* "the area" does not confirm an area chart.
*/
export function utteranceNamesChart(utterance, requestedChart) {
const id = resolveChartName(requestedChart);
if (!id)
return false;
const aliases = new Set();
for (const [key, value] of LOOSE_NAMES) {
if (value === id && key.length >= 3)
aliases.add(key);
}
const raw = normalizeName(requestedChart);
if (raw.length >= 3)
aliases.add(raw);
if (aliases.size === 0)
return false;
const tokens = utterance
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean)
.map((token) => normalizeName(token));
const windows = new Set(tokens);
for (let i = 0; i < tokens.length - 1; i++) {
const left = tokens[i];
const right = tokens[i + 1];
if (left && right)
windows.add(left + right);
}
for (const alias of aliases) {
if (alias.length > SHORT_CHART_STEM) {
if (windows.has(alias))
return true;
continue;
}
for (let i = 1; i < tokens.length; i++) {
const prev = tokens[i - 1];
if (tokens[i] !== alias || !prev || !CHART_ARTICLES.has(prev))
continue;
if (prev === 'the' && THE_STEM_BLOCK.has(alias))
continue;
return true;
}
}
return false;
}
/**
* Keep requestedChart when the utterance is absent, when this is a datasetId
* follow-up with no new table, or when the words name that chart.
* On a new table, drop a known chart the words do not name.
* An unknown name stays, so the existing hold still applies.
*/
export function requestedChartFromUtterance(utterance, requestedChart, options) {
const named = requestedChart?.trim();
if (!named)
return undefined;
if (options?.followUp)
return named;
const words = utterance?.trim();
if (!words)
return named;
if (!resolveChartName(named))
return named;
return utteranceNamesChart(words, named) ? named : undefined;
}
export function chartWhyFor(componentId) {
return chartRole(componentId) ? whyChart(componentId) : undefined;
}
/** Families whose data shape the profile can support. Same gates as discussChart. */
export function chartFamiliesFor(profile, columns, intent) {
const families = eligibleFamilies(profile, columnCounts(profile, columns));
const preferred = preferenceIds(profile, intent);
if (!preferred)
return families;
const rank = (id) => {
const index = preferred.indexOf(id);
return index === -1 ? preferred.length : index;
};
return families
.map((family, index) => ({ family, index }))
.sort((a, b) => rank(a.family.id) - rank(b.family.id) || a.index - b.index)
.map((item) => item.family);
}
/**
* The intent's own families, in preference order.
* Empty when the intent has a list and none of those families fit the columns.
*/
export function guidedChartFamilies(profile, columns, intent) {
const preferred = preferenceIds(profile, intent);
if (!preferred)
return [];
const ids = new Set(preferred);
return chartFamiliesFor(profile, columns, intent).filter((family) => ids.has(family.id));
}
function preferenceIds(profile, intent) {
const known = intent && INTENTS.includes(intent) ? intent : undefined;
if (!known)
return undefined;
const preferred = preferenceFor(known, profile);
return preferred.length > 0 ? preferred : undefined;
}
/** True when this intent should refuse a chart outside its families. */
export function intentGuidesCharts(intent) {
return INTENT_PREFERENCE[intent].length > 0;
}
/**
* A drawing has to match the profile, not only sit in the catalog.
* Line and area need time. Pie needs a small set of categories. A map needs geo.
*/
export function chartFitsProfile(id, profile) {
if (id === 'map-chart')
return profile.hasGeo === true && profile.hasMapToken === true;
if (id === 'pie-chart' || id === 'donut-chart') {
const card = profile.categoryCardinality ?? 0;
return card >= 2 && card <= 8;
}
if (id === 'line-chart' ||
id === 'area-chart' ||
id === 'step-line-chart' ||
id.startsWith('spark')) {
return profile.hasTemporal === true;
}
if (id === 'scatter-plot-chart' || id === 'bubble-chart') {
return profile.hasNumericMetric === true;
}
return true;
}
/** The one chart the words name, or nothing when they name zero or several. */
export function chartNamedByWords(utterance) {
let found;
for (const id of Object.keys(CHART_ROLES)) {
if (!utteranceNamesChart(utterance, id))
continue;
if (found && found !== id)
return undefined;
found = id;
}
return found;
}
function preferenceFor(intent, profile) {
const preferred = [...INTENT_PREFERENCE[intent]];
if (intent === 'comparison' && profile.hasTemporal === true && profile.hasNumericMetric === true) {
const distribution = preferred.indexOf('distribution');
const ordered = preferred.indexOf('ordered-series');
if (distribution !== -1 && ordered > distribution) {
preferred.splice(ordered, 1);
preferred.splice(distribution, 0, 'ordered-series');
}
}
return preferred;
}
/** Both ids are the main or an alternative of one family the columns support. */
export function sharesEligibleFamily(left, right, familyIds) {
const eligible = new Set(familyIds);
const rightIds = new Set(familiesForChart(right)
.map((family) => family.id)
.filter((id) => eligible.has(id)));
return familiesForChart(left).some((family) => rightIds.has(family.id));
}
function eligibleFamilies(profile, counts) {
const card = profile.categoryCardinality ?? 0;
const ids = [];
if (profile.hasLinks && profile.hasNumericMetric)
ids.push('flow');
if (profile.hasNodes && profile.hasLinks)
ids.push('graph');
if (profile.hasGeo)
ids.push('spatial');
if (profile.hasTemporal && profile.hasNumericMetric)
ids.push('ordered-series');
if (counts.categories >= 2 && counts.metrics >= 1)
ids.push('two-way-magnitude');
if (counts.metrics >= 3 && profile.hasCategory)
ids.push('many-metrics');
if (counts.metrics >= 2)
ids.push('two-measures');
if (profile.hasCategory && profile.hasNumericMetric && card > 0 && card <= 40) {
ids.push('category-magnitude');
}
if (profile.hasCategory && profile.hasNumericMetric && card >= 2 && card <= 8) {
ids.push('part-to-whole');
}
if (profile.hasCategory && profile.hasNumericMetric && card >= 2 && card <= 24) {
ids.push('contribution');
}
if (profile.hasNumericMetric && !profile.hasCategory && (profile.rowCount ?? 0) >= 8) {
ids.push('distribution');
}
if (profile.hasNumericMetric &&
card <= 12 &&
(profile.rowCount ?? 0) > 0 &&
(profile.rowCount ?? 0) <= 12) {
ids.push('headline');
}
return CHART_FAMILIES.filter((family) => ids.includes(family.id));
}
function columnCounts(profile, columns) {
if (!columns?.length) {
return {
metrics: profile.hasNumericMetric ? 1 : 0,
categories: profile.hasCategory ? 1 : 0,
};
}
return {
metrics: columns.filter((column) => column.role === 'metric').length,
categories: columns.filter((column) => column.role === 'category').length,
};
}
function pickProposal(families, intent, profile) {
const preferred = preferenceFor(intent, profile);
for (const id of preferred) {
const hit = families.find((family) => family.id === id);
if (hit)
return hit;
}
return families[0];
}
function judgeRequest(chartId, families) {
for (const family of families) {
if (family.main === chartId)
return { kind: 'main', family };
}
for (const family of families) {
if (family.alternatives.some((alt) => alt.id === chartId)) {
return { kind: 'alternative', family };
}
}
return { kind: 'unfit' };
}
export function canDraw(id, profile, catalogIds) {
if (!catalogIds.has(id))
return false;
if (id === 'map-chart' && profile.hasMapToken !== true)
return false;
return true;
}
function firstDrawable(proposal, families, profile, catalogIds) {
if (canDraw(proposal.main, profile, catalogIds))
return proposal.main;
for (const family of families) {
if (canDraw(family.main, profile, catalogIds))
return family.main;
}
return undefined;
}
function whyFamily(family) {
return `${chartLabel(family.main)}. The columns answer "${family.question}" (${family.data}).`;
}
function whyInFamily(id, family) {
const named = chartLabel(id);
const kept = family.main === id ? `${named} is the chart for this group.` : `${named} is in this group, so it stays.`;
return `${kept} The columns answer "${family.question}" (${family.data}).`;
}
function whyChart(id) {
const named = chartLabel(id);
const family = CHART_FAMILIES.find((item) => item.main === id);
if (!family)
return named;
return `${named}. The columns answer "${family.question}" (${family.data}).`;
}
function whyDrawnAnyway(id, suggestionWhy) {
const named = chartLabel(id);
return `You asked for ${named}. Drawing it. ${suggestionWhy} That is a suggestion. This chart stays ${named}.`;
}
function cannotDrawMessage(input) {
const asked = input.resolved ? chartRole(input.resolved) : undefined;
const selected = input.resolved && asked
? `You selected ${chartLabel(input.resolved)}.`
: `You selected "${input.raw.trim()}", which is not a chart I know.`;
const held = 'This workspace cannot draw it, so nothing was generated in its place.';
const offer = input.suggestion
? `${input.suggestionWhy} A closer chart would be ${chartLabel(input.suggestion)}. That is a suggestion only. Call again with it only if the user asks for that chart.`
: `${input.suggestionWhy} This workspace cannot draw a chart for this table. Do not call again.`;
return [selected, held, offer].join(' ');
}
function buildLooseNames() {
const loose = new Map();
const remember = (key, id) => {
if (!key)
return;
const prev = loose.get(key);
if (prev === undefined)
loose.set(key, id);
else if (prev !== id)
loose.set(key, '');
};
for (const role of Object.values(CHART_ROLES)) {
remember(normalizeName(role.id), role.id);
remember(normalizeName(role.id.replace(/-chart$/, '')), role.id);
for (const key of role.chartTypeKeys)
remember(normalizeName(key), role.id);
}
return loose;
}
function normalizeName(value) {
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
}