diff --git a/.changeset/tricky-years-pump.md b/.changeset/tricky-years-pump.md new file mode 100644 index 00000000000..94bf68604cc --- /dev/null +++ b/.changeset/tricky-years-pump.md @@ -0,0 +1,6 @@ +--- +'firebase': minor +'@firebase/ai': minor +--- + +Add `title`, `maximum`, `minimum`, `propertyOrdering` to Schema builder diff --git a/common/api-review/ai.api.md b/common/api-review/ai.api.md index d096d4c27f6..922289ab5d0 100644 --- a/common/api-review/ai.api.md +++ b/common/api-review/ai.api.md @@ -831,10 +831,14 @@ export interface SchemaShared { example?: unknown; format?: string; items?: T; + maximum?: number; + minimum?: number; nullable?: boolean; properties?: { [k: string]: T; }; + propertyOrdering?: string[]; + title?: string; } // @public diff --git a/docs-devsite/ai.schemashared.md b/docs-devsite/ai.schemashared.md index eba57f82935..7f0ed27026c 100644 --- a/docs-devsite/ai.schemashared.md +++ b/docs-devsite/ai.schemashared.md @@ -27,8 +27,12 @@ export interface SchemaShared | [example](./ai.schemashared.md#schemasharedexample) | unknown | Optional. The example of the property. | | [format](./ai.schemashared.md#schemasharedformat) | string | Optional. The format of the property. When using the Gemini Developer API ([GoogleAIBackend](./ai.googleaibackend.md#googleaibackend_class)), this must be either 'enum' or 'date-time', otherwise requests will fail. | | [items](./ai.schemashared.md#schemashareditems) | T | Optional. The items of the property. | +| [maximum](./ai.schemashared.md#schemasharedmaximum) | number | The maximum value of a numeric type. | +| [minimum](./ai.schemashared.md#schemasharedminimum) | number | The minimum value of a numeric type. | | [nullable](./ai.schemashared.md#schemasharednullable) | boolean | Optional. Whether the property is nullable. | | [properties](./ai.schemashared.md#schemasharedproperties) | { \[k: string\]: T; } | Optional. Map of Schema objects. | +| [propertyOrdering](./ai.schemashared.md#schemasharedpropertyordering) | string\[\] | A hint suggesting the order in which the keys should appear in the generated JSON string. | +| [title](./ai.schemashared.md#schemasharedtitle) | string | The title of the property. This helps document the schema's purpose but does not typically constrain the generated value. It can subtly guide the model by clarifying the intent of a field. | ## SchemaShared.description @@ -80,6 +84,26 @@ Optional. The items of the property. items?: T; ``` +## SchemaShared.maximum + +The maximum value of a numeric type. + +Signature: + +```typescript +maximum?: number; +``` + +## SchemaShared.minimum + +The minimum value of a numeric type. + +Signature: + +```typescript +minimum?: number; +``` + ## SchemaShared.nullable Optional. Whether the property is nullable. @@ -101,3 +125,23 @@ properties?: { [k: string]: T; }; ``` + +## SchemaShared.propertyOrdering + +A hint suggesting the order in which the keys should appear in the generated JSON string. + +Signature: + +```typescript +propertyOrdering?: string[]; +``` + +## SchemaShared.title + +The title of the property. This helps document the schema's purpose but does not typically constrain the generated value. It can subtly guide the model by clarifying the intent of a field. + +Signature: + +```typescript +title?: string; +``` diff --git a/packages/ai/src/requests/schema-builder.test.ts b/packages/ai/src/requests/schema-builder.test.ts index d05b81381ea..ddf655b738d 100644 --- a/packages/ai/src/requests/schema-builder.test.ts +++ b/packages/ai/src/requests/schema-builder.test.ts @@ -31,11 +31,20 @@ describe('Schema builder', () => { }); }); it('builds integer schema with options and overrides', () => { - const schema = Schema.integer({ nullable: true, format: 'int32' }); + const schema = Schema.integer({ + nullable: true, + format: 'int32', + title: 'Age', + minimum: 0, + maximum: 120 + }); expect(schema.toJSON()).to.eql({ type: 'integer', format: 'int32', - nullable: true + nullable: true, + title: 'Age', + minimum: 0, + maximum: 120 }); }); it('builds number schema', () => { @@ -46,46 +55,60 @@ describe('Schema builder', () => { }); }); it('builds number schema with options and unknown options', () => { - const schema = Schema.number({ format: 'float', futureOption: 'test' }); + const schema = Schema.number({ + format: 'float', + futureOption: 'test', + title: 'Price', + minimum: 0.01, + maximum: 1000.99 + }); expect(schema.toJSON()).to.eql({ type: 'number', format: 'float', futureOption: 'test', - nullable: false + nullable: false, + title: 'Price', + minimum: 0.01, + maximum: 1000.99 }); }); it('builds boolean schema', () => { - const schema = Schema.boolean(); + const schema = Schema.boolean({ title: 'Is Active' }); expect(schema.toJSON()).to.eql({ type: 'boolean', - nullable: false + nullable: false, + title: 'Is Active' }); }); it('builds string schema', () => { - const schema = Schema.string({ description: 'hey' }); + const schema = Schema.string({ description: 'hey', title: 'Greeting' }); expect(schema.toJSON()).to.eql({ type: 'string', description: 'hey', - nullable: false + nullable: false, + title: 'Greeting' }); }); it('builds enumString schema', () => { const schema = Schema.enumString({ example: 'east', - enum: ['east', 'west'] + enum: ['east', 'west'], + title: 'Direction' }); expect(schema.toJSON()).to.eql({ type: 'string', example: 'east', enum: ['east', 'west'], - nullable: false + nullable: false, + title: 'Direction' }); }); it('builds an object schema', () => { const schema = Schema.object({ properties: { 'someInput': Schema.string() - } + }, + title: 'Input Object' }); expect(schema.toJSON()).to.eql({ type: 'object', @@ -96,16 +119,20 @@ describe('Schema builder', () => { nullable: false } }, - required: ['someInput'] + required: ['someInput'], + title: 'Input Object' }); }); - it('builds an object schema with optional properties', () => { + it('builds an object schema with optional properties and propertyOrdering', () => { const schema = Schema.object({ properties: { 'someInput': Schema.string(), - 'someBool': Schema.boolean() + 'someBool': Schema.boolean(), + 'anotherInput': Schema.integer() }, - optionalProperties: ['someBool'] + optionalProperties: ['someBool'], + propertyOrdering: ['someInput', 'anotherInput', 'someBool'], + title: 'Ordered Object' }); expect(schema.toJSON()).to.eql({ type: 'object', @@ -118,9 +145,15 @@ describe('Schema builder', () => { 'someBool': { type: 'boolean', nullable: false + }, + 'anotherInput': { + type: 'integer', + nullable: false } }, - required: ['someInput'] + required: ['someInput', 'anotherInput'], + propertyOrdering: ['someInput', 'anotherInput', 'someBool'], + title: 'Ordered Object' }); }); it('builds layered schema - partially filled out', () => { @@ -128,82 +161,162 @@ describe('Schema builder', () => { items: Schema.object({ properties: { country: Schema.string({ - description: 'A country name' + description: 'A country name', + title: 'Country Name' }), - population: Schema.integer(), + population: Schema.integer({ title: 'Population Count', minimum: 0 }), coordinates: Schema.object({ + title: 'Geographical Coordinates', properties: { - latitude: Schema.number({ format: 'float' }), - longitude: Schema.number({ format: 'double' }) + latitude: Schema.number({ format: 'float', title: 'Latitude' }), + longitude: Schema.number({ format: 'double', title: 'Longitude' }) } }), hemisphere: Schema.object({ + title: 'Hemisphere Information', properties: { - latitudinal: Schema.enumString({ enum: ['N', 'S'] }), - longitudinal: Schema.enumString({ enum: ['E', 'W'] }) + latitudinal: Schema.enumString({ + enum: ['N', 'S'], + title: 'Latitudinal Hemisphere' + }), + longitudinal: Schema.enumString({ + enum: ['E', 'W'], + title: 'Longitudinal Hemisphere' + }) } }), - isCapital: Schema.boolean() + isCapital: Schema.boolean({ title: 'Is Capital City' }) } - }) + }), + title: 'List of Countries' }); - expect(schema.toJSON()).to.eql(layeredSchemaOutputPartial); + const jsonSchema = schema.toJSON(); + expect(jsonSchema.title).to.equal('List of Countries'); + expect(jsonSchema.items?.properties?.country.title).to.equal( + 'Country Name' + ); + expect(jsonSchema.items?.properties?.population.title).to.equal( + 'Population Count' + ); + expect(jsonSchema.items?.properties?.population.minimum).to.equal(0); + expect(jsonSchema.items?.properties?.coordinates.title).to.equal( + 'Geographical Coordinates' + ); + expect(jsonSchema.items?.properties?.hemisphere.title).to.equal( + 'Hemisphere Information' + ); + expect(jsonSchema.items?.properties?.isCapital.title).to.equal( + 'Is Capital City' + ); }); - it('builds layered schema - fully filled out', () => { + it('builds layered schema - fully filled out with new properties', () => { const schema = Schema.array({ + title: 'Detailed Country Profiles', items: Schema.object({ description: 'A country profile', nullable: false, + title: 'Country Profile', + propertyOrdering: [ + 'country', + 'population', + 'isCapital', + 'elevation', + 'coordinates', + 'hemisphere' + ], properties: { country: Schema.string({ nullable: false, description: 'Country name', - format: undefined + format: undefined, + title: 'Official Country Name' }), population: Schema.integer({ nullable: false, description: 'Number of people in country', - format: 'int64' + format: 'int64', + title: 'Total Population', + minimum: 1 }), coordinates: Schema.object({ nullable: false, description: 'Latitude and longitude', + title: 'Capital Coordinates', properties: { latitude: Schema.number({ nullable: false, description: 'Latitude of capital', - format: 'float' + format: 'float', + title: 'Latitude Value', + minimum: -90, + maximum: 90 }), longitude: Schema.number({ nullable: false, description: 'Longitude of capital', - format: 'double' + format: 'double', + title: 'Longitude Value', + minimum: -180, + maximum: 180 }) } }), hemisphere: Schema.object({ nullable: false, description: 'Hemisphere(s) country is in', + title: 'Geographical Hemispheres', properties: { - latitudinal: Schema.enumString({ enum: ['N', 'S'] }), - longitudinal: Schema.enumString({ enum: ['E', 'W'] }) + latitudinal: Schema.enumString({ + enum: ['N', 'S'], + title: 'Latitudinal' + }), + longitudinal: Schema.enumString({ + enum: ['E', 'W'], + title: 'Longitudinal' + }) } }), isCapital: Schema.boolean({ nullable: false, - description: "This doesn't make a lot of sense but it's a demo" + description: "This doesn't make a lot of sense but it's a demo", + title: 'Is it a capital?' }), elevation: Schema.integer({ nullable: false, - description: 'Average elevation', - format: 'float' + description: 'Average elevation in meters', + format: 'int32', + title: 'Average Elevation (m)', + minimum: -500, + maximum: 9000 }) }, optionalProperties: [] }) }); - expect(schema.toJSON()).to.eql(layeredSchemaOutput); + const jsonResult = schema.toJSON(); + expect(jsonResult.title).to.equal('Detailed Country Profiles'); + expect(jsonResult.items?.title).to.equal('Country Profile'); + expect(jsonResult.items?.propertyOrdering).to.deep.equal([ + 'country', + 'population', + 'isCapital', + 'elevation', + 'coordinates', + 'hemisphere' + ]); + expect(jsonResult.items?.properties?.population.title).to.equal( + 'Total Population' + ); + expect(jsonResult.items?.properties?.population.minimum).to.equal(1); + expect( + jsonResult.items?.properties?.coordinates.properties?.latitude.maximum + ).to.equal(90); + expect(jsonResult.items?.properties?.elevation.title).to.equal( + 'Average Elevation (m)' + ); + expect(jsonResult.items?.properties?.elevation.minimum).to.equal(-500); + expect(jsonResult.items?.properties?.elevation.maximum).to.equal(9000); }); it('can override "nullable" and set optional properties', () => { const schema = Schema.object({ @@ -245,149 +358,53 @@ describe('Schema builder', () => { }); expect(() => schema.toJSON()).to.throw(AIErrorCode.INVALID_SCHEMA); }); -}); + it('builds schema with minimum and maximum for integer', () => { + const schema = Schema.integer({ minimum: 5, maximum: 10, title: 'Rating' }); + expect(schema.toJSON()).to.eql({ + type: 'integer', + nullable: false, + minimum: 5, + maximum: 10, + title: 'Rating' + }); + }); -const layeredSchemaOutputPartial = { - 'type': 'array', - 'nullable': false, - 'items': { - 'type': 'object', - 'nullable': false, - 'properties': { - 'country': { - 'type': 'string', - 'description': 'A country name', - 'nullable': false - }, - 'population': { - 'type': 'integer', - 'nullable': false - }, - 'coordinates': { - 'type': 'object', - 'nullable': false, - 'properties': { - 'latitude': { - 'type': 'number', - 'format': 'float', - 'nullable': false - }, - 'longitude': { - 'type': 'number', - 'format': 'double', - 'nullable': false - } - }, - 'required': ['latitude', 'longitude'] - }, - 'hemisphere': { - 'type': 'object', - 'nullable': false, - 'properties': { - 'latitudinal': { - 'type': 'string', - 'nullable': false, - 'enum': ['N', 'S'] - }, - 'longitudinal': { - 'type': 'string', - 'nullable': false, - 'enum': ['E', 'W'] - } - }, - 'required': ['latitudinal', 'longitudinal'] - }, - 'isCapital': { - 'type': 'boolean', - 'nullable': false - } - }, - 'required': [ - 'country', - 'population', - 'coordinates', - 'hemisphere', - 'isCapital' - ] - } -}; + it('builds schema with minimum and maximum for number', () => { + const schema = Schema.number({ + minimum: 1.5, + maximum: 9.9, + title: 'Measurement' + }); + expect(schema.toJSON()).to.eql({ + type: 'number', + nullable: false, + minimum: 1.5, + maximum: 9.9, + title: 'Measurement' + }); + }); -const layeredSchemaOutput = { - 'type': 'array', - 'nullable': false, - 'items': { - 'type': 'object', - 'description': 'A country profile', - 'nullable': false, - 'required': [ - 'country', - 'population', - 'coordinates', - 'hemisphere', - 'isCapital', - 'elevation' - ], - 'properties': { - 'country': { - 'type': 'string', - 'description': 'Country name', - 'nullable': false - }, - 'population': { - 'type': 'integer', - 'format': 'int64', - 'description': 'Number of people in country', - 'nullable': false - }, - 'coordinates': { - 'type': 'object', - 'description': 'Latitude and longitude', - 'nullable': false, - 'required': ['latitude', 'longitude'], - 'properties': { - 'latitude': { - 'type': 'number', - 'format': 'float', - 'description': 'Latitude of capital', - 'nullable': false - }, - 'longitude': { - 'type': 'number', - 'format': 'double', - 'description': 'Longitude of capital', - 'nullable': false - } - } - }, - 'hemisphere': { - 'type': 'object', - 'description': 'Hemisphere(s) country is in', - 'nullable': false, - 'required': ['latitudinal', 'longitudinal'], - 'properties': { - 'latitudinal': { - 'type': 'string', - 'nullable': false, - 'enum': ['N', 'S'] - }, - 'longitudinal': { - 'type': 'string', - 'nullable': false, - 'enum': ['E', 'W'] - } - } + it('builds object schema with propertyOrdering', () => { + const schema = Schema.object({ + title: 'User Data', + properties: { + name: Schema.string(), + age: Schema.integer(), + email: Schema.string() }, - 'isCapital': { - 'type': 'boolean', - 'description': "This doesn't make a lot of sense but it's a demo", - 'nullable': false + propertyOrdering: ['name', 'email', 'age'] + }); + expect(schema.toJSON()).to.eql({ + type: 'object', + nullable: false, + title: 'User Data', + properties: { + name: { type: 'string', nullable: false }, + age: { type: 'integer', nullable: false }, + email: { type: 'string', nullable: false } }, - 'elevation': { - 'type': 'integer', - 'format': 'float', - 'description': 'Average elevation', - 'nullable': false - } - } - } -}; + required: ['name', 'age', 'email'], + propertyOrdering: ['name', 'email', 'age'] + }); + }); +}); diff --git a/packages/ai/src/types/schema.ts b/packages/ai/src/types/schema.ts index e9fe9286b61..9cfdfad654b 100644 --- a/packages/ai/src/types/schema.ts +++ b/packages/ai/src/types/schema.ts @@ -49,18 +49,30 @@ export interface SchemaShared { format?: string; /** Optional. The description of the property. */ description?: string; + /** + * The title of the property. This helps document the schema's purpose but does not typically + * constrain the generated value. It can subtly guide the model by clarifying the intent of a + * field. + */ + title?: string; /** Optional. The items of the property. */ items?: T; /** Optional. Map of `Schema` objects. */ properties?: { [k: string]: T; }; + /** A hint suggesting the order in which the keys should appear in the generated JSON string. */ + propertyOrdering?: string[]; /** Optional. The enum of the property. */ enum?: string[]; /** Optional. The example of the property. */ example?: unknown; /** Optional. Whether the property is nullable. */ nullable?: boolean; + /** The minimum value of a numeric type. */ + minimum?: number; + /** The maximum value of a numeric type. */ + maximum?: number; [key: string]: unknown; }