1- using System . Text . Json . Nodes ;
1+ using System . ComponentModel ;
2+ using System . Reflection ;
3+ using System . Text . Json . Nodes ;
24using System . Text . Json . Schema ;
35using System . Text . Json . Serialization ;
46using System . Text . Json . Serialization . Metadata ;
@@ -7,151 +9,120 @@ namespace CSharpToJsonSchema;
79
810public static class SchemaBuilder
911{
10- /// <summary>
11- /// Converts a JSON document that contains valid json schema <see href="https://json-schema.org/specification"/> as e.g.
12- /// generated by <code>Microsoft.Extensions.AI.AIJsonUtilities.CreateJsonSchema</code> or <code>JsonSchema.Net</code>'s
13- /// <see cref="JsonSchemaBuilder"/> to a subset that is compatible with LLM's APIs.
14- /// </summary>
15- /// <param name="constructedSchema">Generated, valid json schema.</param>
16- /// <returns>Subset of the given json schema in a LLM-compatible format.</returns>
17- public static OpenApiSchema ConvertToCompatibleSchemaSubset ( JsonNode node )
18- {
19- ConvertNullableProperties ( node ) ;
20- var x1 = node ;
21- var x2 = x1 . ToJsonString ( ) ;
22- var schema = JsonSerializer . Deserialize ( x2 , OpenApiSchemaJsonContext . Default . OpenApiSchema ) ;
23- return schema ;
24- }
25-
26- private static void ConvertNullableProperties ( JsonNode ? node )
27- {
28- // If the node is an object, look for a "type" property or nested definitions
29- if ( node is JsonObject obj )
30- {
31- // If "type" is an array, remove "null" and collapse if it leaves only one type
32- if ( obj . TryGetPropertyValue ( "type" , out var typeValue ) && typeValue is JsonArray array )
33- {
34- if ( array . Count == 2 )
35- {
36- var notNullTypes = array . Where ( x => x is not null && x . GetValue < string > ( ) != "null" ) . ToList ( ) ;
37- if ( notNullTypes . Count == 1 )
38- {
39- obj [ "type" ] = notNullTypes [ 0 ] ! . GetValue < string > ( ) ;
40- obj [ "nullable" ] = true ;
41- }
42- else
43- {
44- throw new InvalidOperationException (
45- $ "LLM's API for strucutured output requires every property to have one defined type, not multiple options. Path: { obj . GetPath ( ) } Schema: { obj . ToJsonString ( ) } ") ;
46- }
47- }
48- else if ( array . Count > 2 )
49- {
50- throw new InvalidOperationException (
51- $ "LLM's API for strucutured output requires every property to have one defined type, not multiple options. Path: { obj . GetPath ( ) } Schema: { obj . ToJsonString ( ) } ") ;
52- }
53- }
54-
55- // Recursively convert any nested schema in "properties"
56- if ( obj . TryGetPropertyValue ( "properties" , out var propertiesNode ) &&
57- propertiesNode is JsonObject propertiesObj )
58- {
59- foreach ( var property in propertiesObj )
60- {
61- ConvertNullableProperties ( property . Value ) ;
62- }
63- }
64-
65- if ( obj . TryGetPropertyValue ( "type" , out var newTypeValue )
66- && newTypeValue is JsonNode
67- && newTypeValue . GetValueKind ( ) == JsonValueKind . String
68- && "object" . Equals ( newTypeValue . GetValue < string > ( ) , StringComparison . OrdinalIgnoreCase )
69- && propertiesNode is not JsonObject )
70- {
71- throw new InvalidOperationException (
72- $ "LLM's API for strucutured output requires every object to have predefined properties. Notably, it does not support dictionaries. Path: { obj . GetPath ( ) } Schema: { obj . ToJsonString ( ) } ") ;
73- }
74-
75- // Recursively convert any nested schema in "items"
76- if ( obj . TryGetPropertyValue ( "items" , out var itemsNode ) )
77- {
78- ConvertNullableProperties ( itemsNode ) ;
79- }
80- }
81-
82- // If the node is an array, traverse each element
83- if ( node is JsonArray arr )
84- {
85- foreach ( var element in arr )
86- {
87- ConvertNullableProperties ( element ) ;
88- }
89- }
90- }
91-
92- public static OpenApiSchema ConvertToSchema < T > ( JsonSerializerOptions ? jsonOptions = null )
93- {
94- if ( jsonOptions == null && ! JsonSerializer . IsReflectionEnabledByDefault )
95- {
96- throw new InvalidOperationException ( "Please provide a JsonSerializerOptions instance to use in AOT mode." ) ;
97- }
98-
99-
100- var newJsonOptions = new JsonSerializerOptions ( jsonOptions )
101- {
102- NumberHandling = JsonNumberHandling . Strict
103- } ;
104-
105- var typeInfo = newJsonOptions . GetTypeInfo ( typeof ( T ) ) ;
106-
107- return ConvertToCompatibleSchemaSubset ( typeInfo . GetJsonSchemaAsNode ( ) ) ;
108- }
109-
12+
11013 public static OpenApiSchema ConvertToSchema ( JsonTypeInfo type , string descriptionString )
11114 {
11215 var typeInfo = type ;
11316
11417 var dics = JsonSerializer . Deserialize ( descriptionString ,
11518 OpenApiSchemaJsonContext . Default . IDictionaryStringString ) ;
11619 List < string > required = new List < string > ( ) ;
117- var x = ConvertToCompatibleSchemaSubset ( typeInfo . GetJsonSchemaAsNode (
20+ var x = typeInfo . GetJsonSchemaAsNode (
11821 exporterOptions : new JsonSchemaExporterOptions ( )
11922 {
120- TransformSchemaNode = ( a , b ) =>
23+ TransformSchemaNode = ( context , schema ) =>
12124 {
122- if ( a . TypeInfo . Type . IsEnum )
25+ if ( context . TypeInfo . Type . IsEnum )
12326 {
124- b [ "type" ] = "string" ;
27+ schema [ "type" ] = "string" ;
12528 }
126-
127- if ( a . PropertyInfo == null )
128- return b ;
129- var propName = ToCamelCase ( a . PropertyInfo . Name ) ;
130- if ( dics . ContainsKey ( propName ) )
131- {
132- b [ "description" ] = dics [ propName ] ;
133- }
134-
135- return b ;
29+
30+ ExtractDescription ( context , schema , dics ) ;
31+ if ( context . PropertyInfo == null )
32+ return schema ;
33+
34+ return schema ;
13635 } ,
137- } ) ) ;
36+ } ) ;
37+
38+ var schema = JsonSerializer . Deserialize ( x . ToJsonString ( ) , OpenApiSchemaJsonContext . Default . OpenApiSchema ) ;
13839
139-
140- foreach ( var re in x . Properties )
40+ foreach ( var re in schema . Properties )
14141 {
14242 required . Add ( re . Key) ;
14343 }
14444
145- var mainDescription = x . Description ?? ( dics . TryGetValue ( "mainFunction_Desc" , out var desc ) ? desc : "" ) ;
45+ var mainDescription = schema . Description ?? ( dics . TryGetValue ( "mainFunction_Desc" , out var desc ) ? desc : "" ) ;
14646 return new OpenApiSchema ( )
14747 {
14848 Description = mainDescription ,
149- Properties = x . Properties ,
49+ Properties = schema . Properties ,
15050 Required = required ,
15151 Type = "object"
15252 } ;
15353 }
54+
55+ private static void ExtractDescription ( JsonSchemaExporterContext context , JsonNode schema , IDictionary < string , string > dics )
56+ {
57+ // Determine if a type or property and extract the relevant attribute provider.
58+ ICustomAttributeProvider ? attributeProvider = context . PropertyInfo is not null
59+ ? context . PropertyInfo . AttributeProvider
60+ : context . TypeInfo . Type ;
61+
62+ // Look up any description attributes.
63+ DescriptionAttribute ? descriptionAttr = attributeProvider ?
64+ . GetCustomAttributes ( inherit : true )
65+ . Select ( attr => attr as DescriptionAttribute )
66+ . FirstOrDefault ( attr => attr is not null ) ;
67+
68+ var description = descriptionAttr ? . Description ;
69+ if ( string . IsNullOrEmpty ( description ) )
70+ {
71+ if ( context . PropertyInfo is null )
72+ {
73+ var propertyName = ToCamelCase ( context . TypeInfo . Type . Name ) ;
74+ dics . TryGetValue ( propertyName , out description ) ;
75+ }
76+ }
15477
78+ FixType ( schema ) ;
79+
80+ // Apply description attribute to the generated schema.
81+ if ( description is not null )
82+ {
83+ if ( schema is not JsonObject jObj )
84+ {
85+ // Handle the case where the schema is a Boolean.
86+ JsonValueKind valueKind = schema . GetValueKind ( ) ;
87+
88+ schema = jObj = new JsonObject ( ) ;
89+ if ( valueKind is JsonValueKind . False )
90+ {
91+ jObj . Add ( "not" , true ) ;
92+ }
93+ }
94+
95+ jObj . Insert ( 0 , "description" , description ) ;
96+ }
97+ }
98+
99+ private static void FixType ( JsonNode schema )
100+ {
101+ // If "type" is an array, remove "null" and collapse if it leaves only one type
102+ var typeValue = schema [ "type" ] ;
103+ if ( typeValue != null && typeValue is JsonArray array )
104+ {
105+ if ( array . Count == 2 )
106+ {
107+ var notNullTypes = array . Where ( x => x is not null && x . GetValue < string > ( ) != "null" ) . ToList ( ) ;
108+ if ( notNullTypes . Count == 1 )
109+ {
110+ schema [ "type" ] = notNullTypes [ 0 ] ! . GetValue < string > ( ) ;
111+ schema [ "nullable" ] = true ;
112+ }
113+ else
114+ {
115+ throw new InvalidOperationException (
116+ $ "LLM's API for strucutured output requires every property to have one defined type, not multiple options. Path: { schema . GetPath ( ) } Schema: { schema . ToJsonString ( ) } ") ;
117+ }
118+ }
119+ else if ( array . Count > 2 )
120+ {
121+ throw new InvalidOperationException (
122+ $ "LLM's API for strucutured output requires every property to have one defined type, not multiple options. Path: { schema . GetPath ( ) } Schema: { schema . ToJsonString ( ) } ") ;
123+ }
124+ }
125+ }
155126 public static string ToCamelCase ( string str )
156127 {
157128 if ( ! string . IsNullOrEmpty ( str ) && str . Length > 1 )
@@ -161,12 +132,4 @@ public static string ToCamelCase(string str)
161132
162133 return str . ToLowerInvariant ( ) ;
163134 }
164-
165- public static string ConvertToSchema ( Type type , JsonSerializerOptions ? jsonOptions )
166- {
167- var node = jsonOptions . GetJsonSchemaAsNode ( type ) ;
168- var x = ConvertToCompatibleSchemaSubset ( node ) ;
169-
170- return JsonSerializer . Serialize ( x . Properties , OpenApiSchemaJsonContext . Default . IDictionaryStringOpenApiSchema ) ;
171- }
172135}
0 commit comments