diff --git a/.changeset/blueprint-summary-operations.md b/.changeset/blueprint-summary-operations.md new file mode 100644 index 0000000000..bf10b1c2a4 --- /dev/null +++ b/.changeset/blueprint-summary-operations.md @@ -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. diff --git a/content/docs/references/ai/solution-blueprint.mdx b/content/docs/references/ai/solution-blueprint.mdx index 17f37014f3..913d1ef64e 100644 --- a/content/docs/references/ai/solution-blueprint.mdx +++ b/content/docs/references/ai/solution-blueprint.mdx @@ -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); @@ -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 @@ -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. | --- @@ -125,6 +139,22 @@ const result = BlueprintApp.parse(data); | **records** | `Record[]` | ✅ | 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 @@ -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 "已完成") | --- @@ -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 | diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 1288c58809..fcedd4911d 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1880,6 +1880,8 @@ "AgentSchema (const)", "BlueprintApp (type)", "BlueprintAppSchema (const)", + "BlueprintCondition (type)", + "BlueprintConditionSchema (const)", "BlueprintDashboard (type)", "BlueprintDashboardSchema (const)", "BlueprintField (type)", @@ -1890,6 +1892,8 @@ "BlueprintObjectSchema (const)", "BlueprintSeed (type)", "BlueprintSeedSchema (const)", + "BlueprintSummaryOperations (type)", + "BlueprintSummaryOperationsSchema (const)", "BlueprintView (type)", "BlueprintViewSchema (const)", "BlueprintWidgetCondition (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index fa7bce48de..fb905f2dd6 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -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", @@ -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", @@ -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", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index fb2d896eaa..4022e4eb8d 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -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", diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts index 74d87c321c..27b4bf732d 100644 --- a/packages/spec/src/ai/solution-blueprint.test.ts +++ b/packages/spec/src/ai/solution-blueprint.test.ts @@ -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', @@ -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', () => { @@ -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 }, ], }, ], @@ -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); }); diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index 8b104fb4e2..790b6c53d8 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { FieldType } from '../data/field.zod'; +import { FilterConditionSchema } from '../data/filter.zod'; /** * Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) @@ -22,6 +23,55 @@ import { FieldType } from '../data/field.zod'; const SNAKE_CASE = /^[a-z_][a-z0-9_]*$/; +/** + * One `field op value` comparison in a blueprint predicate. + * + * Deliberately FLAT — no nested maps — because a blueprint is emitted through + * OpenAI *strict* structured output, which rejects the open-ended + * `additionalProperties` a real {@link FilterConditionSchema} map needs. Both + * places a blueprint scopes a record set (a dashboard widget, a conditional + * roll-up) use this shape; the builder compiles it to the real query filter. + */ +export const BlueprintConditionSchema = lazySchema(() => z.object({ + field: z.string().regex(SNAKE_CASE).describe('Field on the target 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 — for a select field use its option VALUE, never its label (e.g. "completed", not "已完成")'), +})); +export type BlueprintCondition = z.infer; + +/** + * A roll-up (`summary` field) declared IN the blueprint — the aggregation of + * CHILD records onto this parent record that `apply_blueprint` materializes as + * the object field's `summaryOperations`. + * + * Why it lives on the blueprint at all: a `summary` field with no aggregation + * config is runtime-DEAD (the engine's summary index skips it, so it reads + * null/0 everywhere), and roll-ups are only recomputed when a CHILD row is + * written — so a roll-up configured AFTER the build's sample data loaded stays + * empty until someone edits a child. The design phase is where the aggregation + * is actually known ("已完成任务数 counts only completed tasks"), so it must be + * expressible here rather than left to a follow-up patch. + * + * `conditions` is the strict-structured-output-safe way to write the predicate; + * `filter` accepts the canonical query map directly for a hand-authored + * blueprint. Give at most one — `filter` wins when both are present. + */ +export const BlueprintSummaryOperationsSchema = lazySchema(() => z.object({ + object: z.string().regex(SNAKE_CASE) + .describe('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: z.enum(['count', 'sum', 'avg', 'min', 'max']) + .describe('Aggregation: "数量 / 个数 / 计数" → count; "合计 / 总额 / 累计" → sum; "平均" → avg'), + field: z.string().regex(SNAKE_CASE).optional() + .describe('Numeric field on the CHILD object to aggregate. Ignored for "count" (pass "id" or omit it).'), + relationshipField: z.string().regex(SNAKE_CASE).optional() + .describe('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: z.array(BlueprintConditionSchema).optional() + .describe('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: FilterConditionSchema.optional() + .describe('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.'), +})); +export type BlueprintSummaryOperations = z.infer; + /** * A proposed field on a blueprint object. `reference` carries the target * object for `lookup` / `master_detail` types — relationships are expressed @@ -38,6 +88,8 @@ export const BlueprintFieldSchema = lazySchema(() => z.object({ label: z.string(), value: z.string().regex(SNAKE_CASE), })).optional().describe('Choices for select / multiselect / radio fields'), + summaryOperations: BlueprintSummaryOperationsSchema.optional() + .describe('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.'), })); export type BlueprintField = z.infer; @@ -71,12 +123,12 @@ export type BlueprintView = z.infer; * 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. + * + * Alias of {@link BlueprintConditionSchema} (the same `{field, op, value}` shape + * a conditional roll-up uses); kept as its own export/type for the widget call + * sites that name it. */ -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 const BlueprintWidgetConditionSchema = BlueprintConditionSchema; export type BlueprintWidgetCondition = z.infer; /** A proposed dashboard with a few widgets (kept intentionally light). */ @@ -143,7 +195,14 @@ export type BlueprintSeed = z.infer; * structure-deciding clarifications it should ask before proposing. */ export const SolutionBlueprintSchema = lazySchema(() => z.object({ - summary: z.string().describe('One-line description of the proposed solution'), + // OPTIONAL on purpose. The design step (SolutionBlueprintStrictSchema) always + // produces it, but this lenient schema is also what `apply_blueprint` parses + // the model's re-emitted blueprint against — and a purely descriptive + // one-liner must never sink a structurally complete build. It did: a + // hand-authored blueprint that omitted it was rejected with + // `path: "summary"`, which the model read as "the summary FIELDS are + // invalid" and "fixed" by DELETING the roll-up fields (cloud#970). + summary: z.string().optional().describe('One-line description of the proposed solution'), assumptions: z.array(z.string()).default([]) .describe('Design assumptions made from the underspecified goal'), questions: z.array(z.string()).max(2).optional() @@ -185,6 +244,23 @@ export function defineSolutionBlueprint(config: z.input的 count-or-sum) — e.g. [{field:"status",op:"eq",value:"completed"}]. Without it the roll-up counts EVERYTHING and reports a plausible WRONG number.'), +}); + const StrictField = z.object({ name: z.string().describe('Field machine name (snake_case)'), label: z.string().nullable().describe('Human-readable field label, or null'), @@ -193,6 +269,8 @@ const StrictField = z.object({ reference: z.string().nullable().describe('Target object for lookup/master_detail, or null'), options: z.array(z.object({ label: z.string(), value: z.string() })).nullable() .describe('Choices for select-family fields, or null'), + summaryOperations: StrictSummaryOperations.nullable() + .describe('REQUIRED when type is "summary" (a roll-up of child records onto this parent: 任务总数 / 报名人数 / 合计金额 / 已完成任务数); null for every other field type. A "summary" field without it is runtime-dead — it reads 0/empty everywhere.'), }); const StrictObject = z.object({