-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathsolution-blueprint.zod.ts
More file actions
266 lines (245 loc) · 17.9 KB
/
Copy pathsolution-blueprint.zod.ts
File metadata and controls
266 lines (245 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { z } from 'zod';
import { lazySchema } from '../shared/lazy-schema';
import { FieldType } from '../data/field.zod';
/**
* Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring)
*
* The structured-output target an AI agent emits for a *high-level* goal
* ("build me a project-management system") instead of transcribing a field
* list. It is a **simplified proposal shape** — deliberately lighter than the
* full {@link ObjectSchema} / {@link ViewSchema} / {@link DashboardSchema}.
* The `apply_blueprint` tool expands each entry into a proper metadata body
* and stages it as a draft (so the per-type Zod schema still validates the
* real artifact at write time).
*
* The blueprint is **never persisted on its own**: the agent presents it for
* conversational confirmation/edit (cheap), and only on human approval does it
* batch-draft. This is the safety valve for low-specificity input.
*/
const SNAKE_CASE = /^[a-z_][a-z0-9_]*$/;
/**
* A proposed field on a blueprint object. `reference` carries the target
* object for `lookup` / `master_detail` types — relationships are expressed
* inline as reference fields rather than in a separate block.
*/
export const BlueprintFieldSchema = lazySchema(() => z.object({
name: z.string().regex(SNAKE_CASE).describe('Field machine name (snake_case)'),
label: z.string().optional().describe('Human-readable field label'),
type: FieldType.describe('Field data type'),
required: z.boolean().optional().describe('Whether the field is required'),
reference: z.string().regex(SNAKE_CASE).optional()
.describe('Target object name for lookup / master_detail relationship fields'),
options: z.array(z.object({
label: z.string(),
value: z.string().regex(SNAKE_CASE),
})).optional().describe('Choices for select / multiselect / radio fields'),
}));
export type BlueprintField = z.infer<typeof BlueprintFieldSchema>;
/** A proposed business object (table) with its fields. */
export const BlueprintObjectSchema = lazySchema(() => z.object({
name: z.string().regex(SNAKE_CASE).describe('Object machine name (snake_case)'),
label: z.string().optional().describe('Human-readable singular label'),
description: z.string().optional().describe('What this object represents'),
fields: z.array(BlueprintFieldSchema).describe('Fields to create on the object'),
nameField: z.string().regex(SNAKE_CASE).optional()
.describe('The record title field — which field holds the human-readable name shown on cards, lookup chips, breadcrumbs and search (ADR-0079). Set it to the object\'s text label field (e.g. "product_name"). For a numbered entity (invoice/ticket), set it to a formula field that composes number + name (e.g. "{order_no} · {customer}"). Omitting it lets the platform auto-pick a text field, but declaring it is strongly preferred.'),
}));
export type BlueprintObject = z.infer<typeof BlueprintObjectSchema>;
/** A proposed list/form/kanban/calendar/gallery/gantt view over an object. */
export const BlueprintViewSchema = lazySchema(() => z.object({
object: z.string().regex(SNAKE_CASE).describe('Object this view displays (snake_case)'),
name: z.string().regex(SNAKE_CASE).describe('View machine name (snake_case)'),
label: z.string().optional().describe('Human-readable view label'),
type: z.enum(['list', 'form', 'kanban', 'calendar', 'gallery', 'gantt']).default('list')
.describe('View kind. Pick the surface that fits the data: "gallery" for a visual card/cover browse when the user asks for a 画廊/相册/卡片墙/封面/海报/图集 (a gallery / card wall / cover / poster grid) or the object has an image/avatar/file field worth showing as a card cover; "gantt" for a 甘特图/时间线/排期 (timeline / schedule) when the object has BOTH a start and an end date field; "kanban" for a board grouped by a status/select field; "calendar" for a single-date schedule; "form" for a record editor; else "list".'),
columns: z.array(z.string().regex(SNAKE_CASE)).optional()
.describe('Field names shown as columns (in order). For a gallery, INCLUDE the image/avatar/file field (it becomes the card cover); for a gantt, INCLUDE the start date column before the end date column.'),
groupBy: z.string().regex(SNAKE_CASE).optional()
.describe('REQUIRED for kanban views: the select/status field whose options become the board columns (e.g. "stage", "status"). Without it a kanban renders as a plain list. Optional for gantt (groups leaf tasks into summary rows).'),
}));
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)'),
label: z.string().optional().describe('Human-readable dashboard label'),
widgets: z.array(z.object({
id: z.string().regex(SNAKE_CASE).describe('Widget id (snake_case)'),
title: z.string().optional().describe('Widget title'),
object: z.string().regex(SNAKE_CASE).optional().describe('Source object for the widget'),
chart: z.enum(['metric', 'bar', 'line', 'pie', 'table']).optional().describe('Widget visualization'),
measure: z.string().regex(SNAKE_CASE).optional()
.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>;
/**
* A proposed navigation item in the blueprint app — points at one of the
* created objects or dashboards. `apply_blueprint` expands it into the full
* `AppSchema` nav item (object → list view, dashboard → dashboard view).
*/
export const BlueprintNavItemSchema = lazySchema(() => z.object({
type: z.enum(['object', 'dashboard']).default('object').describe('What this nav entry opens'),
target: z.string().regex(SNAKE_CASE).describe('Object or dashboard machine name to surface (snake_case)'),
label: z.string().optional().describe('Nav entry label (defaults to the target label/name)'),
icon: z.string().optional().describe('Lucide icon name for the nav entry'),
}));
export type BlueprintNavItem = z.infer<typeof BlueprintNavItemSchema>;
/**
* The navigation shell (the thing end users open in the App Launcher) that
* surfaces the solution. When `nav` is omitted, `apply_blueprint` auto-builds
* one nav entry per created object (then per dashboard).
*/
export const BlueprintAppSchema = lazySchema(() => z.object({
name: z.string().regex(SNAKE_CASE).describe('App machine name (snake_case)'),
label: z.string().optional().describe('App display label'),
icon: z.string().optional().describe('Lucide icon for the App Launcher'),
nav: z.array(BlueprintNavItemSchema).optional()
.describe('Navigation entries; omit to auto-surface every created object and dashboard'),
}));
export type BlueprintApp = z.infer<typeof BlueprintAppSchema>;
/**
* Seed data the agent suggests. Mirrors {@link SeedSchema.records}. NOTE:
* Phase C does NOT auto-apply seed data — there is no runtime-draftable
* `dataset` metadata type (seed = code-loaded `*.seed.ts`). `apply_blueprint`
* reports it as "proposed, not applied" so a human can wire it deliberately.
*/
export const BlueprintSeedSchema = lazySchema(() => z.object({
object: z.string().regex(SNAKE_CASE).describe('Target object name (snake_case)'),
records: z.array(z.record(z.string(), z.unknown())).describe('Rows to seed'),
}));
export type BlueprintSeed = z.infer<typeof BlueprintSeedSchema>;
/**
* The full plan-first blueprint. `assumptions` state the design choices the
* agent made from an underspecified goal; `questions` (≤2) are the only
* 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'),
assumptions: z.array(z.string()).default([])
.describe('Design assumptions made from the underspecified goal'),
questions: z.array(z.string()).max(2).optional()
.describe('At most 1-2 structure-deciding questions to confirm before building'),
objects: z.array(BlueprintObjectSchema).describe('Objects (tables) to create'),
views: z.array(BlueprintViewSchema).optional().describe('Views to create'),
dashboards: z.array(BlueprintDashboardSchema).optional().describe('Dashboards to create'),
app: BlueprintAppSchema.optional()
.describe('The navigation shell (app) that surfaces the created objects/dashboards to end users'),
seedData: z.array(BlueprintSeedSchema).optional()
.describe('Suggested seed data (reported, not auto-applied in Phase C)'),
}));
export type SolutionBlueprint = z.infer<typeof SolutionBlueprintSchema>;
/**
* Factory mirroring `defineAgent` / `defineTool` / `defineSkill`: validates a
* blueprint literal at authoring time and returns the parsed value.
*/
export function defineSolutionBlueprint(config: z.input<typeof SolutionBlueprintSchema>): SolutionBlueprint {
return SolutionBlueprintSchema.parse(config);
}
// ---------------------------------------------------------------------------
// Strict structured-output mirror (OpenAI / Vercel AI Gateway)
//
// OpenAI's *strict* structured outputs (what `generateObject` uses through the
// gateway) require that EVERY property is listed in `required` and reject
// open-ended `additionalProperties` (i.e. `z.record`). The authoring schema
// above is deliberately lenient (optional fields, a free-form `seedData`
// record), which OpenAI rejects with:
// "'required' … must include every key in properties. Missing 'label'."
//
// This mirror expresses the SAME shape in a strict-compatible way — every key
// present, "optional" → `.nullable()`, and the un-representable `seedData`
// record dropped (Phase C only *reports* seed data; it never applies it, and
// the agent can still describe it in prose). It is used ONLY as the
// `generateObject` output contract. The model emits `null` for empty fields;
// the blueprint tools strip those nulls so the lenient {@link
// SolutionBlueprintSchema} (and every existing consumer/test) is unchanged.
// ---------------------------------------------------------------------------
const StrictField = z.object({
name: z.string().describe('Field machine name (snake_case)'),
label: z.string().nullable().describe('Human-readable field label, or null'),
type: FieldType.describe('Field data type'),
required: z.boolean().nullable().describe('Whether the field is required, or null'),
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'),
});
const StrictObject = z.object({
name: z.string().describe('Object machine name (snake_case)'),
label: z.string().nullable().describe('Human-readable singular label, or null'),
description: z.string().nullable().describe('What this object represents, or null'),
fields: z.array(StrictField).describe('Fields to create on the object'),
});
const StrictView = z.object({
object: z.string().describe('Object this view displays (snake_case)'),
name: z.string().describe('View machine name (snake_case)'),
label: z.string().nullable().describe('Human-readable view label, or null'),
type: z.enum(['list', 'form', 'kanban', 'calendar', 'gallery', 'gantt']).nullable().describe('View kind, or null for list. "gallery" = visual card/cover browse (画廊/相册/卡片墙/封面/海报, or an object with an image/avatar/file field); "gantt" = timeline/schedule (甘特图/时间线/排期, object with BOTH a start and an end date field); "kanban" = board grouped by a status/select field; "calendar" = single-date schedule; "form" = record editor.'),
columns: z.array(z.string()).nullable().describe('Field names shown as columns, or null. For a gallery, INCLUDE the image/avatar/file field (becomes the card cover); for a gantt, INCLUDE the start date column before the end date column.'),
groupBy: z.string().nullable().describe('REQUIRED for kanban: the select/status field whose options become the board columns (e.g. "stage"). Optional for gantt (groups leaf tasks). Null for list/form/calendar/gallery.'),
});
const StrictDashboard = z.object({
name: z.string().describe('Dashboard machine name (snake_case)'),
label: z.string().nullable().describe('Human-readable dashboard label, or null'),
widgets: z.array(z.object({
id: z.string().describe('Widget id (snake_case)'),
title: z.string().nullable().describe('Widget title, or null'),
object: z.string().nullable().describe('Source object, or null'),
chart: z.enum(['metric', 'bar', 'line', 'pie', 'table']).nullable().describe('Visualization, or null'),
measure: z.string().nullable()
.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'),
});
const StrictNavItem = z.object({
type: z.enum(['object', 'dashboard']).describe('What this nav entry opens'),
target: z.string().describe('Object or dashboard machine name to surface (snake_case)'),
label: z.string().nullable().describe('Nav entry label, or null'),
icon: z.string().nullable().describe('Lucide icon name, or null'),
});
const StrictApp = z.object({
name: z.string().describe('App machine name (snake_case)'),
label: z.string().nullable().describe('App display label, or null'),
icon: z.string().nullable().describe('Lucide icon for the App Launcher, or null'),
nav: z.array(StrictNavItem).nullable()
.describe('Navigation entries; null to auto-surface every created object and dashboard'),
});
/**
* OpenAI-strict-compatible mirror of {@link SolutionBlueprintSchema}, used only
* as the `generateObject` output contract (see comment above). Validate / apply
* still go through the lenient `SolutionBlueprintSchema`.
*/
export const SolutionBlueprintStrictSchema = z.object({
summary: z.string().describe('One-line description of the proposed solution'),
assumptions: z.array(z.string()).describe('Design assumptions made from the underspecified goal'),
questions: z.array(z.string()).nullable()
.describe('At most 1-2 structure-deciding questions to confirm before building, or null'),
objects: z.array(StrictObject).describe('Objects (tables) to create'),
views: z.array(StrictView).nullable().describe('Views to create, or null'),
dashboards: z.array(StrictDashboard).nullable().describe('Dashboards to create, or null'),
app: StrictApp.nullable()
.describe('The navigation shell (app) that surfaces the created objects/dashboards, or null'),
});
export type SolutionBlueprintStrict = z.infer<typeof SolutionBlueprintStrictSchema>;