Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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.ai.edge.gallery.data

import com.google.gson.annotations.SerializedName
import javax.inject.Qualifier

/** Hilt qualifier annotation for feedback API key binding. */
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class FeedbackApiKey

/** Interface to fetch OAuth Bearer credentials on-device. */
interface AuthTokenProvider {
suspend fun getAuthToken(scope: String): String?
}

/** Enum matching Feedback Oneplatform MicrofeedbackScore values for lightweight sentiment. */
enum class MicrofeedbackScore {
@SerializedName("SCORE_UNSPECIFIED") SCORE_UNSPECIFIED,
@SerializedName("SCORE0") SCORE0,
@SerializedName("SCORE1") SCORE1,
@SerializedName("SCORE2") SCORE2,
@SerializedName("SCORE3") SCORE3,
@SerializedName("SCORE4") SCORE4,
@SerializedName("SCORE5") SCORE5,
}

/** A key-value pair for Product Specific Data (PSD) metadata attachment. */
data class ModelFeedbackPsdData(
@SerializedName("key") val key: String,
@SerializedName("value") val value: String,
)

/** Product metadata and environment info where the feedback was collected. */
data class ModelFeedbackProductInfo(
@SerializedName("ui_language") val uiLanguage: String = "en-US",
@SerializedName("product_version") val productVersion: String,
@SerializedName("product_specific_data") val productSpecificData: List<ModelFeedbackPsdData>,
)

/** Core user entry details, including comment text and lightweight sentiment scores. */
data class ModelFeedbackDataPayload(
@SerializedName("description") val description: String,
@SerializedName("microfeedback_score") val microfeedbackScore: MicrofeedbackScore,
)

