-
Notifications
You must be signed in to change notification settings - Fork 706
[AI] support on-device structured output #8395
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
base: main
Are you sure you want to change the base?
Changes from all commits
a0ffcd5
64ef2ef
35e128a
60b3c12
8d670e8
565be05
b12b96d
e4fc45a
f98bd5c
e3dbf21
7e1f92b
cda79ef
1944371
2981a63
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 | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -25,12 +25,18 @@ import com.google.devtools.ksp.symbol.KSClassDeclaration | |||||||||||||||||||
| import com.google.devtools.ksp.symbol.KSType | ||||||||||||||||||||
| import com.google.devtools.ksp.symbol.KSVisitorVoid | ||||||||||||||||||||
| import com.google.devtools.ksp.symbol.Modifier | ||||||||||||||||||||
| import com.squareup.kotlinpoet.AnnotationSpec | ||||||||||||||||||||
| import com.squareup.kotlinpoet.ClassName | ||||||||||||||||||||
| import com.squareup.kotlinpoet.CodeBlock | ||||||||||||||||||||
| import com.squareup.kotlinpoet.FileSpec | ||||||||||||||||||||
| import com.squareup.kotlinpoet.FunSpec | ||||||||||||||||||||
| import com.squareup.kotlinpoet.KModifier | ||||||||||||||||||||
| import com.squareup.kotlinpoet.ParameterSpec | ||||||||||||||||||||
| import com.squareup.kotlinpoet.ParameterizedTypeName | ||||||||||||||||||||
| import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy | ||||||||||||||||||||
| import com.squareup.kotlinpoet.PropertySpec | ||||||||||||||||||||
| import com.squareup.kotlinpoet.TypeName | ||||||||||||||||||||
| import com.squareup.kotlinpoet.TypeSpec | ||||||||||||||||||||
| import com.squareup.kotlinpoet.ksp.toClassName | ||||||||||||||||||||
| import com.squareup.kotlinpoet.ksp.toTypeName | ||||||||||||||||||||
| import com.squareup.kotlinpoet.ksp.writeTo | ||||||||||||||||||||
|
|
@@ -57,6 +63,11 @@ internal class SchemaSymbolProcessorVisitor( | |||||||||||||||||||
| codeGenerator, | ||||||||||||||||||||
| Dependencies(true, containingFile), | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| val companionFile = generateMlKitCompanionFileSpec(classDeclaration) | ||||||||||||||||||||
| companionFile.writeTo( | ||||||||||||||||||||
| codeGenerator, | ||||||||||||||||||||
| Dependencies(true, containingFile), | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| fun generateFileSpec(classDeclaration: KSClassDeclaration): FileSpec { | ||||||||||||||||||||
|
|
@@ -142,7 +153,18 @@ internal class SchemaSymbolProcessorVisitor( | |||||||||||||||||||
| builder.addStatement("JsonSchema.double(").indent() | ||||||||||||||||||||
| } | ||||||||||||||||||||
| "kotlin.String" -> { | ||||||||||||||||||||
| builder.addStatement("JsonSchema.string(").indent() | ||||||||||||||||||||
| if (!guideValues.enumValues.isNullOrEmpty()) { | ||||||||||||||||||||
| builder | ||||||||||||||||||||
| .addStatement("JsonSchema.enumeration(") | ||||||||||||||||||||
| .indent() | ||||||||||||||||||||
| .addStatement("values = listOf(") | ||||||||||||||||||||
| .indent() | ||||||||||||||||||||
| .addStatement(guideValues.enumValues.joinToString { "\"$it\"" }) | ||||||||||||||||||||
| .unindent() | ||||||||||||||||||||
| .addStatement("),") | ||||||||||||||||||||
| } else { | ||||||||||||||||||||
| builder.addStatement("JsonSchema.string(").indent() | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| "kotlin.collections.List" -> { | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
@@ -155,7 +177,12 @@ internal class SchemaSymbolProcessorVisitor( | |||||||||||||||||||
| throw RuntimeException() | ||||||||||||||||||||
| } | ||||||||||||||||||||
| val listParamCodeBlock = | ||||||||||||||||||||
| generateCodeBlockForSchema(type = listTypeParam.resolve(), parentType = type) | ||||||||||||||||||||
| generateCodeBlockForSchema( | ||||||||||||||||||||
| type = listTypeParam.resolve(), | ||||||||||||||||||||
| parentType = type, | ||||||||||||||||||||
| guideAnnotation = | ||||||||||||||||||||
| if (!guideValues.enumValues.isNullOrEmpty()) guideAnnotation else null, | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| builder | ||||||||||||||||||||
| .addStatement("JsonSchema.array(") | ||||||||||||||||||||
| .indent() | ||||||||||||||||||||
|
|
@@ -248,4 +275,124 @@ internal class SchemaSymbolProcessorVisitor( | |||||||||||||||||||
| builder.addStatement("nullable = %L)", className.isNullable).unindent() | ||||||||||||||||||||
| return builder.build() | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| private fun isGenerableClass(type: KSType): Boolean { | ||||||||||||||||||||
| return type.declaration.annotations.any { it.shortName.getShortName() == "Generable" } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| private fun isListOfGenerableClass(type: KSType): Boolean { | ||||||||||||||||||||
| val qualifiedName = type.declaration.qualifiedName?.asString() | ||||||||||||||||||||
| if (qualifiedName == "kotlin.collections.List" || qualifiedName == "java.util.List") { | ||||||||||||||||||||
| val argType = type.arguments.firstOrNull()?.type?.resolve() | ||||||||||||||||||||
| if (argType != null) { | ||||||||||||||||||||
| return isGenerableClass(argType) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return false | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| private fun mapToMlKitCompanionType(type: KSType, packageName: String): TypeName { | ||||||||||||||||||||
| if (isListOfGenerableClass(type)) { | ||||||||||||||||||||
| type.arguments.firstOrNull()?.type?.resolve()?.let { argType -> | ||||||||||||||||||||
| val argClassName = | ||||||||||||||||||||
| ClassName( | ||||||||||||||||||||
| argType.declaration.packageName.asString(), | ||||||||||||||||||||
| "${argType.declaration.simpleName.asString()}_MlKitCompanion", | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| return ClassName("kotlin.collections", "List").parameterizedBy(argClassName) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } else if (isGenerableClass(type)) { | ||||||||||||||||||||
| return ClassName( | ||||||||||||||||||||
| type.declaration.packageName.asString(), | ||||||||||||||||||||
| "${type.declaration.simpleName.asString()}_MlKitCompanion" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return type.toTypeName() | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| fun generateMlKitCompanionFileSpec(classDeclaration: KSClassDeclaration): FileSpec { | ||||||||||||||||||||
| val packageName = classDeclaration.packageName.asString() | ||||||||||||||||||||
| val companionClassName = "${classDeclaration.simpleName.asString()}_MlKitCompanion" | ||||||||||||||||||||
|
Comment on lines
+314
to
+315
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. Extract the property KDocs once at the beginning of the function so they can be used as fallbacks for property descriptions in the generated companion class.
Suggested change
|
||||||||||||||||||||
| val fileBuilder = | ||||||||||||||||||||
| FileSpec.builder(packageName, companionClassName).addAnnotation(Generated::class) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| val classBuilder = TypeSpec.classBuilder(companionClassName).addModifiers(KModifier.DATA) | ||||||||||||||||||||
| val keepAnnotation = AnnotationSpec.builder(ClassName("androidx.annotation", "Keep")).build() | ||||||||||||||||||||
| classBuilder.addAnnotation(keepAnnotation) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| val generableAnn = | ||||||||||||||||||||
| classDeclaration.annotations.firstOrNull { it.shortName.getShortName() == "Generable" } | ||||||||||||||||||||
| val classDesc = getStringFromAnnotation(generableAnn, "description") | ||||||||||||||||||||
|
Comment on lines
+323
to
+325
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. Class descriptions documented via KDocs are not propagated to the generated companion class's
Suggested change
|
||||||||||||||||||||
| val mlkitGenerableBuilder = | ||||||||||||||||||||
| AnnotationSpec.builder(ClassName("com.google.mlkit.genai.schema.annotations", "Generable")) | ||||||||||||||||||||
| if (!classDesc.isNullOrEmpty()) { | ||||||||||||||||||||
| mlkitGenerableBuilder.addMember("description = %S", classDesc) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| classBuilder.addAnnotation(mlkitGenerableBuilder.build()) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| val primaryConstructor = FunSpec.constructorBuilder() | ||||||||||||||||||||
| val toSdkBuilder = | ||||||||||||||||||||
| FunSpec.builder("toSdk") | ||||||||||||||||||||
| .addAnnotation(keepAnnotation) | ||||||||||||||||||||
| .returns(ClassName(packageName, classDeclaration.simpleName.asString())) | ||||||||||||||||||||
| val toSdkArgs = mutableListOf<String>() | ||||||||||||||||||||
|
|
||||||||||||||||||||
| classDeclaration.getAllProperties().forEach { property -> | ||||||||||||||||||||
| val propName = property.simpleName.asString() | ||||||||||||||||||||
| val propType = property.type.resolve() | ||||||||||||||||||||
| val typeName = mapToMlKitCompanionType(propType, packageName) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| val paramBuilder = ParameterSpec.builder(propName, typeName) | ||||||||||||||||||||
| val propBuilder = PropertySpec.builder(propName, typeName).initializer(propName) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| val guideAnn = property.annotations.firstOrNull { it.shortName.getShortName() == "Guide" } | ||||||||||||||||||||
| if (guideAnn != null) { | ||||||||||||||||||||
| val guideValues = | ||||||||||||||||||||
| getGuideValuesFromAnnotation(guideAnn, getStringFromAnnotation(guideAnn, "description")) | ||||||||||||||||||||
|
Comment on lines
+348
to
+351
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. Use the extracted property KDocs as a fallback description for the
Suggested change
|
||||||||||||||||||||
| val mlkitGuideBuilder = | ||||||||||||||||||||
| AnnotationSpec.builder(ClassName("com.google.mlkit.genai.schema.annotations", "Guide")) | ||||||||||||||||||||
| if (!guideValues.description.isNullOrEmpty()) | ||||||||||||||||||||
| mlkitGuideBuilder.addMember("description = %S", guideValues.description) | ||||||||||||||||||||
| if (guideValues.minimum != null) | ||||||||||||||||||||
| mlkitGuideBuilder.addMember("minimum = %L", guideValues.minimum) | ||||||||||||||||||||
| if (guideValues.maximum != null) | ||||||||||||||||||||
| mlkitGuideBuilder.addMember("maximum = %L", guideValues.maximum) | ||||||||||||||||||||
| if (guideValues.minItems != null) | ||||||||||||||||||||
| mlkitGuideBuilder.addMember("minItems = %L", guideValues.minItems) | ||||||||||||||||||||
| if (guideValues.maxItems != null) | ||||||||||||||||||||
| mlkitGuideBuilder.addMember("maxItems = %L", guideValues.maxItems) | ||||||||||||||||||||
| if (!guideValues.format.isNullOrEmpty()) | ||||||||||||||||||||
| mlkitGuideBuilder.addMember("format = %S", guideValues.format) | ||||||||||||||||||||
| if (!guideValues.enumValues.isNullOrEmpty()) { | ||||||||||||||||||||
| val enumElements = guideValues.enumValues.joinToString { "%S" } | ||||||||||||||||||||
| mlkitGuideBuilder.addMember( | ||||||||||||||||||||
| "enumValues = arrayOf($enumElements)", | ||||||||||||||||||||
| *guideValues.enumValues.toTypedArray() | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| paramBuilder.addAnnotation(mlkitGuideBuilder.build()) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| primaryConstructor.addParameter(paramBuilder.build()) | ||||||||||||||||||||
| classBuilder.addProperty(propBuilder.build()) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (isListOfGenerableClass(propType)) { | ||||||||||||||||||||
| toSdkArgs.add("$propName = this.$propName.map { it.toSdk() }") | ||||||||||||||||||||
| } else if (isGenerableClass(propType)) { | ||||||||||||||||||||
| toSdkArgs.add("$propName = this.$propName.toSdk()") | ||||||||||||||||||||
| } else { | ||||||||||||||||||||
| toSdkArgs.add("$propName = this.$propName") | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| classBuilder.primaryConstructor(primaryConstructor.build()) | ||||||||||||||||||||
| toSdkBuilder.addStatement( | ||||||||||||||||||||
| "return %T(\n ${toSdkArgs.joinToString(",\n ")}\n)", | ||||||||||||||||||||
| ClassName(packageName, classDeclaration.simpleName.asString()) | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| classBuilder.addFunction(toSdkBuilder.build()) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| fileBuilder.addType(classBuilder.build()) | ||||||||||||||||||||
| return fileBuilder.build() | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| /* | ||
| * Copyright 2026 Google LLC | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package com.google.firebase.ai.ondevice.interop | ||
|
|
||
| /** | ||
| * Represents a structured object generation response from the on-device model interop layer. | ||
| * | ||
| * @property instances The list of generated object instances across all candidates returned | ||
| * directly by the model engine. | ||
| */ | ||
| public class GenerateObjectResponse<T>(public val instances: List<T>) { | ||
| public constructor(instance: T) : this(listOf(instance)) | ||
| } |
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.
Using
joinToString { "\"$it\"" }to embed raw string values directly into the generated code block can result in invalid Kotlin code if any enum value contains double quotes or other special characters. Use KotlinPoet's%Sformat specifier to safely escape and format the string values.