From b79622232acb889047ada899601e04f84786be22 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 11:00:03 +0000 Subject: [PATCH] Flatten schemas and document compatibility guidelines This commit implements schema flattening and documentation to ensure compatibility with OpenAI-compatible endpoints (xAI, Gemini, etc.). Key changes: 1. Created lib/schema/README.md with AI schema compatibility rules. 2. Flattened 'coordinates' into 'latitude' and 'longitude' in geospatialQuerySchema and updated geospatial tool. 3. Simplified relatedSchema from array of objects to array of strings and updated all UI consumers (SearchRelated, SuggestionsDropdown). 4. Fixed type mismatches and build errors resulting from schema changes. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- app/actions.tsx | 2 +- components/search-related-optimized.tsx | 8 ++++---- components/search-related.tsx | 8 ++++---- components/suggestions-dropdown.tsx | 12 ++++++------ lib/agents/query-suggestor.tsx | 2 +- lib/agents/tools/geospatial.tsx | 22 ++++++++++++++-------- lib/schema/README.md | 22 ++++++++++++++++++++++ lib/schema/geospatial.tsx | 18 ++++++++++-------- lib/schema/related.tsx | 11 ++++------- 9 files changed, 66 insertions(+), 39 deletions(-) create mode 100644 lib/schema/README.md diff --git a/app/actions.tsx b/app/actions.tsx index 167d23d4..4cd8b38b 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -30,7 +30,7 @@ import { MapQueryHandler } from '@/components/map/map-query-handler' // Define the type for related queries type RelatedQueries = { - items: { query: string }[] + items: string[] } async function submit(formData?: FormData, skip?: boolean) { diff --git a/components/search-related-optimized.tsx b/components/search-related-optimized.tsx index b2e1c701..3ee2900b 100644 --- a/components/search-related-optimized.tsx +++ b/components/search-related-optimized.tsx @@ -50,8 +50,8 @@ export const SearchRelated: React.FC = React.memo(({ // OPTIMIZATION: Memoize filtered and mapped items const relatedItems = useMemo(() => { return data?.items - ?.filter(item => item?.query !== '') - .map((item, index) => ( + ?.filter(query => query !== '') + .map((query, index) => (
= React.memo(({
)) || [] diff --git a/components/search-related.tsx b/components/search-related.tsx index b2e1c701..3ee2900b 100644 --- a/components/search-related.tsx +++ b/components/search-related.tsx @@ -50,8 +50,8 @@ export const SearchRelated: React.FC = React.memo(({ // OPTIMIZATION: Memoize filtered and mapped items const relatedItems = useMemo(() => { return data?.items - ?.filter(item => item?.query !== '') - .map((item, index) => ( + ?.filter(query => query !== '') + .map((query, index) => (
= React.memo(({
)) || [] diff --git a/components/suggestions-dropdown.tsx b/components/suggestions-dropdown.tsx index 33be6e48..99f23921 100644 --- a/components/suggestions-dropdown.tsx +++ b/components/suggestions-dropdown.tsx @@ -23,7 +23,7 @@ export const SuggestionsDropdown: React.FC = ({ const dropdownRef = useRef(null) const suggestionItems = useMemo( - () => suggestions?.items?.filter(item => item?.query) || [], + () => (suggestions?.items?.filter(query => query) as string[]) || [], [suggestions] ) @@ -47,7 +47,7 @@ export const SuggestionsDropdown: React.FC = ({ } else if (e.key === 'Enter') { e.preventDefault() if (selectedIndex >= 0 && suggestionItems[selectedIndex]) { - onSelect(suggestionItems[selectedIndex].query!) + onSelect(suggestionItems[selectedIndex]) } } else if (e.key === 'Escape') { onClose() @@ -92,8 +92,8 @@ export const SuggestionsDropdown: React.FC = ({ >

Suggestions

- {suggestionItems.map((item, index) => { - if (!item?.query) return null + {suggestionItems.map((query, index) => { + if (!query) return null return ( ) })} diff --git a/lib/agents/query-suggestor.tsx b/lib/agents/query-suggestor.tsx index 7cb8e50c..f7cd53eb 100644 --- a/lib/agents/query-suggestor.tsx +++ b/lib/agents/query-suggestor.tsx @@ -57,7 +57,7 @@ export async function querySuggestor( // OPTIMIZATION: Use a more concise system prompt to reduce token usage const result = await streamObject({ model: (await getModel()) as LanguageModel, - system: `Generate 3 follow-up queries that explore the subject matter deeper. Format as JSON with an "items" array containing objects with "query" fields. Keep queries concise and relevant.`, + system: `Generate 3 follow-up queries that explore the subject matter deeper. Format as JSON with an "items" array containing strings. Keep queries concise and relevant.`, messages, schema: relatedSchema, temperature: 0.7, // Lower temperature for more consistent results diff --git a/lib/agents/tools/geospatial.tsx b/lib/agents/tools/geospatial.tsx index 863f59b5..769ca098 100644 --- a/lib/agents/tools/geospatial.tsx +++ b/lib/agents/tools/geospatial.tsx @@ -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) => { - const { queryType, includeMap = true } = params; + const { queryType, includeMap = true, latitude, longitude } = params; console.log('[GeospatialTool] Execute called with:', params, 'and map provider:', mapProvider); const uiFeedbackStream = createStreamableValue(); @@ -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': { diff --git a/lib/schema/README.md b/lib/schema/README.md new file mode 100644 index 00000000..50644b4d --- /dev/null +++ b/lib/schema/README.md @@ -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 +See `app/actions.tsx` (specifically the `processResolutionSearch` function) for how flattened features are reconstructed into a standard `FeatureCollection`. diff --git a/lib/schema/geospatial.tsx b/lib/schema/geospatial.tsx index c58b7f0a..4c6161e5 100644 --- a/lib/schema/geospatial.tsx +++ b/lib/schema/geospatial.tsx @@ -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')"), origin: z.string() .min(1) .optional() diff --git a/lib/schema/related.tsx b/lib/schema/related.tsx index 77fc3835..80c6d9f7 100644 --- a/lib/schema/related.tsx +++ b/lib/schema/related.tsx @@ -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 export type Related = z.infer