/** DTO request body for the Feedback Oneplatform SubmitFeedback RPC public endpoint. */
data class ModelFeedbackRequest(
@SerializedName("product_id") val productId: Int = 5372309,
@SerializedName("bucket_id") val bucketId: String = "android-agent-chat-feedback",
@SerializedName("product_info") val productInfo: ModelFeedbackProductInfo,
@SerializedName("feedback_data") val feedbackData: ModelFeedbackDataPayload,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* 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.ai.edge.gallery.data

import android.util.Log
import com.google.ai.edge.gallery.BuildConfig
import com.google.gson.Gson
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

private const val TAG = "AGModelFeedbackRepo"

/**
* Repository for packaging and submitting user feedback on model responses to the Oneplatform API.
*/
@Singleton
class ModelFeedbackRepository
@Inject
constructor(
private val authTokenProvider: AuthTokenProvider,
@FeedbackApiKey private val apiKey: String,
) {

/**
* Submits user rating and conversational metadata to the Feedback Oneplatform service.
*
* @param isPositive True if Thumbs Up ( SCORE5 ), false if Thumbs Down ( SCORE0 ).
* @param description Free text user comment entered in the dialog.
* @param selectedChips Categorical taxonomical chips chosen by the user.
* @param userPrompt Prompt that triggered the response.
* @param modelResponse Agent answer being rated.
* @param modelId Unique name of the model.
* @param modelVersion Active version identifier of the model.
* @param temperature Generative temperature model parameter.
* @param topK Top K model parameter.
* @param topP Top P model parameter.
* @param extraPsd Map of any additional key-value pairs specific to the feature (e.g.
* feature_card).
* @param conversationHistory Full formatted conversation logs up to the rated agent answer.
*/
@Suppress("AndroidLintDispatcherUsage")
suspend fun submitFeedback(
isPositive: Boolean,
description: String,
selectedChips: List<String>,
userPrompt: String,
modelResponse: String,
modelId: String,
modelVersion: String,
temperature: String,
topK: String,
topP: String,
extraPsd: Map<String, String> = emptyMap(),
conversationHistory: String,
): Result<Unit> =
withContext(Dispatchers.IO) {
try {
// Retrieve the OAuth Bearer Token with the supportcontent scope
val scope = "oauth2:https://www.googleapis.com/auth/supportcontent"
val token = authTokenProvider.getAuthToken(scope)
Log.d(TAG, "Fetched OAuth token present: ${token != null} (scope: $scope)")

// TODO: Remove this short-circuit block once we configure an active FeedbackApiKey in
// AppModule.kt
if (token == null && apiKey.isEmpty()) {
Log.w(
TAG,
"No OAuth token or API Key provided. Short-circuiting to simulate successful sandbox submission for local developer testing.",
)
return@withContext Result.success(Unit)
}

val score = if (isPositive) MicrofeedbackScore.SCORE5 else MicrofeedbackScore.SCORE0

// Construct tabular metadata key-value pairs
val psdList =
mutableListOf(
ModelFeedbackPsdData("model_id", modelId),
ModelFeedbackPsdData("model_version", modelVersion),
ModelFeedbackPsdData("temperature", temperature),
ModelFeedbackPsdData("top_k", topK),
ModelFeedbackPsdData("top_p", topP),
ModelFeedbackPsdData("selected_chips", selectedChips.joinToString(",")),
ModelFeedbackPsdData("app_version", BuildConfig.VERSION_NAME),
ModelFeedbackPsdData("user_prompt", userPrompt),
ModelFeedbackPsdData("model_response", modelResponse),
ModelFeedbackPsdData("conversation_history", conversationHistory),
)

// Merge extra PSD fields
for ((key, value) in extraPsd) {
psdList.add(ModelFeedbackPsdData(key, value))
}

val productInfo =
ModelFeedbackProductInfo(
uiLanguage = "en-US",
productVersion = BuildConfig.VERSION_NAME,
productSpecificData = psdList,
)

val payload =
ModelFeedbackDataPayload(description = description, microfeedbackScore = score)

val request =
ModelFeedbackRequest(
productId = 5372309,
bucketId = "android-agent-chat-feedback",
productInfo = productInfo,
feedbackData = payload,
)

// Staging public network REST submission endpoint
var urlString =
"https://stagingqual-feedback-pa-googleapis.sandbox.google.com/v1/feedback/products/5372309:submit"
if (token == null && apiKey.isNotEmpty()) {
urlString += "?key=$apiKey"
}
val url = URL(urlString)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8")
if (token != null) {
connection.setRequestProperty("Authorization", "Bearer $token")
}

val json = Gson().toJson(request)
Log.d(TAG, "Feedback JSON Request Payload: $json")
OutputStreamWriter(connection.outputStream, "UTF-8").use { writer ->
writer.write(json)
writer.flush()
}

val responseCode = connection.responseCode
Log.d(TAG, "Feedback submission HTTP Response Code: $responseCode")
if (responseCode in 200..299) {
Result.success(Unit)
} else {
val errorMsg = connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error"
Result.failure(
Exception("Feedback submission failed with response code: $responseCode - $errorMsg")
)
}
} catch (e: Exception) {
Log.e(TAG, "Error occurred during feedback submission", e)
Result.failure(e)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ import com.google.ai.edge.gallery.GalleryLifecycleProvider
import com.google.ai.edge.gallery.SettingsSerializer
import com.google.ai.edge.gallery.SkillsSerializer
import com.google.ai.edge.gallery.UserDataSerializer
import com.google.ai.edge.gallery.data.AuthTokenProvider
import com.google.ai.edge.gallery.data.DataStoreRepository
import com.google.ai.edge.gallery.data.DefaultDataStoreRepository
import com.google.ai.edge.gallery.data.DefaultDownloadRepository
import com.google.ai.edge.gallery.data.DownloadRepository
import com.google.ai.edge.gallery.data.FeedbackApiKey
import com.google.ai.edge.gallery.proto.BenchmarkResults
import com.google.ai.edge.gallery.proto.CutoutCollection
import com.google.ai.edge.gallery.proto.Settings
Expand Down Expand Up @@ -183,4 +185,24 @@ internal object AppModule {
): DownloadRepository {
return DefaultDownloadRepository(context, lifecycleProvider)
}

// Provides AuthTokenProvider stub implementation
@Provides
@Singleton
fun provideAuthTokenProvider(): AuthTokenProvider {
return object : AuthTokenProvider {
override suspend fun getAuthToken(scope: String): String? {
return null
}
}
}

// Provides FeedbackApiKey
@Provides
@Singleton
@FeedbackApiKey
fun provideFeedbackApiKey(): String {
// TODO: Add the staging/sandbox Listnr API key here for anonymous feedback submissions
return ""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,20 @@ open class ChatMessage(
open val hideSenderLabel: Boolean = false,
open val disableBubbleShape: Boolean = false,
) {
var feedbackRating: Boolean? = null

open fun clone(): ChatMessage {
return ChatMessage(
type = type,
side = side,
latencyMs = latencyMs,
accelerator = accelerator,
hideSenderLabel = hideSenderLabel,
disableBubbleShape = disableBubbleShape,
)
val cloned =
ChatMessage(
type = type,
side = side,
latencyMs = latencyMs,
accelerator = accelerator,
hideSenderLabel = hideSenderLabel,
disableBubbleShape = disableBubbleShape,
)
cloned.feedbackRating = feedbackRating
return cloned
}
}

Expand Down Expand Up @@ -126,16 +131,19 @@ open class ChatMessageText(
hideSenderLabel = hideSenderLabel,
) {
override fun clone(): ChatMessageText {
return ChatMessageText(
content = content,
side = side,
latencyMs = latencyMs,
accelerator = accelerator,
isMarkdown = isMarkdown,
llmBenchmarkResult = llmBenchmarkResult,
hideSenderLabel = hideSenderLabel,
data = data,
)
val cloned =
ChatMessageText(
content = content,
side = side,
latencyMs = latencyMs,
accelerator = accelerator,
isMarkdown = isMarkdown,
llmBenchmarkResult = llmBenchmarkResult,
hideSenderLabel = hideSenderLabel,
data = data,
)
cloned.feedbackRating = feedbackRating
return cloned
}
}

Expand Down
Loading
Loading