Skip to content

Commit 462d9c4

Browse files
os-zhuangclaude
andauthored
feat(spec): 蓝图可以声明汇总字段(含带条件的汇总) (#4425)
* feat(spec): a solution blueprint can declare a roll-up, including a conditional one `BlueprintFieldSchema` had no slot for `summaryOperations`, so a `summary` field could only ever be proposed as a bare shell. Two consequences, both observed live (cloud#970): 1. `z.object` STRIPS unknown keys, so a blueprint that correctly declared `{ type:'summary', summaryOperations:{ object:'task', function:'count', filter:{ status:'completed' } } }` silently lost that config at the parse waist and materialized runtime-dead. 2. The strict structured-output mirror could not express it either, so the design step — the one place the aggregation is actually known ("已完成任务数 counts only completed tasks") — had nowhere to put it. This matters beyond tidiness: the engine recomputes roll-ups only when a CHILD row is written, so a `summaryOperations` bolted on AFTER a build's sample data loaded leaves the parent values empty until someone edits a child. The design phase is the only place it can be declared in time. Adds `BlueprintSummaryOperationsSchema` (object / function / field / relationshipField, plus the predicate) to both the lenient schema and the strict mirror. Strict mode cannot represent the canonical `filter` map (open-ended `additionalProperties`), so the predicate is a flat `conditions` array of `{field, op, value}` — the same shape a dashboard widget's `condition` already uses; the lenient schema also accepts a real `filter` map for a hand-authored blueprint. `BlueprintWidgetConditionSchema` becomes an alias of the now-shared `BlueprintConditionSchema`. Also makes the lenient schema's top-level `summary` OPTIONAL. It is a purely descriptive one-liner with no structural role, but `apply_blueprint` parses the model's re-emitted blueprint against this schema — so omitting it rejected the whole build with `path: "summary"`, which the model read as "the summary FIELDS are invalid" and repaired by DELETING the roll-up fields. The strict design contract still requires it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(spec): record the new blueprint keys in the authorable surface + changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(spec): regenerate the solution-blueprint reference page Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(spec): record the 4 added ai/ exports in the API-surface snapshot Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 5b843fb commit 462d9c4

7 files changed

Lines changed: 251 additions & 16 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): a solution blueprint can declare a roll-up, including a conditional one
6+
7+
`BlueprintFieldSchema` gains `summaryOperations` (object / function / field /
8+
relationshipField + a predicate), in both the lenient schema and the strict
9+
structured-output mirror. Without a slot for it, a blueprint `summary` field
10+
could only ever be proposed as a bare shell: `z.object` strips unknown keys, so
11+
a blueprint that correctly declared `{ type:'summary', summaryOperations:{
12+
object:'task', function:'count', filter:{ status:'completed' } } }` lost that
13+
config at the parse waist and materialized runtime-dead — and the design step,
14+
the one place the aggregation is actually known, had nowhere to put it.
15+
16+
It has to be declarable at design time: the engine recomputes a roll-up only
17+
when a CHILD row is written, so operations added after a build's sample data
18+
loaded leave every parent value empty until someone edits a child.
19+
20+
Strict mode cannot represent the canonical `filter` map (open-ended
21+
`additionalProperties`), so the predicate is a flat `conditions` array of
22+
`{field, op, value}` — the shape a dashboard widget's `condition` already uses;
23+
the lenient schema also accepts a real `filter` map for a hand-authored
24+
blueprint. `BlueprintWidgetConditionSchema` is now an alias of the shared
25+
`BlueprintConditionSchema`.
26+
27+
Also makes the lenient schema's top-level `summary` optional. It is a purely
28+
descriptive one-liner with no structural role, but it is what `apply_blueprint`
29+
parses the model's re-emitted blueprint against — omitting it rejected the whole
30+
build with `path: "summary"`, which an agent read as "the summary FIELDS are
31+
invalid" and repaired by deleting the roll-up fields. The strict design contract
32+
still requires it.

content/docs/references/ai/solution-blueprint.mdx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ batch-draft. This is the safety valve for low-specificity input.
3434
## TypeScript Usage
3535

3636
```typescript
37-
import { BlueprintApp, BlueprintDashboard, BlueprintField, BlueprintNavItem, BlueprintObject, BlueprintSeed, BlueprintView, BlueprintWidgetCondition, SolutionBlueprint, SolutionBlueprintStrict } from '@objectstack/spec/ai';
38-
import type { BlueprintApp, BlueprintDashboard, BlueprintField, BlueprintNavItem, BlueprintObject, BlueprintSeed, BlueprintView, BlueprintWidgetCondition, SolutionBlueprint, SolutionBlueprintStrict } from '@objectstack/spec/ai';
37+
import { BlueprintApp, BlueprintCondition, BlueprintDashboard, BlueprintField, BlueprintNavItem, BlueprintObject, BlueprintSeed, BlueprintSummaryOperations, BlueprintView, BlueprintWidgetCondition, SolutionBlueprint, SolutionBlueprintStrict } from '@objectstack/spec/ai';
38+
import type { BlueprintApp, BlueprintCondition, BlueprintDashboard, BlueprintField, BlueprintNavItem, BlueprintObject, BlueprintSeed, BlueprintSummaryOperations, BlueprintView, BlueprintWidgetCondition, SolutionBlueprint, SolutionBlueprintStrict } from '@objectstack/spec/ai';
3939

4040
// Validate data
4141
const result = BlueprintApp.parse(data);
@@ -55,6 +55,19 @@ const result = BlueprintApp.parse(data);
5555
| **nav** | `{ type: Enum<'object' \| 'dashboard'>; target: string; label?: string; icon?: string }[]` | optional | Navigation entries; omit to auto-surface every created object and dashboard |
5656

5757

58+
---
59+
60+
## BlueprintCondition
61+
62+
### Properties
63+
64+
| Property | Type | Required | Description |
65+
| :--- | :--- | :--- | :--- |
66+
| **field** | `string` || Field on the target object to filter by (e.g. "stock_quantity", "status") |
67+
| **op** | `Enum<'lt' \| 'lte' \| 'gt' \| 'gte' \| 'eq' \| 'ne'>` || Comparison operator |
68+
| **value** | `number \| string \| boolean` || Comparison value — for a select field use its option VALUE, never its label (e.g. "completed", not "已完成") |
69+
70+
5871
---
5972

6073
## BlueprintDashboard
@@ -82,6 +95,7 @@ const result = BlueprintApp.parse(data);
8295
| **required** | `boolean` | optional | Whether the field is required |
8396
| **reference** | `string` | optional | Target object name for lookup / master_detail relationship fields |
8497
| **options** | `{ label: string; value: string }[]` | optional | Choices for select / multiselect / radio fields |
98+
| **summaryOperations** | `{ object: string; function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>; field?: string; relationshipField?: string; … }` | optional | REQUIRED when `type` is "summary" (a roll-up of child records: 任务总数 / 报名人数 / 合计金额 / 已完成任务数). Names the child object, the aggregation, and — for a qualified count/sum — the condition. A "summary" field without it materializes runtime-dead. |
8599

86100

87101
---
@@ -125,6 +139,22 @@ const result = BlueprintApp.parse(data);
125139
| **records** | `Record<string, any>[]` || Rows to seed |
126140

127141

142+
---
143+
144+
## BlueprintSummaryOperations
145+
146+
### Properties
147+
148+
| Property | Type | Required | Description |
149+
| :--- | :--- | :--- | :--- |
150+
| **object** | `string` || The CHILD object whose records are aggregated (snake_case). It must carry a lookup / master_detail field pointing back at this parent, or the roll-up never computes. |
151+
| **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` || Aggregation: "数量 / 个数 / 计数" → count; "合计 / 总额 / 累计" → sum; "平均" → avg |
152+
| **field** | `string` | optional | Numeric field on the CHILD object to aggregate. Ignored for "count" (pass "id" or omit it). |
153+
| **relationshipField** | `string` | optional | The child FK field pointing back at this parent. Auto-detected from the child's lookup / master_detail; set it only when the child has more than one reference to this parent. |
154+
| **conditions** | `{ field: string; op: Enum<'lt' \| 'lte' \| 'gt' \| 'gte' \| 'eq' \| 'ne'>; value: number \| string \| boolean }[]` | optional | CONDITIONAL roll-up: aggregate only the child rows matching these comparisons (ANDed). REQUIRED whenever the field name carries a qualifier — "已完成任务数 / 已收货金额 / 待处理工单数", any 已X / 未X / `<某状态>`的 count-or-sum → e.g. [`{ field: "status", op: "eq", value: "completed" }`]. WITHOUT it the roll-up silently counts EVERY child and reports a plausible-looking WRONG number, which is worse than a visible 0. |
155+
| **filter** | `any` | optional | The same predicate as a canonical query filter map (e.g. `{ status: "completed" }`, `{ status: { $in: ["received", "partial"] }` }). Use it when hand-authoring a blueprint; the structured design path uses `conditions` instead. Wins over `conditions` when both are given. |
156+
157+
128158
---
129159

