From c4ad0ec91727790bd63caeb47f528c5fcb476af1 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:57:52 +0800 Subject: [PATCH] feat(spec): blueprint dashboard widget gains explicit measure/groupBy slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan-first blueprint dashboard widget carried only {id,title,object,chart}, so cloud's deterministic dashboardBody() had to GUESS which measure/dimension a widget binds from the title alone — a non-count metric took the next measure by field order, a grouped chart took the first category dimension. Both produce structurally-valid-but-semantically-wrong bindings that graph-lint cannot catch (cloud #476). Add optional `measure` (the field to aggregate, or "count") and `groupBy` (the breakdown field) to both the lenient BlueprintDashboardSchema and the strict StrictDashboard mirror (the generateObject contract; stripNulls already drops the emitted nulls). The model names the FIELD; cloud maps it to the semantic measure (total_* SUM / avg_* AVG) so aggregation stays coherent. Co-Authored-By: Claude Opus 4.8 --- .../spec/src/ai/solution-blueprint.test.ts | 62 +++++++++++++++++++ .../spec/src/ai/solution-blueprint.zod.ts | 8 +++ 2 files changed, 70 insertions(+) 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'), });