fix(antigravity): address code review feedback on schema cleaner

- Fix test assertion target location for whereItems prefixItems
- Support const keyword normalization to enum and infer scalar type
- Infer scalar type for enum-only schema to prevent object reason injection
- Recursively clean properties and items after anyOf/oneOf merge
- Ensure empty prefixItems is safely deleted
This commit is contained in:
郭兰鑫
2026-09-13 03:57:18 +08:00
parent 5d781ee6b7
commit 69d49744cf
2 changed files with 180 additions and 12 deletions
@@ -120,16 +120,21 @@ func cleanJSONSchemaRecursive(value any) any {
mergeAllOf(schemaMap)
// 0.5 [FIX] 处理 Draft 2020-12 的 prefixItems (元组定义)
if prefixItems, ok := schemaMap["prefixItems"].([]any); ok && len(prefixItems) > 0 {
itemsVal, hasItems := schemaMap["items"]
_, itemsIsBool := itemsVal.(bool)
if !hasItems || itemsVal == nil || itemsIsBool {
best := extractBestSchemaFromUnion(prefixItems)
if best == nil {
best = map[string]any{"type": "string"}
if prefixItemsVal, hasPrefix := schemaMap["prefixItems"]; hasPrefix {
if prefixItems, ok := prefixItemsVal.([]any); ok && len(prefixItems) > 0 {
itemsVal, hasItems := schemaMap["items"]
_, itemsIsBool := itemsVal.(bool)
// 如果没有 items,或者 items 为 nil,或者 items 是布尔值(如 Draft 2020-12 中表示元组闭合的 items: false),
// 用选出的最佳元组项填充 items,以满足 Gemini 对 array.items 必须为合法 Schema 对象的规范。
if !hasItems || itemsVal == nil || itemsIsBool {
best := extractBestSchemaFromUnion(prefixItems)
if best == nil {
best = map[string]any{"type": "string"}
}
schemaMap["items"] = best
}
schemaMap["items"] = best
}
// 无论 prefixItems 是否为空数组,都彻底删除 prefixItems 关键字以防 Gemini 400
delete(schemaMap, "prefixItems")
}
@@ -219,6 +224,18 @@ func cleanJSONSchemaRecursive(value any) any {
schemaMap[k] = deepCopy(v)
}
}
// 清除已合并的 union 关键字
delete(schemaMap, "anyOf")
delete(schemaMap, "oneOf")
// 对合并进来的新属性和 items 进行深度递归清洗,确保合并进来的子结构(如 prefixItems)也能被处理
if props, ok := schemaMap["properties"].(map[string]any); ok {
for _, pv := range props {
cleanJSONSchemaRecursive(pv)
}
}
if items, ok := schemaMap["items"]; ok {
cleanJSONSchemaRecursive(items)
}
}
}
}
@@ -228,14 +245,37 @@ func cleanJSONSchemaRecursive(value any) any {
hasKey(schemaMap, "properties") ||
hasKey(schemaMap, "items") ||
hasKey(schemaMap, "enum") ||
hasKey(schemaMap, "const") ||
hasKey(schemaMap, "anyOf") ||
hasKey(schemaMap, "oneOf") ||
hasKey(schemaMap, "allOf")
if looksLikeSchema {
// 4. [ROBUST] 约束迁移
// 4. [ROBUST] 约束迁移与 const 规范化
migrateConstraints(schemaMap)
// 规范化 const 关键字 (Draft 6+ 转换为 Gemini 兼容的 enum: [const])
if constVal, exists := schemaMap["const"]; exists {
if _, hasEnum := schemaMap["enum"]; !hasEnum {
schemaMap["enum"] = []any{constVal}
}
if _, hasType := schemaMap["type"]; !hasType {
switch constVal.(type) {
case string:
schemaMap["type"] = "string"
case int, int32, int64:
schemaMap["type"] = "integer"
case float32, float64:
schemaMap["type"] = "number"
case bool:
schemaMap["type"] = "boolean"
default:
schemaMap["type"] = "string"
}
}
delete(schemaMap, "const")
}
// 5. [CRITICAL] 白名单过滤
allowedFields := map[string]bool{
"type": true,
@@ -283,11 +323,25 @@ func cleanJSONSchemaRecursive(value any) any {
}
schemaMap["type"] = selectedType
} else {
// 如果没有 type,但有 properties/items,推断并补全
// 如果没有 type,但有 properties/items/enum,推断并补全
if hasKey(schemaMap, "properties") {
schemaMap["type"] = "object"
} else if hasKey(schemaMap, "items") {
schemaMap["type"] = "array"
} else if enumArr, ok := schemaMap["enum"].([]any); ok && len(enumArr) > 0 {
// [FIX] 针对仅含 enum 的 schema,按其元素类型推断标量类型,避免被误判为 object 并注入 reason 属性
switch enumArr[0].(type) {
case int, int32, int64:
schemaMap["type"] = "integer"
case float32, float64:
schemaMap["type"] = "number"
case bool:
schemaMap["type"] = "boolean"
default:
schemaMap["type"] = "string"
}
} else if hasKey(schemaMap, "enum") {
schemaMap["type"] = "string"
} else {
schemaMap["type"] = "object"
}
@@ -525,6 +579,9 @@ func scoreSchemaOption(val any) int {
if hasKey(m, "items") || typeStr == "array" {
return 2
}
if hasKey(m, "enum") || hasKey(m, "const") {
return 1
}
if typeStr != "" && typeStr != "null" {
return 1
}
@@ -41,12 +41,12 @@ func TestCleanJSONSchema_ArrayPrefixItems(t *testing.T) {
require.True(t, ok)
assert.Equal(t, "array", where["type"])
// prefixItems 应被移除或替换
assert.Nil(t, where["prefixItems"])
whereItems, ok := where["items"].(map[string]any)
require.True(t, ok, "where.items must be an object")
assert.Equal(t, "array", whereItems["type"])
// prefixItems 应在 whereItems 中被彻底移除
assert.Nil(t, whereItems["prefixItems"])
// 关键验证:where.items.items 必须存在且有效,不能缺失导致 Gemini 400
innerItems, ok := whereItems["items"].(map[string]any)
@@ -108,3 +108,114 @@ func TestCleanJSONSchema_ArrayExistingItemsPreserved(t *testing.T) {
items := numbers["items"].(map[string]any)
assert.Equal(t, "integer", items["type"])
}
func TestCleanJSONSchema_EnumOnlySchemaInTuple(t *testing.T) {
// 测试 enum-only schema 不会被误判为 object,也不应被注入 reason 属性
input := map[string]any{
"type": "object",
"properties": map[string]any{
"choice": map[string]any{
"enum": []any{"option_a", "option_b"},
},
"tuple_with_enum": map[string]any{
"type": "array",
"prefixItems": []any{
map[string]any{
"enum": []any{"read", "write"},
},
},
},
},
}
cleaned := CleanJSONSchema(input)
require.NotNil(t, cleaned)
props := cleaned["properties"].(map[string]any)
choice := props["choice"].(map[string]any)
assert.Equal(t, "string", choice["type"])
assert.Nil(t, choice["properties"], "enum-only schema must NOT be treated as object with reason property")
assert.Equal(t, []any{"option_a", "option_b"}, choice["enum"])
tupleArray := props["tuple_with_enum"].(map[string]any)
tupleItems := tupleArray["items"].(map[string]any)
assert.Equal(t, "string", tupleItems["type"])
assert.Nil(t, tupleItems["properties"])
assert.Equal(t, []any{"read", "write"}, tupleItems["enum"])
}
func TestCleanJSONSchema_ConstKeywordConversion(t *testing.T) {
// 测试 const 关键字自动转换为 Gemini 兼容的 enum: [const] 且具有合法 type
input := map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"const": "ping",
},
},
}
cleaned := CleanJSONSchema(input)
require.NotNil(t, cleaned)
props := cleaned["properties"].(map[string]any)
action := props["action"].(map[string]any)
assert.Nil(t, action["const"], "const keyword must be removed")
assert.Equal(t, "string", action["type"])
assert.Equal(t, []any{"ping"}, action["enum"])
}
func TestCleanJSONSchema_AnyOfNestedPrefixItems(t *testing.T) {
// 测试 anyOf 分支合并进来的嵌套 prefixItems 也被完整深度清洗
input := map[string]any{
"type": "object",
"properties": map[string]any{
"status": map[string]any{"type": "string"},
},
"anyOf": []any{
map[string]any{
"properties": map[string]any{
"extra_tuple": map[string]any{
"type": "array",
"prefixItems": []any{
map[string]any{"type": "integer"},
},
},
},
},
},
}
cleaned := CleanJSONSchema(input)
require.NotNil(t, cleaned)
props := cleaned["properties"].(map[string]any)
require.NotNil(t, props["extra_tuple"])
extraTuple := props["extra_tuple"].(map[string]any)
assert.Nil(t, extraTuple["prefixItems"], "nested prefixItems from anyOf merge must be cleaned")
require.NotNil(t, extraTuple["items"])
assert.Equal(t, "integer", extraTuple["items"].(map[string]any)["type"])
}
func TestCleanJSONSchema_EmptyPrefixItems(t *testing.T) {
// 验证空 prefixItems: [] 也被安全删除,不遗留非法关键字
input := map[string]any{
"type": "object",
"properties": map[string]any{
"empty_tuple": map[string]any{
"type": "array",
"prefixItems": []any{},
},
},
}
cleaned := CleanJSONSchema(input)
require.NotNil(t, cleaned)
props := cleaned["properties"].(map[string]any)
emptyTuple := props["empty_tuple"].(map[string]any)
assert.Nil(t, emptyTuple["prefixItems"], "empty prefixItems array must be removed")
require.NotNil(t, emptyTuple["items"])
assert.Equal(t, "string", emptyTuple["items"].(map[string]any)["type"])
}