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
Expand Up @@ -24,6 +24,13 @@ data class ViewDismissed(val isEngineAvailable: Boolean) : SummarizationAction
/** The user tapped the settings cog. */
data object SettingsClicked : SummarizationAction

/**
* The user rated the generated summary using the feedback control.
*
* @param feedback The [SummaryFeedback] the user selected.
*/
data class SummaryFeedbackProvided(val feedback: SummaryFeedback) : SummarizationAction

/** The user tapped the back button from settings. */
data object SettingsBackClicked : SummarizationAction

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,18 @@ fun summarizationReducer(state: SummarizationState, action: SummarizationAction)
is SummarizationFailed -> SummarizationState.Error(SummarizationError.SummarizationFailed(action.exception))
is ReceivedParsedDocument -> state.updateDocument(action.document)
is SettingsClicked -> when (state) {
is SummarizationState.Summarized -> SummarizationState.Settings(info = state.info, document = state.document)
is SummarizationState.Summarized ->
SummarizationState.Settings(info = state.info, document = state.document, feedback = state.feedback)
else -> state
}
is SettingsBackClicked -> when (state) {
is SummarizationState.Settings -> SummarizationState.Summarized(info = state.info, document = state.document)
is SummarizationState.Settings ->
SummarizationState.Summarized(info = state.info, document = state.document, feedback = state.feedback)
else -> state
}
is SummaryFeedbackProvided -> when (state) {
is SummarizationState.Summarized ->
if (state.feedback == null) state.copy(feedback = action.feedback) else state
else -> state
}
else -> state
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ private fun SummarizationScreenContent(
info = state.info,
document = state.document,
onSettingsClicked = { store.dispatch(SettingsClicked) },
feedback = state.feedback,
onFeedbackClicked = { store.dispatch(SummaryFeedbackProvided(it)) },
)