130160
## BlueprintView
@@ -149,9 +179,9 @@ const result = BlueprintApp.parse(data);
149179

150180
| Property | Type | Required | Description |
151181
| :--- | :--- | :--- | :--- |
152-
| **field** | `string` || Field on the widget object to filter by (e.g. "stock_quantity", "status") |
182+
| **field** | `string` || Field on the target object to filter by (e.g. "stock_quantity", "status") |
153183
| **op** | `Enum<'lt' \| 'lte' \| 'gt' \| 'gte' \| 'eq' \| 'ne'>` || Comparison operator |
154-
| **value** | `number \| string \| boolean` || Comparison value (e.g. 10, "open") |
184+
| **value** | `number \| string \| boolean` || Comparison value — for a select field use its option VALUE, never its label (e.g. "completed", not "已完成") |
155185

156186

157187
---
@@ -162,7 +192,7 @@ const result = BlueprintApp.parse(data);
162192

163193
| Property | Type | Required | Description |
164194
| :--- | :--- | :--- | :--- |
165-
| **summary** | `string` | | One-line description of the proposed solution |
195+
| **summary** | `string` | optional | One-line description of the proposed solution |
166196
| **assumptions** | `string[]` || Design assumptions made from the underspecified goal |
167197
| **questions** | `string[]` | optional | At most 1-2 structure-deciding questions to confirm before building |
168198
| **objects** | `{ name: string; label?: string; description?: string; fields: { name: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>; required?: boolean; … }[]; … }[]` || Objects (tables) to create |

