33
44using System ;
55using System . Collections . Generic ;
6- using System . ComponentModel ;
7- using System . Reflection ;
6+ using System . Diagnostics ;
87using System . Text . Json ;
98using System . Text . Json . Nodes ;
10- using System . Text . RegularExpressions ;
119using System . Threading ;
1210using System . Threading . Tasks ;
1311using Microsoft . Shared . Diagnostics ;
@@ -23,17 +21,6 @@ namespace Microsoft.Extensions.AI;
2321/// <related type="Article" href="https://learn.microsoft.com/dotnet/ai/quickstarts/structured-output">Request a response with structured output.</related>
2422public static partial class ChatClientStructuredOutputExtensions
2523{
26- private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new ( )
27- {
28- IncludeSchemaKeyword = true ,
29- TransformOptions = new AIJsonSchemaTransformOptions
30- {
31- DisallowAdditionalProperties = true ,
32- RequireAllProperties = true ,
33- MoveDefaultKeywordToDescription = true ,
34- } ,
35- } ;
36-
3724 /// <summary>Sends chat messages, requesting a response matching the type <typeparamref name="T"/>.</summary>
3825 /// <param name="chatClient">The <see cref="IChatClient"/>.</param>
3926 /// <param name="messages">The chat content to send.</param>
@@ -161,20 +148,12 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
161148
162149 serializerOptions . MakeReadOnly ( ) ;
163150
164- var schemaElement = AIJsonUtilities . CreateJsonSchema (
165- type : typeof ( T ) ,
166- serializerOptions : serializerOptions ,
167- inferenceOptions : _inferenceOptions ) ;
151+ var responseFormat = ChatResponseFormat . ForJsonSchema < T > ( serializerOptions ) ;
168152
169- bool isWrappedInObject ;
170- JsonElement schema ;
171- if ( SchemaRepresentsObject ( schemaElement ) )
172- {
173- // For object-representing schemas, we can use them as-is
174- isWrappedInObject = false ;
175- schema = schemaElement ;
176- }
177- else
153+ Debug . Assert ( responseFormat . Schema is not null , "ForJsonSchema should always populate Schema" ) ;
154+ var schema = responseFormat . Schema ! . Value ;
155+ bool isWrappedInObject = false ;
156+ if ( ! SchemaRepresentsObject ( schema ) )
178157 {
179158 // For non-object-representing schemas, we wrap them in an object schema, because all
180159 // the real LLM providers today require an object schema as the root. This is currently
@@ -184,10 +163,11 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
184163 {
185164 { "$schema" , "https://json-schema.org/draft/2020-12/schema" } ,
186165 { "type" , "object" } ,
187- { "properties" , new JsonObject { { "data" , JsonElementToJsonNode ( schemaElement ) } } } ,
166+ { "properties" , new JsonObject { { "data" , JsonElementToJsonNode ( schema ) } } } ,
188167 { "additionalProperties" , false } ,
189168 { "required" , new JsonArray ( "data" ) } ,
190169 } , AIJsonUtilities . DefaultOptions . GetTypeInfo ( typeof ( JsonObject ) ) ) ;
170+ responseFormat = ChatResponseFormat . ForJsonSchema ( schema , responseFormat . SchemaName , responseFormat . SchemaDescription ) ;
191171 }
192172
193173 ChatMessage ? promptAugmentation = null ;
@@ -200,10 +180,7 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
200180 {
201181 // When using native structured output, we don't add any additional prompt, because
202182 // the LLM backend is meant to do whatever's needed to explain the schema to the LLM.
203- options . ResponseFormat = ChatResponseFormat . ForJsonSchema (
204- schema ,
205- schemaName : SanitizeMemberName ( typeof ( T ) . Name ) ,
206- schemaDescription : typeof ( T ) . GetCustomAttribute < DescriptionAttribute > ( ) ? . Description ) ;
183+ options . ResponseFormat = responseFormat ;
207184 }
208185 else
209186 {
@@ -213,7 +190,7 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
213190 promptAugmentation = new ChatMessage ( ChatRole . User , $$ """
214191 Respond with a JSON value conforming to the following schema:
215192 ```
216- {{ schema }}
193+ {{ responseFormat . Schema }}
217194 ```
218195 """ ) ;
219196
@@ -222,53 +199,31 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
222199
223200 var result = await chatClient . GetResponseAsync ( messages , options , cancellationToken ) ;
224201 return new ChatResponse < T > ( result , serializerOptions ) { IsWrappedInObject = isWrappedInObject } ;
225- }
226202
227- private static bool SchemaRepresentsObject ( JsonElement schemaElement )
228- {
229- if ( schemaElement . ValueKind is JsonValueKind . Object )
203+ static bool SchemaRepresentsObject ( JsonElement schemaElement )
230204 {
231- foreach ( var property in schemaElement . EnumerateObject ( ) )
205+ if ( schemaElement . ValueKind is JsonValueKind . Object )
232206 {
233- if ( property . NameEquals ( "type"u8 ) )
207+ foreach ( var property in schemaElement . EnumerateObject ( ) )
234208 {
235- return property . Value . ValueKind == JsonValueKind . String
236- && property . Value . ValueEquals ( "object"u8 ) ;
209+ if ( property . NameEquals ( "type"u8 ) )
210+ {
211+ return property . Value . ValueKind == JsonValueKind . String
212+ && property . Value . ValueEquals ( "object"u8 ) ;
213+ }
237214 }
238215 }
239- }
240216
241- return false ;
242- }
217+ return false ;
218+ }
243219
244- private static JsonNode ? JsonElementToJsonNode ( JsonElement element )
245- {
246- return element . ValueKind switch
247- {
248- JsonValueKind . Null => null ,
249- JsonValueKind . Array => JsonArray . Create ( element ) ,
250- JsonValueKind . Object => JsonObject . Create ( element ) ,
251- _ => JsonValue . Create ( element )
252- } ;
220+ static JsonNode ? JsonElementToJsonNode ( JsonElement element ) =>
221+ element . ValueKind switch
222+ {
223+ JsonValueKind . Null => null ,
224+ JsonValueKind . Array => JsonArray . Create ( element ) ,
225+ JsonValueKind . Object => JsonObject . Create ( element ) ,
226+ _ => JsonValue . Create ( element )
227+ } ;
253228 }
254-
255- /// <summary>
256- /// Removes characters from a .NET member name that shouldn't be used in an AI function name.
257- /// </summary>
258- /// <param name="memberName">The .NET member name that should be sanitized.</param>
259- /// <returns>
260- /// Replaces non-alphanumeric characters in the identifier with the underscore character.
261- /// Primarily intended to remove characters produced by compiler-generated method name mangling.
262- /// </returns>
263- private static string SanitizeMemberName ( string memberName ) =>
264- InvalidNameCharsRegex ( ) . Replace ( memberName , "_" ) ;
265-
266- /// <summary>Regex that flags any character other than ASCII digits or letters or the underscore.</summary>
267- #if NET
268- [ GeneratedRegex ( "[^0-9A-Za-z_]" ) ]
269- private static partial Regex InvalidNameCharsRegex ( ) ;
270- #else
271- private static Regex InvalidNameCharsRegex ( ) => _invalidNameCharsRegex ;
272- private static readonly Regex _invalidNameCharsRegex = new ( "[^0-9A-Za-z_]" , RegexOptions . Compiled ) ;
273- #endif
274229}
0 commit comments