Skip to content

Commit 39f896c

Browse files
os-zhuangclaude
andauthored
feat(spec): blueprint dashboard widget gains explicit measure/groupBy slots (#2360)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 27af33a commit 39f896c

2 files changed

Lines changed: 70 additions & 0 deletions

File tree

packages/spec/src/ai/solution-blueprint.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,41 @@ describe('SolutionBlueprintSchema', () => {
136136
}),
137137
).toThrow();
138138
});
139+
140+
it('accepts dashboard widgets that name an explicit measure + groupBy', () => {
141+
const parsed = SolutionBlueprintSchema.parse({
142+
...validBlueprint,
143+
dashboards: [
144+
{
145+
name: 'overview',
146+
widgets: [
147+
{ id: 'revenue', title: 'Total revenue', object: 'task', chart: 'metric', measure: 'amount' },
148+
{ id: 'by_status', title: 'By status', object: 'task', chart: 'bar', measure: 'count', groupBy: 'status' },
149+
],
150+
},
151+
],
152+
});
153+
expect(parsed.dashboards?.[0].widgets?.[0]).toMatchObject({ measure: 'amount' });
154+
expect(parsed.dashboards?.[0].widgets?.[1]).toMatchObject({ measure: 'count', groupBy: 'status' });
155+
});
156+
157+
it('allows a dashboard widget to omit measure + groupBy (builder infers them)', () => {
158+
const parsed = SolutionBlueprintSchema.parse({
159+
...validBlueprint,
160+
dashboards: [{ name: 'overview', widgets: [{ id: 'w1', title: 'Tasks', object: 'task', chart: 'metric' }] }],
161+
});
162+
expect(parsed.dashboards?.[0].widgets?.[0].measure).toBeUndefined();
163+
expect(parsed.dashboards?.[0].widgets?.[0].groupBy).toBeUndefined();
164+
});
165+
166+
it('rejects a non-snake_case widget measure / groupBy', () => {
167+
expect(() =>
168+
SolutionBlueprintSchema.parse({
169+
...validBlueprint,
170+
dashboards: [{ name: 'd', widgets: [{ id: 'w', object: 'task', chart: 'bar', groupBy: 'By Status' }] }],
171+
}),
172+
).toThrow();
173+
});
139174
});
140175

141176
// The strict mirror is what `generateObject` sends to OpenAI: every property
@@ -188,4 +223,31 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
188223
it('drops the un-strict-able seedData record (OpenAI strict cannot represent open key/value maps)', () => {
189224
expect('seedData' in SolutionBlueprintStrictSchema.shape).toBe(false);
190225
});
226+
227+
it('accepts a dashboard widget carrying the (nullable) measure + groupBy keys', () => {
228+
const parsed = SolutionBlueprintStrictSchema.parse({
229+
...strictBp,
230+
dashboards: [
231+
{
232+
name: 'overview',
233+
label: null,
234+
widgets: [
235+
{ id: 'revenue', title: 'Total revenue', object: 'project', chart: 'metric', measure: 'amount', groupBy: null },
236+
{ id: 'w2', title: null, object: null, chart: null, measure: null, groupBy: null },
237+
],
238+
},
239+
],
240+
});
241+
expect(parsed.dashboards?.[0].widgets?.[0]).toMatchObject({ measure: 'amount', groupBy: null });
242+
expect(parsed.dashboards?.[0].widgets?.[1].measure).toBeNull();
243+
});
244+
245+
it('requires the (nullable) measure + groupBy widget keys to be present (OpenAI strict)', () => {
246+
const missingKeys = {
247+
...strictBp,
248+
// widget omits `measure` and `groupBy` — strict mode needs every key in `required`.
249+
dashboards: [{ name: 'd', label: null, widgets: [{ id: 'w', title: null, object: null, chart: null }] }],
250+
};
251+
expect(() => SolutionBlueprintStrictSchema.parse(missingKeys)).toThrow();
252+
});
191253
});

packages/spec/src/ai/solution-blueprint.zod.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ export const BlueprintDashboardSchema = lazySchema(() => z.object({
7272
title: z.string().optional().describe('Widget title'),
7373
object: z.string().regex(SNAKE_CASE).optional().describe('Source object for the widget'),
7474
chart: z.enum(['metric', 'bar', 'line', 'pie', 'table']).optional().describe('Widget visualization'),
75+
measure: z.string().regex(SNAKE_CASE).optional()
76+
.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.'),
77+
groupBy: z.string().regex(SNAKE_CASE).optional()
78+
.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.'),
7579
})).optional().describe('Widgets to place on the dashboard'),
7680
}));
7781
export type BlueprintDashboard = z.infer<typeof BlueprintDashboardSchema>;
@@ -197,6 +201,10 @@ const StrictDashboard = z.object({
197201
title: z.string().nullable().describe('Widget title, or null'),
198202
object: z.string().nullable().describe('Source object, or null'),
199203
chart: z.enum(['metric', 'bar', 'line', 'pie', 'table']).nullable().describe('Visualization, or null'),
204+
measure: z.string().nullable()
205+
.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".'),
206+
groupBy: z.string().nullable()
207+
.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.'),
200208
})).nullable().describe('Widgets to place on the dashboard, or null'),
201209
});
202210

0 commit comments

Comments
 (0)