packages/spec/api-surface.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1883,6 +1883,8 @@
18831883
"AgentSchema (const)",
18841884
"BlueprintApp (type)",
18851885
"BlueprintAppSchema (const)",
1886+
"BlueprintCondition (type)",
1887+
"BlueprintConditionSchema (const)",
18861888
"BlueprintDashboard (type)",
18871889
"BlueprintDashboardSchema (const)",
18881890
"BlueprintField (type)",
@@ -1893,6 +1895,8 @@
18931895
"BlueprintObjectSchema (const)",
18941896
"BlueprintSeed (type)",
18951897
"BlueprintSeedSchema (const)",
1898+
"BlueprintSummaryOperations (type)",
1899+
"BlueprintSummaryOperationsSchema (const)",
18961900
"BlueprintView (type)",
18971901
"BlueprintViewSchema (const)",
18981902
"BlueprintWidgetCondition (type)",

packages/spec/authorable-surface.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
"ai/BlueprintApp:label",
4242
"ai/BlueprintApp:name",
4343
"ai/BlueprintApp:nav",
44+
"ai/BlueprintCondition:field",
45+
"ai/BlueprintCondition:op",
46+
"ai/BlueprintCondition:value",
4447
"ai/BlueprintDashboard:label",
4548
"ai/BlueprintDashboard:name",
4649
"ai/BlueprintDashboard:widgets",
@@ -49,6 +52,7 @@
4952
"ai/BlueprintField:options",
5053
"ai/BlueprintField:reference",
5154
"ai/BlueprintField:required",
55+
"ai/BlueprintField:summaryOperations",
5256
"ai/BlueprintField:type",
5357
"ai/BlueprintNavItem:icon",
5458
"ai/BlueprintNavItem:label",
@@ -61,6 +65,12 @@
6165
"ai/BlueprintObject:nameField",
6266
"ai/BlueprintSeed:object",
6367
"ai/BlueprintSeed:records",
68+
"ai/BlueprintSummaryOperations:conditions",
69+
"ai/BlueprintSummaryOperations:field",
70+
"ai/BlueprintSummaryOperations:filter",
71+
"ai/BlueprintSummaryOperations:function",
72+
"ai/BlueprintSummaryOperations:object",
73+
"ai/BlueprintSummaryOperations:relationshipField",
6474
"ai/BlueprintView:columns",
6575
"ai/BlueprintView:groupBy",
6676
"ai/BlueprintView:label",

