diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts index 63ea4e29bb..3ea0d201b7 100644 --- a/packages/spec/src/ai/solution-blueprint.test.ts +++ b/packages/spec/src/ai/solution-blueprint.test.ts @@ -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 @@ -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(); + }); }); diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index c0ca5b0514..964410064c 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -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; @@ -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'), });