-
-
Notifications
You must be signed in to change notification settings - Fork 8
Flatten Schemas for AI Compatibility #633
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -241,7 +241,7 @@ Uses the Mapbox Search Box Text Search API endpoint to power searching for and g | |
| , | ||
| parameters: geospatialQuerySchema, | ||
| execute: async (params: z.infer<typeof geospatialQuerySchema>) => { | ||
| const { queryType, includeMap = true } = params; | ||
| const { queryType, includeMap = true, latitude, longitude } = params; | ||
| console.log('[GeospatialTool] Execute called with:', params, 'and map provider:', mapProvider); | ||
|
Comment on lines
+244
to
245
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle After flattening, Suggested fix- const searchText = (params as any).location || (params as any).query;
+ const searchText =
+ queryType === 'reverse' && latitude !== undefined && longitude !== undefined
+ ? `${latitude},${longitude}`
+ : params.location || params.query;
+
+ if (!searchText) {
+ throw new Error(`'${queryType}' query is not supported by the Gemini grounding path`);
+ }
const prompt = `Find the location for: ${searchText}`;Also applies to: 262-263 🤖 Prompt for AI Agents |
||
|
|
||
| const uiFeedbackStream = createStreamableValue<string>(); | ||
|
|
@@ -275,19 +275,19 @@ Uses the Mapbox Search Box Text Search API endpoint to power searching for and g | |
| // as I don't have a way to inspect it at the moment. | ||
| const place = (gsr as any).results[0].place; | ||
| if (place) { | ||
| const { latitude, longitude } = place.coordinates; | ||
| const { latitude: lat, longitude: lng } = place.coordinates; | ||
| const place_name = place.displayName; | ||
|
|
||
| const mcpData: McpResponse = { | ||
| location: { | ||
| latitude, | ||
| longitude, | ||
| latitude: lat, | ||
| longitude: lng, | ||
| place_name, | ||
| }, | ||
| }; | ||
|
|
||
| if (mapProvider === 'google') { | ||
| mcpData.mapUrl = getGoogleStaticMapUrl(latitude, longitude); | ||
| mcpData.mapUrl = getGoogleStaticMapUrl(lat, lng); | ||
| } | ||
|
|
||
| feedbackMessage = `Found location: ${place_name}`; | ||
|
|
@@ -355,12 +355,18 @@ Uses the Mapbox Search Box Text Search API endpoint to power searching for and g | |
| return { places: [params.origin, params.destination], includeMapPreview: includeMap, mode: params.mode || 'driving' }; | ||
| } | ||
| case 'reverse': { | ||
| if (!params.coordinates) throw new Error("'reverse' query requires coordinates"); | ||
| return { searchText: `${params.coordinates.latitude},${params.coordinates.longitude}`, includeMapPreview: includeMap, maxResults: params.maxResults || 5 }; | ||
| if (latitude === undefined || longitude === undefined) throw new Error("'reverse' query requires latitude and longitude"); | ||
| return { searchText: `${latitude},${longitude}`, includeMapPreview: includeMap, maxResults: params.maxResults || 5 }; | ||
| } | ||
| case 'search': { | ||
| if (!params.query) throw new Error("'search' query requires query"); | ||
| return { searchText: params.query, includeMapPreview: includeMap, maxResults: params.maxResults || 5, ...(params.coordinates && { proximity: `${params.coordinates.latitude},${params.coordinates.longitude}` }), ...(params.radius && { radius: params.radius }) }; | ||
| return { | ||
| searchText: params.query, | ||
| includeMapPreview: includeMap, | ||
| maxResults: params.maxResults || 5, | ||
| ...(latitude !== undefined && longitude !== undefined && { proximity: `${latitude},${longitude}` }), | ||
| ...(params.radius && { radius: params.radius }) | ||
| }; | ||
| } | ||
| case 'geocode': | ||
| case 'map': { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| # AI Schema Compatibility Guidelines | ||
|
|
||
| To ensure maximum compatibility with OpenAI-compatible endpoints (e.g., xAI, Grok, Gemini), all Zod schemas used for structured AI output must follow these rules. | ||
|
|
||
| ## Core Rules | ||
|
|
||
| 1. **Avoid `z.literal()`**: Some endpoints do not support the `const` constraint in JSON Schema generated by `z.literal()`. Use `z.string()` and provide allowed values in the field description. | ||
| 2. **Avoid `z.any()`**: Use explicit union types or more specific schemas to ensure validation reliability. | ||
| 3. **Prefer Flat Optional Fields**: Avoid deeply nested required objects or complex discriminated unions. Instead, use a flat object with optional fields. | ||
| 4. **Use Field Descriptions**: Convey conditional requirements (e.g., "Field X is required if `queryType` is 'search'") via the `.describe()` method on fields. | ||
| 5. **Runtime Reconstruction**: If the frontend or downstream tools require a nested format (like standard GeoJSON), implement a reconstruction step in the application logic (e.g., in server actions) to transform the flat AI output into the desired structure. | ||
|
|
||
| ## Canonical Examples | ||
|
|
||
| ### Flat Schema with Conditional Requirements | ||
| See `lib/schema/geospatial.tsx` for how `queryType` guides the use of other optional top-level fields. | ||
|
|
||
| ### Avoiding Const and Nested GeoJSON | ||
| See `lib/schema/resolution-search.ts` for a flattened GeoJSON structure that avoids `z.literal()` and `z.any()`. | ||
|
|
||
| ### Runtime Reconstruction | ||
|
Comment on lines
+15
to
+21
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add missing blank lines after subheadings (MD022).
Proposed fix ### Flat Schema with Conditional Requirements
+
See `lib/schema/geospatial.tsx` for how `queryType` guides the use of other optional top-level fields.
### Avoiding Const and Nested GeoJSON
+
See `lib/schema/resolution-search.ts` for a flattened GeoJSON structure that avoids `z.literal()` and `z.any()`.
### Runtime Reconstruction
+
See `app/actions.tsx` (specifically the `processResolutionSearch` function) for how flattened features are reconstructed into a standard `FeatureCollection`.🧰 Tools🪛 markdownlint-cli2 (0.22.1)[warning] 15-15: Headings should be surrounded by blank lines (MD022, blanks-around-headings) [warning] 18-18: Headings should be surrounded by blank lines (MD022, blanks-around-headings) [warning] 21-21: Headings should be surrounded by blank lines (MD022, blanks-around-headings) 🤖 Prompt for AI Agents |
||
| See `app/actions.tsx` (specifically the `processResolutionSearch` function) for how flattened features are reconstructed into a standard `FeatureCollection`. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,14 +5,18 @@ import { z } from 'zod'; | |||||||||||||||||||||||||||||||||||||||||||||||||
| // queryType are conveyed to the LLM via the queryType description; runtime | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // behavior in the tool's execute() already tolerates missing fields per | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // queryType, so loosening the schema introduces no new failure modes. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // Compatibility Note: Following the established flattening pattern, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // nested 'coordinates' object has been replaced with top-level 'latitude' | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // and 'longitude' fields. | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| export const geospatialQuerySchema = z.object({ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| queryType: z.enum(['search', 'geocode', 'reverse', 'directions', 'distance', 'map']) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .describe( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "Type of geospatial query. Set the corresponding fields: " + | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "'search' → query (optionally coordinates, radius, maxResults); " + | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "'search' → query (optionally latitude/longitude, radius, maxResults); " + | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "'geocode' → location (optionally maxResults); " + | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "'reverse' → coordinates (optionally maxResults); " + | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "'reverse' → latitude + longitude (optionally maxResults); " + | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "'directions' → origin + destination (optionally mode); " + | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "'distance' → origin + destination (optionally mode); " + | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "'map' → location." | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -25,12 +29,10 @@ export const geospatialQuerySchema = z.object({ | |||||||||||||||||||||||||||||||||||||||||||||||||
| .min(1) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .optional() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .describe("Location to geocode or render as a map (used by 'geocode' and 'map')"), | ||||||||||||||||||||||||||||||||||||||||||||||||||
| coordinates: z.object({ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| latitude: z.number().min(-90).max(90), | ||||||||||||||||||||||||||||||||||||||||||||||||||
| longitude: z.number().min(-180).max(180) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .optional() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .describe("Coordinates (required for 'reverse', optional proximity hint for 'search')"), | ||||||||||||||||||||||||||||||||||||||||||||||||||
| latitude: z.number().min(-90).max(90).optional() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .describe("Latitude for reverse geocoding or map centering (used by 'reverse', optional for 'search')"), | ||||||||||||||||||||||||||||||||||||||||||||||||||
| longitude: z.number().min(-180).max(180).optional() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .describe("Longitude for reverse geocoding or map centering (used by 'reverse', optional for 'search')"), | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+32
to
+35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep the field descriptions aligned with the executor. These descriptions mention map centering, but 🤖 Prompt for AI AgentsRequire Flattening made the two coordinate fields independently optional, so malformed payloads like Suggested fix export const geospatialQuerySchema = z.object({
queryType: z.enum(['search', 'geocode', 'reverse', 'directions', 'distance', 'map'])
@@
includeMap: z.boolean()
.optional()
.default(true)
.describe("Whether to include a map preview/URL in the response"),
-});
+}).superRefine(({ latitude, longitude }, ctx) => {
+ if ((latitude === undefined) !== (longitude === undefined)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: [latitude === undefined ? 'latitude' : 'longitude'],
+ message: 'latitude and longitude must be provided together',
+ });
+ }
+});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| origin: z.string() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .min(1) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .optional() | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,12 @@ | ||
| import { DeepPartial } from 'ai' | ||
| import { z } from 'zod' | ||
|
|
||
| // Simplified schema to reduce nested complexity for better model compatibility. | ||
| // Items are now plain strings instead of objects with a 'query' property. | ||
| export const relatedSchema = z.object({ | ||
| items: z | ||
| .array( | ||
| z.object({ | ||
| query: z.string() | ||
| }) | ||
| ) | ||
| .length(3) | ||
| items: z.array(z.string()).length(3) | ||
| }) | ||
|
|
||
| export type PartialRelated = DeepPartial<typeof relatedSchema> | ||
|
|
||
| export type Related = z.infer<typeof relatedSchema> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filter out falsy items, not just empty strings.
query => query !== ''letsundefined/nullthrough, which can occur for not-yet-filled elements during partialstreamObjectstreaming. That renders a blank button whoseonClicksubmits an emptyrelated_query.suggestions-dropdown.tsxalready uses a truthy filter; align here for consistency.🛡️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents