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
32 changes: 32 additions & 0 deletions .changeset/blueprint-summary-operations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@objectstack/spec": minor
---

feat(spec): a solution blueprint can declare a roll-up, including a conditional one

`BlueprintFieldSchema` gains `summaryOperations` (object / function / field /
relationshipField + a predicate), in both the lenient schema and the strict
structured-output mirror. Without a slot for it, a blueprint `summary` field
could only ever be proposed as a bare shell: `z.object` strips unknown keys, so
a blueprint that correctly declared `{ type:'summary', summaryOperations:{
object:'task', function:'count', filter:{ status:'completed' } } }` lost that
config at the parse waist and materialized runtime-dead — and the design step,
the one place the aggregation is actually known, had nowhere to put it.

It has to be declarable at design time: the engine recomputes a roll-up only
when a CHILD row is written, so operations added after a build's sample data
loaded leave every parent value empty until someone edits a child.

Strict mode cannot represent the canonical `filter` map (open-ended
`additionalProperties`), so the predicate is a flat `conditions` array of
`{field, op, value}` — the shape a dashboard widget's `condition` already uses;
the lenient schema also accepts a real `filter` map for a hand-authored
blueprint. `BlueprintWidgetConditionSchema` is now an alias of the shared
`BlueprintConditionSchema`.

Also makes the lenient schema's top-level `summary` optional. It is a purely
descriptive one-liner with no structural role, but it is what `apply_blueprint`
parses the model's re-emitted blueprint against — omitting it rejected the whole
build with `path: "summary"`, which an agent read as "the summary FIELDS are
invalid" and repaired by deleting the roll-up fields. The strict design contract
still requires it.
40 changes: 35 additions & 5 deletions content/docs/references/ai/solution-blueprint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ batch-draft. This is the safety valve for low-specificity input.
## TypeScript Usage

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

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


---

## BlueprintCondition

### Properties

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


---

## BlueprintDashboard
Expand Down Expand Up @@ -82,6 +95,7 @@ const result = BlueprintApp.parse(data);
| **required** | `boolean` | optional | Whether the field is required |
| **reference** | `string` | optional | Target object name for lookup / master_detail relationship fields |
| **options** | `{ label: string; value: string }[]` | optional | Choices for select / multiselect / radio fields |
| **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. |


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


---

