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
2 changes: 2 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -1731,6 +1731,8 @@
"BlueprintSeedSchema (const)",
"BlueprintView (type)",
"BlueprintViewSchema (const)",
"BlueprintWidgetCondition (type)",
"BlueprintWidgetConditionSchema (const)",
"CodeContentSchema (const)",
"ConversationAnalytics (type)",
"ConversationAnalyticsSchema (const)",
Expand Down
9 changes: 5 additions & 4 deletions packages/spec/src/ai/solution-blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,22 +224,23 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
expect('seedData' in SolutionBlueprintStrictSchema.shape).toBe(false);
});

it('accepts a dashboard widget carrying the (nullable) measure + groupBy keys', () => {
it('accepts a dashboard widget carrying the (nullable) measure + groupBy + condition 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 },
{ id: 'revenue', title: 'Total revenue', object: 'project', chart: 'metric', measure: 'amount', groupBy: null, condition: null },
{ id: 'low_stock', title: 'Low stock', object: 'project', chart: 'table', measure: null, groupBy: null, condition: { field: 'qty', op: 'lt', value: 10 } },
],
},
],
});
expect(parsed.dashboards?.[0].widgets?.[0]).toMatchObject({ measure: 'amount', groupBy: null });
expect(parsed.dashboards?.[0].widgets?.[1].measure).toBeNull();
expect(parsed.dashboards?.[0].widgets?.[0].condition).toBeNull();
expect(parsed.dashboards?.[0].widgets?.[1].condition).toMatchObject({ field: 'qty', op: 'lt', value: 10 });
});

it('requires the (nullable) measure + groupBy widget keys to be present (OpenAI strict)', () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/spec/src/ai/solution-blueprint.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ export const BlueprintViewSchema = lazySchema(() => z.object({
}));
export type BlueprintView = z.infer<typeof BlueprintViewSchema>;

/**
* A single comparison that scopes WHICH records a dashboard widget
* counts/aggregates — kept deliberately simple (one field op value) so the
* builder can compile it to a widget `runtimeFilter`, and the model can emit it
* reliably, instead of leaving a "low stock" / "overdue" card counting every row.
*/
export const BlueprintWidgetConditionSchema = lazySchema(() => z.object({
field: z.string().regex(SNAKE_CASE).describe('Field on the widget object to filter by (e.g. "stock_quantity", "status")'),
op: z.enum(['lt', 'lte', 'gt', 'gte', 'eq', 'ne']).describe('Comparison operator'),
value: z.union([z.number(), z.string(), z.boolean()]).describe('Comparison value (e.g. 10, "open")'),
}));
export type BlueprintWidgetCondition = z.infer<typeof BlueprintWidgetConditionSchema>;

/** A proposed dashboard with a few widgets (kept intentionally light). */
export const BlueprintDashboardSchema = lazySchema(() => z.object({
name: z.string().regex(SNAKE_CASE).describe('Dashboard machine name (snake_case)'),
Expand All @@ -76,6 +89,8 @@ export const BlueprintDashboardSchema = lazySchema(() => z.object({
.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.'),
condition: BlueprintWidgetConditionSchema.optional()
.describe('Restrict WHICH records the widget counts/aggregates when its title implies a threshold or status (e.g. "stock below 10" → {field:"stock_quantity", op:"lt", value:10}; "open tickets" → {field:"status", op:"eq", value:"open"}). Without it the widget covers ALL records — so a "低于10的备件预警" / "overdue" card would wrongly count everything. Omit when the widget genuinely spans every record.'),
})).optional().describe('Widgets to place on the dashboard'),
}));
export type BlueprintDashboard = z.infer<typeof BlueprintDashboardSchema>;
Expand Down Expand Up @@ -205,6 +220,12 @@ const StrictDashboard = z.object({
.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.'),
condition: z.object({
field: z.string().describe('Field on the widget object to filter by (e.g. "stock_quantity", "status")'),
op: z.enum(['lt', 'lte', 'gt', 'gte', 'eq', 'ne']).describe('Comparison operator'),
value: z.union([z.number(), z.string(), z.boolean()]).describe('Comparison value (e.g. 10, "open")'),
}).nullable()
.describe('Restrict WHICH records the widget counts/aggregates when its title implies a threshold or status (e.g. "stock below 10" → {field:"stock_quantity",op:"lt",value:10}; "open tickets" → {field:"status",op:"eq",value:"open"}), or null when the widget covers every record. Without it a "低于10的预警" / "overdue" card wrongly counts ALL rows.'),
})).nullable().describe('Widgets to place on the dashboard, or null'),
});

Expand Down