packages/spec/json-schema.manifest.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
"ai/AIUsageRecord",
66
"ai/Agent",
77
"ai/BlueprintApp",
8+
"ai/BlueprintCondition",
89
"ai/BlueprintDashboard",
910
"ai/BlueprintField",
1011
"ai/BlueprintNavItem",
1112
"ai/BlueprintObject",
1213
"ai/BlueprintSeed",
14+
"ai/BlueprintSummaryOperations",
1315
"ai/BlueprintView",
1416
"ai/BlueprintWidgetCondition",
1517
"ai/CodeContent",

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

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,48 @@ describe('SolutionBlueprintSchema', () => {
4343
expect(parsed.views?.[0].type).toBe('list');
4444
});
4545

46+
it('keeps summaryOperations (incl. a conditional roll-up) on a summary field', () => {
47+
// z.object STRIPS unknown keys, so before this slot existed a blueprint that
48+
// correctly declared { type:'summary', summaryOperations:{…filter…} } lost the
49+
// config at the parse waist and materialized runtime-dead (cloud#970).
50+
const parsed = SolutionBlueprintSchema.parse({
51+
summary: 'tasks',
52+
objects: [
53+
{
54+
name: 'project',
55+
fields: [
56+
{ name: 'task_total', type: 'summary', summaryOperations: { object: 'task', field: 'id', function: 'count' } },
57+
{
58+
name: 'completed_task_count', type: 'summary',
59+
summaryOperations: { object: 'task', field: 'id', function: 'count', filter: { status: 'completed' } },
60+
},
61+
{
62+
name: 'open_task_count', type: 'summary',
63+
summaryOperations: { object: 'task', function: 'count', conditions: [{ field: 'status', op: 'ne', value: 'completed' }] },
64+
},
65+
],
66+
},
67+
],
68+
});
69+
expect(parsed.objects[0].fields[0].summaryOperations).toEqual({ object: 'task', field: 'id', function: 'count' });
70+
expect(parsed.objects[0].fields[1].summaryOperations?.filter).toEqual({ status: 'completed' });
71+
expect(parsed.objects[0].fields[2].summaryOperations?.conditions).toEqual([
72+
{ field: 'status', op: 'ne', value: 'completed' },
73+
]);
74+
});
75+
76+
it('accepts a blueprint with no top-level `summary` — a prose one-liner must not sink a valid build', () => {
77+
// cloud#970: apply_blueprint parses the model's re-emitted blueprint with this
78+
// schema. A missing `summary` used to hard-fail with `path: "summary"`, which
79+
// the model read as "the summary FIELDS are invalid" and repaired by deleting
80+
// the roll-up fields.
81+
const parsed = SolutionBlueprintSchema.parse({
82+
objects: [{ name: 'thing', fields: [{ name: 'name', type: 'text' }] }],
83+
});
84+
expect(parsed.summary).toBeUndefined();
85+
expect(parsed.objects).toHaveLength(1);
86+
});
87+
4688
it('defaults assumptions to an empty array and view type to list', () => {
4789
const parsed = SolutionBlueprintSchema.parse({
4890
summary: 'minimal',
@@ -68,9 +110,9 @@ describe('SolutionBlueprintSchema', () => {
68110
expect(parsed.views?.map((v) => v.type)).toEqual(['gallery', 'gantt']);
69111
});
70112

71-
it('rejects a missing summary', () => {
72-
const { summary: _drop, ...noSummary } = validBlueprint;
73-
expect(() => SolutionBlueprintSchema.parse(noSummary)).toThrow();
113+
it('still rejects a missing `objects` — structure is required even though prose is not', () => {
114+
const { objects: _drop, ...noObjects } = validBlueprint;
115+
expect(() => SolutionBlueprintSchema.parse(noObjects)).toThrow();
74116
});
75117

76118
it('rejects an invalid field type', () => {
@@ -203,7 +245,7 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
203245
label: null,
204246
description: null,
205247
fields: [
206-
{ name: 'name', label: null, type: 'text', required: null, reference: null, options: null },
248+
{ name: 'name', label: null, type: 'text', required: null, reference: null, options: null, summaryOperations: null },
207249
],
208250
},
209251
],
@@ -242,13 +284,50 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
242284
const badField = {
243285
...strictBp,
244286
objects: [
245-
{ name: 'x', label: null, description: null, fields: [{ name: 'f', type: 'text', required: null, reference: null, options: null }] },
287+
{ name: 'x', label: null, description: null, fields: [{ name: 'f', type: 'text', required: null, reference: null, options: null, summaryOperations: null }] },
246288
],
247289
};
248290
// `f` is missing the (nullable, required) `label` key.
291+
delete (badField.objects[0].fields[0] as { label?: unknown }).label;
249292
expect(() => SolutionBlueprintStrictSchema.parse(badField)).toThrow();
250293
});
251294

295+
it('carries summaryOperations on a strict field, with the predicate as a flat conditions ARRAY', () => {
296+
// The design step's structured output is the ONLY place a conditional
297+
// roll-up can be designed — the aggregation is known there ("已完成任务数
298+
// counts only completed tasks") and roll-ups only recompute on child writes,
299+
// so a config bolted on after the build's sample data loaded stays empty.
300+
// Strict mode cannot express the canonical `filter` MAP, hence `conditions`.
301+
const parsed = SolutionBlueprintStrictSchema.parse({
302+
...strictBp,
303+
objects: [
304+
{
305+
name: 'project',
306+
label: null,
307+
description: null,
308+
fields: [
309+
{
310+
name: 'task_total', label: '任务总数', type: 'summary', required: null, reference: null, options: null,
311+
summaryOperations: { object: 'task', function: 'count', field: 'id', relationshipField: null, conditions: null },
312+
},
313+
{
314+
name: 'completed_task_count', label: '已完成任务数', type: 'summary', required: null, reference: null, options: null,
315+
summaryOperations: {
316+
object: 'task', function: 'count', field: 'id', relationshipField: null,
317+
conditions: [{ field: 'status', op: 'eq', value: 'completed' }],
318+
},
319+
},
320+
],
321+
},
322+
],
323+
});
324+
expect(parsed.objects[0].fields[0].summaryOperations).toMatchObject({ object: 'task', function: 'count' });
325+
expect(parsed.objects[0].fields[0].summaryOperations?.conditions).toBeNull();
326+
expect(parsed.objects[0].fields[1].summaryOperations?.conditions).toEqual([
327+
{ field: 'status', op: 'eq', value: 'completed' },
328+
]);
329+
});
330+
252331
it('drops the un-strict-able seedData record (OpenAI strict cannot represent open key/value maps)', () => {
253332
expect('seedData' in SolutionBlueprintStrictSchema.shape).toBe(false);
254333
});

0 commit comments

Comments
 (0)