## BlueprintSummaryOperations

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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. |
| **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` | ✅ | Aggregation: "数量 / 个数 / 计数" → count; "合计 / 总额 / 累计" → sum; "平均" → avg |
| **field** | `string` | optional | Numeric field on the CHILD object to aggregate. Ignored for "count" (pass "id" or omit it). |
| **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. |
| **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. |
| **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. |


---

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

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


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

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **summary** | `string` | | One-line description of the proposed solution |
| **summary** | `string` | optional | One-line description of the proposed solution |
| **assumptions** | `string[]` | ✅ | Design assumptions made from the underspecified goal |
| **questions** | `string[]` | optional | At most 1-2 structure-deciding questions to confirm before building |
| **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 |
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -1880,6 +1880,8 @@
"AgentSchema (const)",
"BlueprintApp (type)",
"BlueprintAppSchema (const)",
"BlueprintCondition (type)",
"BlueprintConditionSchema (const)",
"BlueprintDashboard (type)",
"BlueprintDashboardSchema (const)",
"BlueprintField (type)",
Expand All @@ -1890,6 +1892,8 @@
"BlueprintObjectSchema (const)",
"BlueprintSeed (type)",
"BlueprintSeedSchema (const)",
"BlueprintSummaryOperations (type)",
"BlueprintSummaryOperationsSchema (const)",
"BlueprintView (type)",
"BlueprintViewSchema (const)",
"BlueprintWidgetCondition (type)",
Expand Down
10 changes: 10 additions & 0 deletions packages/spec/authorable-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
"ai/BlueprintApp:label",
"ai/BlueprintApp:name",
"ai/BlueprintApp:nav",
"ai/BlueprintCondition:field",
"ai/BlueprintCondition:op",
"ai/BlueprintCondition:value",
"ai/BlueprintDashboard:label",
"ai/BlueprintDashboard:name",
"ai/BlueprintDashboard:widgets",
Expand All @@ -49,6 +52,7 @@
"ai/BlueprintField:options",
"ai/BlueprintField:reference",
"ai/BlueprintField:required",
"ai/BlueprintField:summaryOperations",
"ai/BlueprintField:type",
"ai/BlueprintNavItem:icon",
"ai/BlueprintNavItem:label",
Expand All @@ -61,6 +65,12 @@
"ai/BlueprintObject:nameField",
"ai/BlueprintSeed:object",
"ai/BlueprintSeed:records",
"ai/BlueprintSummaryOperations:conditions",
"ai/BlueprintSummaryOperations:field",
"ai/BlueprintSummaryOperations:filter",
"ai/BlueprintSummaryOperations:function",
"ai/BlueprintSummaryOperations:object",
"ai/BlueprintSummaryOperations:relationshipField",
"ai/BlueprintView:columns",
"ai/BlueprintView:groupBy",
"ai/BlueprintView:label",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/json-schema.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
"ai/AIUsageRecord",
"ai/Agent",
"ai/BlueprintApp",
"ai/BlueprintCondition",
"ai/BlueprintDashboard",
"ai/BlueprintField",
"ai/BlueprintNavItem",
"ai/BlueprintObject",
"ai/BlueprintSeed",
"ai/BlueprintSummaryOperations",
"ai/BlueprintView",
"ai/BlueprintWidgetCondition",
"ai/CodeContent",
Expand Down
89 changes: 84 additions & 5 deletions packages/spec/src/ai/solution-blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,48 @@ describe('SolutionBlueprintSchema', () => {
expect(parsed.views?.[0].type).toBe('list');
});

it('keeps summaryOperations (incl. a conditional roll-up) on a summary field', () => {
// z.object STRIPS unknown keys, so before this slot existed a blueprint that
// correctly declared { type:'summary', summaryOperations:{…filter…} } lost the
// config at the parse waist and materialized runtime-dead (cloud#970).
const parsed = SolutionBlueprintSchema.parse({
summary: 'tasks',
objects: [
{
name: 'project',
fields: [
{ name: 'task_total', type: 'summary', summaryOperations: { object: 'task', field: 'id', function: 'count' } },
{
name: 'completed_task_count', type: 'summary',
summaryOperations: { object: 'task', field: 'id', function: 'count', filter: { status: 'completed' } },
},
{
name: 'open_task_count', type: 'summary',
summaryOperations: { object: 'task', function: 'count', conditions: [{ field: 'status', op: 'ne', value: 'completed' }] },
},
],
},
],
});
expect(parsed.objects[0].fields[0].summaryOperations).toEqual({ object: 'task', field: 'id', function: 'count' });
expect(parsed.objects[0].fields[1].summaryOperations?.filter).toEqual({ status: 'completed' });
expect(parsed.objects[0].fields[2].summaryOperations?.conditions).toEqual([
{ field: 'status', op: 'ne', value: 'completed' },
]);
});

it('accepts a blueprint with no top-level `summary` — a prose one-liner must not sink a valid build', () => {
// cloud#970: apply_blueprint parses the model's re-emitted blueprint with this
// schema. A missing `summary` used to hard-fail with `path: "summary"`, which
// the model read as "the summary FIELDS are invalid" and repaired by deleting
// the roll-up fields.
const parsed = SolutionBlueprintSchema.parse({
objects: [{ name: 'thing', fields: [{ name: 'name', type: 'text' }] }],
});
expect(parsed.summary).toBeUndefined();
expect(parsed.objects).toHaveLength(1);
});

it('defaults assumptions to an empty array and view type to list', () => {
const parsed = SolutionBlueprintSchema.parse({
summary: 'minimal',
Expand All @@ -68,9 +110,9 @@ describe('SolutionBlueprintSchema', () => {
expect(parsed.views?.map((v) => v.type)).toEqual(['gallery', 'gantt']);
});

it('rejects a missing summary', () => {
const { summary: _drop, ...noSummary } = validBlueprint;
expect(() => SolutionBlueprintSchema.parse(noSummary)).toThrow();
it('still rejects a missing `objects` — structure is required even though prose is not', () => {
const { objects: _drop, ...noObjects } = validBlueprint;
expect(() => SolutionBlueprintSchema.parse(noObjects)).toThrow();
});

it('rejects an invalid field type', () => {
Expand Down Expand Up @@ -203,7 +245,7 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
label: null,
description: null,
fields: [
{ name: 'name', label: null, type: 'text', required: null, reference: null, options: null },
{ name: 'name', label: null, type: 'text', required: null, reference: null, options: null, summaryOperations: null },
],
},
],
Expand Down Expand Up @@ -242,13 +284,50 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
const badField = {
...strictBp,
objects: [
{ name: 'x', label: null, description: null, fields: [{ name: 'f', type: 'text', required: null, reference: null, options: null }] },
{ name: 'x', label: null, description: null, fields: [{ name: 'f', type: 'text', required: null, reference: null, options: null, summaryOperations: null }] },
],
};
// `f` is missing the (nullable, required) `label` key.
delete (badField.objects[0].fields[0] as { label?: unknown }).label;
expect(() => SolutionBlueprintStrictSchema.parse(badField)).toThrow();
});

it('carries summaryOperations on a strict field, with the predicate as a flat conditions ARRAY', () => {
// The design step's structured output is the ONLY place a conditional
// roll-up can be designed — the aggregation is known there ("已完成任务数
// counts only completed tasks") and roll-ups only recompute on child writes,
// so a config bolted on after the build's sample data loaded stays empty.
// Strict mode cannot express the canonical `filter` MAP, hence `conditions`.
const parsed = SolutionBlueprintStrictSchema.parse({
...strictBp,
objects: [
{
name: 'project',
label: null,
description: null,
fields: [
{
name: 'task_total', label: '任务总数', type: 'summary', required: null, reference: null, options: null,
summaryOperations: { object: 'task', function: 'count', field: 'id', relationshipField: null, conditions: null },
},
{
name: 'completed_task_count', label: '已完成任务数', type: 'summary', required: null, reference: null, options: null,
summaryOperations: {
object: 'task', function: 'count', field: 'id', relationshipField: null,
conditions: [{ field: 'status', op: 'eq', value: 'completed' }],
},
},
],
},
],
});
expect(parsed.objects[0].fields[0].summaryOperations).toMatchObject({ object: 'task', function: 'count' });
expect(parsed.objects[0].fields[0].summaryOperations?.conditions).toBeNull();
expect(parsed.objects[0].fields[1].summaryOperations?.conditions).toEqual([
{ field: 'status', op: 'eq', value: 'completed' },
]);
});

it('drops the un-strict-able seedData record (OpenAI strict cannot represent open key/value maps)', () => {
expect('seedData' in SolutionBlueprintStrictSchema.shape).toBe(false);
});
Expand Down
Loading
Loading