From 90dcf0aa8d18b1c308977682b6663d372788a3e4 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:36:29 +0800 Subject: [PATCH 1/2] feat(spec): blueprint dashboard widget `condition` slot (threshold/status filter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A widget whose title restricts WHICH records it counts — "库存低于10的备件预警", "overdue work orders", "金额超过 2000" — has no way to say so: the `measure`/ `groupBy` slots choose the NUMBER and the axis, not the row set. So the builder emits a plain count and a "low stock" card counts EVERY part. Add an optional `condition {field, op, value}` (op ∈ lt|lte|gt|gte|eq|ne) to the blueprint widget — lenient + strict variants — that the builder compiles to the framework widget's presentation-scope `filter` (runtimeFilter). Kept to a single comparison so the model emits it reliably and a bad guess can be dropped rather than producing an empty card. Pairs with the cloud dashboardBody mapping + prompt guidance (separate PR). Co-Authored-By: Claude Opus 4.8 --- .../spec/src/ai/solution-blueprint.zod.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index 964410064c..d702fb00b4 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -63,6 +63,19 @@ export const BlueprintViewSchema = lazySchema(() => z.object({ })); export type BlueprintView = z.infer; +/** + * 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; + /** 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)'), @@ -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; @@ -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'), }); From c546fda841e345b6ceafd3c4b9e8502f8bc57fb9 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:09:42 +0800 Subject: [PATCH 2/2] test(spec): cover widget `condition` in strict mirror + refresh api-surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strict SolutionBlueprintStrictSchema makes every widget key required-present (nullable for optionality), so the new `condition` slot must appear in the strict widget fixtures — one null, one a real {field,op,value}. Regenerate api-surface.json to register the new BlueprintWidgetCondition export. Co-Authored-By: Claude Opus 4.8 --- packages/spec/api-surface.json | 2 ++ packages/spec/src/ai/solution-blueprint.test.ts | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 20d4c0b300..ec6b800ee6 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1731,6 +1731,8 @@ "BlueprintSeedSchema (const)", "BlueprintView (type)", "BlueprintViewSchema (const)", + "BlueprintWidgetCondition (type)", + "BlueprintWidgetConditionSchema (const)", "CodeContentSchema (const)", "ConversationAnalytics (type)", "ConversationAnalyticsSchema (const)", diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts index 3ea0d201b7..aa90bd43f5 100644 --- a/packages/spec/src/ai/solution-blueprint.test.ts +++ b/packages/spec/src/ai/solution-blueprint.test.ts @@ -224,7 +224,7 @@ 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: [ @@ -232,14 +232,15 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { 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)', () => {