Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/spec/src/ai/solution-blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,41 @@ describe('SolutionBlueprintSchema', () => {
}),
).toThrow();
});

it('accepts dashboard widgets that name an explicit measure + groupBy', () => {
const parsed = SolutionBlueprintSchema.parse({
...validBlueprint,
dashboards: [
{
name: 'overview',
widgets: [
{ id: 'revenue', title: 'Total revenue', object: 'task', chart: 'metric', measure: 'amount' },
{ id: 'by_status', title: 'By status', object: 'task', chart: 'bar', measure: 'count', groupBy: 'status' },
],
},
],
});
expect(parsed.dashboards?.[0].widgets?.[0]).toMatchObject({ measure: 'amount' });
expect(parsed.dashboards?.[0].widgets?.[1]).toMatchObject({ measure: 'count', groupBy: 'status' });
});

it('allows a dashboard widget to omit measure + groupBy (builder infers them)', () => {
const parsed = SolutionBlueprintSchema.parse({
...validBlueprint,
dashboards: [{ name: 'overview', widgets: [{ id: 'w1', title: 'Tasks', object: 'task', chart: 'metric' }] }],
});
expect(parsed.dashboards?.[0].widgets?.[0].measure).toBeUndefined();
expect(parsed.dashboards?.[0].widgets?.[0].groupBy).toBeUndefined();
});

it('rejects a non-snake_case widget measure / groupBy', () => {
expect(() =>
SolutionBlueprintSchema.parse({
...validBlueprint,
dashboards: [{ name: 'd', widgets: [{ id: 'w', object: 'task', chart: 'bar', groupBy: 'By Status' }] }],
}),
).toThrow();
});
});

// The strict mirror is what `generateObject` sends to OpenAI: every property
Expand Down Expand Up @@ -188,4 +223,31 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
it('drops the un-strict-able seedData record (OpenAI strict cannot represent open key/value maps)', () => {
expect('seedData' in SolutionBlueprintStrictSchema.shape).toBe(false);
});

it('accepts a dashboard widget carrying the (nullable) measure + groupBy keys', () => {
const parsed = SolutionBlueprintStrictSchema.parse({
...strictBp,
dashboards: [
{
name: 'overview',
label: null,
widgets: [
{ id: 'revenue', title: 'Total revenue', object: 'project', chart: 'metric', measure: 'amount', groupBy: null },
{ id: 'w2', title: null, object: null, chart: null, measure: null, groupBy: null },
],
},
],
});
expect(parsed.dashboards?.[0].widgets?.[0]).toMatchObject({ measure: 'amount', groupBy: null });
expect(parsed.dashboards?.[0].widgets?.[1].measure).toBeNull();
});

it('requires the (nullable) measure + groupBy widget keys to be present (OpenAI strict)', () => {
const missingKeys = {
...strictBp,
// widget omits `measure` and `groupBy` — strict mode needs every key in `required`.
dashboards: [{ name: 'd', label: null, widgets: [{ id: 'w', title: null, object: null, chart: null }] }],
};
expect(() => SolutionBlueprintStrictSchema.parse(missingKeys)).toThrow();
});
});
8 changes: 8 additions & 0 deletions packages/spec/src/ai/solution-blueprint.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export const BlueprintDashboardSchema = lazySchema(() => z.object({
title: z.string().optional().describe('Widget title'),
object: z.string().regex(SNAKE_CASE).optional().describe('Source object for the widget'),
chart: z.enum(['metric', 'bar', 'line', 'pie', 'table']).optional().describe('Widget visualization'),
measure: z.string().regex(SNAKE_CASE).optional()
.describe('The field this widget aggregates (e.g. "amount", "probability"), or "count" to count records. The aggregation is chosen automatically from the field type — a money field SUMs, a percentage/rate AVERAGEs — so name the FIELD, not "total_amount". A "total revenue" widget sets measure:"amount"; an "average win rate" widget sets measure:"win_rate"; a "number of deals" widget sets measure:"count". Omit to let the builder infer from the title.'),
groupBy: z.string().regex(SNAKE_CASE).optional()
.describe('The field to break the widget down by — the category or time axis (e.g. "stage", "created_at"). A "by status" chart MUST set this to the status field; the title and this field MUST name the SAME field. Omit for a single-number metric.'),
})).optional().describe('Widgets to place on the dashboard'),
}));
export type BlueprintDashboard = z.infer<typeof BlueprintDashboardSchema>;
Expand Down Expand Up @@ -197,6 +201,10 @@ const StrictDashboard = z.object({
title: z.string().nullable().describe('Widget title, or null'),
object: z.string().nullable().describe('Source object, or null'),
chart: z.enum(['metric', 'bar', 'line', 'pie', 'table']).nullable().describe('Visualization, or null'),
measure: z.string().nullable()
.describe('The field this widget aggregates (e.g. "amount", "probability"), or "count" to count records, or null to infer from the title. The aggregation (sum vs average) is chosen automatically from the field type — name the FIELD, not "total_amount". "total revenue" → "amount"; "average win rate" → "win_rate"; "number of deals" → "count".'),
groupBy: z.string().nullable()
.describe('The field to break the widget down by — the category or time axis (e.g. "stage", "created_at"), or null for a single-number metric. A "by status" chart MUST set this to the status field; the title and this field MUST name the SAME field.'),
})).nullable().describe('Widgets to place on the dashboard, or null'),
});

Expand Down