is SummarizationState.Settings -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,13 @@ sealed class SummarizationState : State {
*
* @param info metadata about the LLM that generated the summary
* @param document The generated document.
* @param feedback The rating the user gave the summary, or `null` if they have not rated it.
* The rating is single-use: once set it is never cleared, disabling further feedback.
*/
data class Summarized(
val info: LlmProvider.Info,
val document: RichDocument = RichDocument(listOf()),
val feedback: SummaryFeedback? = null,
) : SummarizationState()

/**
Expand All @@ -75,8 +78,13 @@ sealed class SummarizationState : State {
*
* @param info metadata about the LLM that generated the summary
* @param document The document to return to when navigating back.
* @param feedback The rating to restore when navigating back to the summary.
*/
data class Settings(val info: LlmProvider.Info, val document: RichDocument) : SummarizationState()
data class Settings(
val info: LlmProvider.Info,
val document: RichDocument,
val feedback: SummaryFeedback? = null,
) : SummarizationState()

/** User is finished with the Summarization Flow */
sealed class Finished : SummarizationState() {
Expand Down Expand Up @@ -106,6 +114,17 @@ sealed class SummarizationError {
data class SummarizationFailed(val exception: Throwable) : SummarizationError()
}

/**
* The rating a user can give a generated summary via the feedback control.
*/
enum class SummaryFeedback {
/** The user found the summary helpful. */
POSITIVE,

/** The user found the summary unhelpful. */
NEGATIVE,
}

val SummarizationState.isLoading get() = this is SummarizationState.Loading
val SummarizationState.isSummarizing get() = this is SummarizationState.Summarizing
val SummarizationState.isSummarized get() = this is SummarizationState.Summarized
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

package mozilla.components.feature.summarize.ui

import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -33,18 +34,28 @@ import mozilla.components.compose.base.button.IconButton
import mozilla.components.compose.base.theme.AcornTheme
import mozilla.components.concept.llm.LlmProvider
import mozilla.components.feature.summarize.R
import mozilla.components.feature.summarize.SummaryFeedback
import mozilla.components.ui.richtext.RichText
import mozilla.components.ui.richtext.ir.RichDocument
import mozilla.components.ui.icons.R as iconsR

/**
* Content being shown after the page summary has been generated
* Content being shown after the page summary has been generated.
*
* @param document The generated summary to render.
* @param info Metadata about the LLM that generated the summary.
* @param onSettingsClicked Invoked when the user taps the settings cog.
* @param feedback The rating the user has given the summary, or `null` if unrated.
* @param onFeedbackClicked Invoked when the user rates the summary. When `null` the feedback
* control is hidden (e.g. while the summary is still streaming).
*/
@Composable
internal fun SummaryContentLoaded(
document: RichDocument,
info: LlmProvider.Info,
onSettingsClicked: () -> Unit = {},
feedback: SummaryFeedback? = null,
onFeedbackClicked: ((SummaryFeedback) -> Unit)? = null,
) {
Column(
modifier = Modifier
Expand All @@ -61,7 +72,7 @@ internal fun SummaryContentLoaded(
.verticalScroll(rememberScrollState()),
)
Spacer(Modifier.height(AcornTheme.layout.space.static200))
DisclaimerMessage()
SummaryFooter(feedback = feedback, onFeedbackClicked = onFeedbackClicked)
Spacer(Modifier.height(AcornTheme.layout.space.static200))
}
}
Expand Down Expand Up @@ -144,13 +155,91 @@ private fun SummarizedContent(document: RichDocument, modifier: Modifier = Modif
}

@Composable
private fun DisclaimerMessage() {
private fun SummaryFooter(
feedback: SummaryFeedback?,
onFeedbackClicked: ((SummaryFeedback) -> Unit)?,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
DisclaimerMessage(modifier = Modifier.weight(1f))

if (onFeedbackClicked != null) {
SummaryFeedbackControl(feedback = feedback, onFeedbackClicked = onFeedbackClicked)
}
}
}

@Composable
private fun DisclaimerMessage(modifier: Modifier = Modifier) {
Text(
text = stringResource(R.string.mozac_feature_summarize_disclaimer_message),
fontSize = 14.sp,
modifier = Modifier
.height(24.dp)
.width(AcornTheme.layout.size.containerMaxWidth),
modifier = modifier,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}

@Composable
private fun SummaryFeedbackControl(
feedback: SummaryFeedback?,
onFeedbackClicked: (SummaryFeedback) -> Unit,
modifier: Modifier = Modifier,
) {
val hasRated = feedback != null
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
FeedbackButton(
selected = feedback == SummaryFeedback.POSITIVE,
enabled = !hasRated,
selectedIcon = iconsR.drawable.mozac_ic_thumbs_up_fill_24,
unselectedIcon = iconsR.drawable.mozac_ic_thumbs_up_24,
contentDescription = stringResource(
R.string.mozac_feature_summarize_feedback_positive_content_description,
),
onClick = { onFeedbackClicked(SummaryFeedback.POSITIVE) },
)
FeedbackButton(
selected = feedback == SummaryFeedback.NEGATIVE,
enabled = !hasRated,
selectedIcon = iconsR.drawable.mozac_ic_thumbs_down_fill_24,
unselectedIcon = iconsR.drawable.mozac_ic_thumbs_down_24,
contentDescription = stringResource(
R.string.mozac_feature_summarize_feedback_negative_content_description,
),
onClick = { onFeedbackClicked(SummaryFeedback.NEGATIVE) },
)
}
}

@Composable
private fun FeedbackButton(
selected: Boolean,
enabled: Boolean,
@DrawableRes selectedIcon: Int,
@DrawableRes unselectedIcon: Int,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(
onClick = onClick,
contentDescription = contentDescription,
modifier = modifier,
enabled = enabled,
) {
Icon(
painter = painterResource(if (selected) selectedIcon else unselectedIcon),
contentDescription = null,
tint = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@
<!-- Disclaimer message -->
<string name="mozac_feature_summarize_disclaimer_message">AI can make mistakes</string>

<!-- Content description for the thumbs up button used to rate a generated summary as helpful -->
<string name="mozac_feature_summarize_feedback_positive_content_description">Good summary</string>

<!-- Content description for the thumbs down button used to rate a generated summary as unhelpful -->
<string name="mozac_feature_summarize_feedback_negative_content_description">Bad summary</string>

<!-- Title for the page summary AI controllable feature -->
<string name="mozac_ai_controls_page_summary_title">Page summaries</string>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import mozilla.components.concept.llm.CloudLlmProvider
import mozilla.components.concept.llm.Llm
Expand Down Expand Up @@ -40,6 +41,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
import mozilla.components.feature.summarize.SummarizationState.Settings as SettingsState

class SummarizationStoreTest {

Expand Down Expand Up @@ -635,4 +637,110 @@ class SummarizationStoreTest {
)
assertEquals(expected, states)
}

@Test
fun `when feedback is provided on a summary, it is stored`() = runTest {
val provider = FakeCloudProvider(preparedState = CloudLlmProvider.State.Ready(FakeLlm.successful))
val pageTitle = "Article Headline"
val store = summarizedStore(provider, pageTitle)

val states = mutableListOf<SummarizationState>()
backgroundScope.launch {
store.stateFlow.toList(states)
}

store.dispatch(ViewAppeared)
testScheduler.advanceTimeBy(15.seconds)
store.dispatch(SummaryFeedbackProvided(SummaryFeedback.POSITIVE))
testScheduler.advanceTimeBy(1.seconds)

val document = parser.parse("# $pageTitle\nThis is the article\nThis is some content...\nThis is some *bold* content.\n")
assertEquals(Summarized(provider.info, document, SummaryFeedback.POSITIVE), states.last())
}

@Test
fun `when feedback has already been provided, providing it again is ignored`() = runTest {
val provider = FakeCloudProvider(preparedState = CloudLlmProvider.State.Ready(FakeLlm.successful))
val pageTitle = "Article Headline"
val store = summarizedStore(provider, pageTitle)

val states = mutableListOf<SummarizationState>()
backgroundScope.launch {
store.stateFlow.toList(states)
}

store.dispatch(ViewAppeared)
testScheduler.advanceTimeBy(15.seconds)
store.dispatch(SummaryFeedbackProvided(SummaryFeedback.POSITIVE))
testScheduler.advanceTimeBy(1.seconds)
store.dispatch(SummaryFeedbackProvided(SummaryFeedback.NEGATIVE))
testScheduler.advanceTimeBy(1.seconds)

val document = parser.parse("# $pageTitle\nThis is the article\nThis is some content...\nThis is some *bold* content.\n")
assertEquals(Summarized(provider.info, document, SummaryFeedback.POSITIVE), states.last())
}

@Test
fun `when feedback is provided before a summary is ready, the state is unchanged`() = runTest {
val hangingLlm = object : Llm {
override suspend fun prompt(prompt: Prompt): Flow<String> = flow { awaitCancellation() }
}
val provider = FakeCloudProvider(preparedState = CloudLlmProvider.State.Ready(hangingLlm))
val store = summarizedStore(provider, "Article Headline")

val states = mutableListOf<SummarizationState>()
backgroundScope.launch {
store.stateFlow.toList(states)
}

store.dispatch(ViewAppeared)
testScheduler.advanceTimeBy(1.seconds)
store.dispatch(SummaryFeedbackProvided(SummaryFeedback.POSITIVE))
testScheduler.advanceTimeBy(1.seconds)

assertEquals(Loading(provider.info), states.last())
}

@Test
fun `feedback is preserved when navigating to settings and back`() = runTest {
val provider = FakeCloudProvider(preparedState = CloudLlmProvider.State.Ready(FakeLlm.successful))
val pageTitle = "Article Headline"
val store = summarizedStore(provider, pageTitle)

val states = mutableListOf<SummarizationState>()
backgroundScope.launch {
store.stateFlow.toList(states)
}

store.dispatch(ViewAppeared)
testScheduler.advanceTimeBy(15.seconds)
store.dispatch(SummaryFeedbackProvided(SummaryFeedback.NEGATIVE))
testScheduler.advanceTimeBy(1.seconds)
store.dispatch(SettingsClicked)
testScheduler.advanceTimeBy(1.seconds)
store.dispatch(SettingsBackClicked)
testScheduler.advanceTimeBy(1.seconds)

val document = parser.parse("# $pageTitle\nThis is the article\nThis is some content...\nThis is some *bold* content.\n")
assertTrue(states.contains(SettingsState(provider.info, document, SummaryFeedback.NEGATIVE)))
assertEquals(Summarized(provider.info, document, SummaryFeedback.NEGATIVE), states.last())
}

private fun TestScope.summarizedStore(
provider: FakeCloudProvider,
pageTitle: String,
) = SummarizationStore(
initialState = Inert(true),
reducer = ::summarizationReducer,
middleware = listOf(
SummarizationMiddleware(
llmProvider = provider,
settings = SummarizationSettings.inMemory(hasConsentedToShake = true),
contentProvider = { Result.success(Content(PageMetadata(pageTitle = pageTitle))) },
errorReporter = noopReporter,
scope = backgroundScope,
dispatcher = StandardTestDispatcher(testScheduler),
),
),
)
}
Loading