Repository: takahirom/roborazzi
Pull request #532 (2024-11-02 10:28:20)

Add ai_powered_image_assertion.md and refactor naming


Patch:
diff --git a/README.template.md b/README.template.md
index 84bbd4d9..9c5e6ce6 100644
--- a/README.template.md
+++ b/README.template.md
@@ -9,6 +9,8 @@
 </div>
 <div name="topic_preview_support">
 </div>
+<div name="ai_powered_image_assertion">
+</div>
 <div name="topic_idea_plugin">
 </div>
 <div name="topic_compose_multiplatform">
diff --git a/docs/roborazzi-docs.tree b/docs/roborazzi-docs.tree
index 30e3e59b..5c426f3c 100644
--- a/docs/roborazzi-docs.tree
+++ b/docs/roborazzi-docs.tree
@@ -11,6 +11,7 @@
     <toc-element topic="build_setup.md"/>
     <toc-element topic="how_to_use.md"/>
     <toc-element topic="preview_support.md"/>
+    <toc-element topic="ai_powered_image_assertion.md"/>
     <toc-element topic="idea_plugin.md"/>
     <toc-element topic="compose_multiplatform.md"/>
     <toc-element topic="gradle_properties_options.md"/>
diff --git a/docs/topics/ai_powered_image_assertion.md b/docs/topics/ai_powered_image_assertion.md
new file mode 100644
index 00000000..a9aaa39a
--- /dev/null
+++ b/docs/topics/ai_powered_image_assertion.md
@@ -0,0 +1,102 @@
+# Experimental AI-Powered Image Assertion
+
+Roborazzi supports AI-powered image assertion. 
+AI-powered image assertion is an experimental feature. Screenshot tests are a great way to verify the UI of your app, but verifying the content of the image is challenging because it is a manual process. Roborazzi can help you automate this process by using AI-powered image assertion.
+
+There are two new library modules: `io.github.takahirom.roborazzi:roborazzi-ai-gemini` and `io.github.takahirom.roborazzi:roborazzi-ai-openai` for AI-powered image assertion.
+
+`roborazzi-ai-gemini` uses [Gemini](https://gemini.google.com/), and `roborazzi-ai-openai` uses the [OpenAI API](https://platform.openai.com/).
+
+```kotlin
+...
+@get:Rule
+val composeTestRule = createAndroidComposeRule<MainActivity>()
+
+@get:Rule
+val roborazziRule = RoborazziRule(
+  options = RoborazziRule.Options(
+    roborazziOptions = RoborazziOptions(
+      compareOptions = RoborazziOptions.CompareOptions(
+        aiAssertionOptions = AiAssertionOptions(
+          aiAssertionModel = GeminiAiAssertionModel(
+            apiKey = System.getenv("gemini_api_key") ?: ""
+          ),
+        )
+      )
+    )
+  )
+)
+
+@Test
+fun captureWithAi() {
+  ROBORAZZI_DEBUG = true
+  onView(ViewMatchers.isRoot())
+    .captureRoboImage(
+      roborazziOptions = provideRoborazziContext().options.addedAiAssertions(
+        AiAssertionOptions.AiAssertion(
+          assertPrompt = "it should have PREVIOUS button",
+          requiredFulfillmentPercent = 90,
+        ),
+        AiAssertionOptions.AiAssertion(
+          assertPrompt = "it should show First Fragment",
+          requiredFulfillmentPercent = 90,
+        )
+      )
+    )
+}
+```
+
+## Behavior of AI-Powered Image Assertion
+
+AI-Powered Image Assertion runs only when the images are different. If the images are the same, AI-Powered Image Assertion is skipped.  
+This is because AI-Powered Image Assertion can be slow and expensive.
+
+## Manual Image Assertion
+
+You can use manual image assertion with Roborazzi. This allows you to utilize local LLMs or other LLMs. Manual Image Assertion doesn't require adding any dependencies other than Roborazzi itself.
+
+You must provide the `AiAssertionModel` to `RoborazziOptions` to use manual image assertion.
+
+```kotlin
+interface AiAssertionModel {
+  fun assert(
+    referenceImageFilePath: String,
+    comparisonImageFilePath: String,
+    actualImageFilePath: String,
+    aiAssertionOptions: AiAssertionOptions
+  ): AiAssertionResults
+}
+```
+
+```kotlin
+compareOptions = RoborazziOptions.CompareOptions(
+  aiAssertionOptions = AiAssertionOptions(
+    aiAssertionModel = object : AiAssertionOptions.AiAssertionModel {
+      override fun assert(
+        comparisonImageFilePath: String,
+        aiAssertionOptions: AiAssertionOptions
+      ): AiAssertionResults {
+        // You can use any LLMs here to create AiAssertionResults
+        return AiAssertionResults(
+          aiAssertionResults = aiAssertionOptions.aiAssertions.map { assertion ->
+            AiAssertionResult(
+              assertPrompt = assertion.assertPrompt,
+              fulfillmentPercent = fulfillmentPercent,
+              requiredFulfillmentPercent = assertion.requiredFulfillmentPercent,
+              failIfNotFulfilled = assertion.failIfNotFulfilled,
+              explanation = "This is a manual test.",
+            )
+          }
+        )
+      }
+    },
+    aiAssertions = listOf(
+      AiAssertionOptions.AiAssertion(
+        assertPrompt = "it should have PREVIOUS button",
+        requiredFulfillmentPercent = 90,
+      ),
+    ),
+  )
+)
+          ...
+```
diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt
index 08ff4c30..3ba3a2b8 100644
--- a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt
+++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt
@@ -100,7 +100,7 @@ data class RoborazziOptions(
     val outputDirectoryPath: String = roborazziSystemPropertyOutputDirectory(),
     val imageComparator: ImageComparator = DefaultImageComparator,
     val comparisonStyle: ComparisonStyle = ComparisonStyle.Grid(),
-    val aiCompareOptions: AiCompareOptions? = null,
+    val aiAssertionOptions: AiAssertionOptions? = null,
     val resultValidator: (result: ImageComparator.ComparisonResult) -> Boolean = DefaultResultValidator,
   ) {
 
@@ -161,19 +161,19 @@ data class RoborazziOptions(
       override fun report(captureResult: CaptureResult, roborazziTaskType: RoborazziTaskType) {
         val aiResult = when (captureResult) {
           is CaptureResult.Changed -> {
-            captureResult.aiComparisonResult
+            captureResult.aiAssertionResults
           }
 
           is CaptureResult.Added -> {
-            captureResult.aiComparisonResult
+            captureResult.aiAssertionResults
           }
 
           else -> {
             null
           }
         }
-        aiResult?.aiConditionResults
-          ?.filter { conditionResult -> conditionResult.requiredFulfillmentPercent != null }
+        aiResult?.aiAssertionResults
+          ?.filter { conditionResult -> conditionResult.requiredFulfillmentPercent != null && conditionResult.failIfNotFulfilled }
           ?.forEach { conditionResult ->
           if (conditionResult.fulfillmentPercent < conditionResult.requiredFulfillmentPercent!!) {
             throw AssertionError(
@@ -245,27 +245,25 @@ data class RoborazziOptions(
 
   internal val shouldTakeBitmap: Boolean = captureType.shouldTakeScreenshot()
 
-  fun addedCompareAiAssertion(
+  @ExperimentalRoborazziApi
+  fun addedAiAssertion(
     assert: String,
     requiredFulfillmentPercent: Int
   ): RoborazziOptions {
-    return copy(
-      compareOptions = compareOptions.copy(
-        aiCompareOptions = compareOptions.aiCompareOptions!!.copy(
-          aiConditions = compareOptions.aiCompareOptions.aiConditions + AiCompareOptions.AiCondition(
-            assertPrompt = assert,
-            requiredFulfillmentPercent = requiredFulfillmentPercent
-          )
-        )
+    return addedAiAssertions(
+      AiAssertionOptions.AiAssertion(
+        assertPrompt = assert,
+        requiredFulfillmentPercent = requiredFulfillmentPercent
       )
     )
   }
 
-  fun addedCompareAiAssertions(vararg assertions: AiCompareOptions.AiCondition): RoborazziOptions {
+  @ExperimentalRoborazziApi
+  fun addedAiAssertions(vararg assertions: AiAssertionOptions.AiAssertion): RoborazziOptions {
     return copy(
       compareOptions = compareOptions.copy(
-        aiCompareOptions = compareOptions.aiCompareOptions!!.copy(
-          aiConditions = compareOptions.aiCompareOptions.aiConditions + assertions
+        aiAssertionOptions = compareOptions.aiAssertionOptions!!.copy(
+          aiAssertions = compareOptions.aiAssertionOptions.aiAssertions + assertions
         )
       )
     )
diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt
index 53254c91..61a3d66c 100644
--- a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt
+++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt
@@ -122,18 +122,6 @@ fun processOutputImageAndReport(
           resizeScale = resizeScale,
           contextData = contextData
         )
-      val aiOptions = compareOptions.aiCompareOptions
-      val aiResult = if (aiOptions != null && aiOptions.aiConditions.isNotEmpty()) {
-        val comparisonResultFactory = if (aiOptions.aiModel is AiComparisonResultFactory) {
-          aiOptions.aiModel
-        } else {
-          throw NotImplementedError("aiCompareCanvasFactory is not implemented. Did you add roborazzi-ai dependency and (call loadRoboAi() or use RoborazziRule)?")
-        }
-        val aiResult = comparisonResultFactory.invoke(comparisonFile.absolutePath, aiOptions)
-        aiResult
-      } else {
-        null
-      }
       debugLog {
         "processOutputImageAndReport(): compareCanvas is saved " +
           "compareFile:${comparisonFile.absolutePath}"
@@ -155,6 +143,17 @@ fun processOutputImageAndReport(
           resizeScale = resizeScale,
           contextData = contextData
         )
+      val aiOptions = compareOptions.aiAssertionOptions
+      val aiResult = if (aiOptions != null && aiOptions.aiAssertions.isNotEmpty()) {
+        aiOptions.aiAssertionModel.assert(
+          referenceImageFilePath = goldenFile.absolutePath,
+          comparisonImageFilePath = comparisonFile.absolutePath,
+          actualImageFilePath = actualFile.absolutePath,
+          aiAssertionOptions = aiOptions
+        )
+      } else {
+        null
+      }
       debugLog {
         "processOutputImageAndReport(): actualCanvas is saved " +
           "actualFile:${actualFile.absolutePath}"
@@ -166,7 +165,7 @@ fun processOutputImageAndReport(
           goldenFile = goldenFile.absolutePath,
           timestampNs = System.nanoTime(),
           diffPercentage = diffPercentage,
-          aiComparisonResult = aiResult,
+          aiAssertionResults = aiResult,
           contextData = contextData,
         )
       } else {
@@ -175,7 +174,7 @@ fun processOutputImageAndReport(
           actualFile = actualFile.absolutePath,
           goldenFile = goldenFile.absolutePath,
           timestampNs = System.nanoTime(),
-          aiComparisonResult = aiResult,
+          aiAssertionResults = aiResult,
           contextData = contextData,
         )
       }
diff --git a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/AiAssertionOptions.kt b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/AiAssertionOptions.kt
new file mode 100644
index 00000000..a58d7f11
--- /dev/null
+++ b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/AiAssertionOptions.kt
@@ -0,0 +1,50 @@
+package com.github.takahirom.roborazzi
+
+/**
+ * If you want to use AI to compare images, you can specify the model and prompt.
+ */
+@ExperimentalRoborazziApi
+data class AiAssertionOptions(
+  val aiAssertionModel: AiAssertionModel,
+  val aiAssertions: List<AiAssertion> = emptyList(),
+  val systemPrompt: String = """Evaluate the following assertion for fulfillment in the new image.
+The evaluation should be based on the comparison between the original image on the left and the new image on the right, with differences highlighted in red in the center. Focus on whether the new image fulfills the requirement specified in the user input.
+
+Output:
+For each assertion:
+A fulfillment percentage from 0 to 100.
+A brief explanation of how this percentage was determined.""",
+  val promptTemplate: String = """Assertions:
+INPUT_PROMPT
+""",
+  val inputPrompt: (AiAssertionOptions) -> String = { aiOptions ->
+    buildString {
+      aiOptions.aiAssertions.forEachIndexed { index, aiAssertion ->
+        appendLine("Assertion ${index + 1}: ${aiAssertion.assertPrompt}\n")
+      }
+    }
+  },
+) {
+  @ExperimentalRoborazziApi
+  interface AiAssertionModel {
+    fun assert(
+      referenceImageFilePath: String,
+      comparisonImageFilePath: String,
+      actualImageFilePath: String,
+      aiAssertionOptions: AiAssertionOptions
+    ): AiAssertionResults
+    companion object {
+      const val DefaultMaxOutputTokens = 300
+      const val DefaultTemperature = 0.4F
+    }
+  }
+
+  data class AiAssertion(
+    val assertPrompt: String,
+    val failIfNotFulfilled: Boolean = true,
+    /**
+     * If null, the AI result is not validated. But the fulfillment_percent are still included in the report.
+     */
+    val requiredFulfillmentPercent: Int? = 80
+  )
+}
\ No newline at end of file
diff --git a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/AiCompareOptions.kt b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/AiCompareOptions.kt
deleted file mode 100644
index a050980a..00000000
--- a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/AiCompareOptions.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package com.github.takahirom.roborazzi
-
-/**
- * If you want to use AI to compare images, you can specify the model and prompt.
- */
-data class AiCompareOptions(
-  val aiModel: AiModel,
-  val aiConditions: List<AiCondition> = emptyList(),
-  val systemPrompt: String = """Evaluate the following assertion for fulfillment in the new image.
-The evaluation should be based on the comparison between the original image on the left and the new image on the right, with differences highlighted in red in the center. Focus on whether the new image fulfills the requirement specified in the user input.
-
-Output:
-For each assertion:
-A fulfillment percentage from 0 to 100.
-A brief explanation of how this percentage was determined.""",
-  val promptTemplate: String = """Assertions:
-INPUT_PROMPT
-""",
-  val inputPrompt: (AiCompareOptions) -> String = { aiOptions ->
-    buildString {
-      aiOptions.aiConditions.forEachIndexed { index, aiAssertion ->
-        appendLine("Assertion ${index + 1}: ${aiAssertion.assertPrompt}\n")
-      }
-    }
-  },
-) {
-  interface AiModel {
-    /**
-     * You can use this model if you want to use other models.
-     */
-    interface Manual : AiModel, AiComparisonResultFactory
-  }
-
-  data class AiCondition(
-    val assertPrompt: String,
-    /**
-     * If null, the AI result is not validated. But they are still included in the report.
-     */
-    val requiredFulfillmentPercent: Int?
-  )
-}
\ No newline at end of file
diff --git a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/AiResultFactory.kt b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/AiResultFactory.kt
deleted file mode 100644
index 06b46a8d..00000000
--- a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/AiResultFactory.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.github.takahirom.roborazzi
-
-fun interface AiComparisonResultFactory {
-  operator fun invoke(
-    comparisonImageFilePath: String,
-    aiCompareOptions: AiCompareOptions
-  ): AiComparisonResult
-}
diff --git a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResult.kt b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResult.kt
index ef7b6e88..0ad34576 100644
--- a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResult.kt
+++ b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResult.kt
@@ -58,8 +58,8 @@ sealed interface CaptureResult {
     override val goldenFile: @Contextual String,
     @SerialName("timestamp")
     override val timestampNs: Long,
-    @SerialName("ai_result")
-    val aiComparisonResult: AiComparisonResult?,
+    @SerialName("ai_assertion_results")
+    val aiAssertionResults: AiAssertionResults?,
     @SerialName("context_data")
     override val contextData: Map<String, @Contextual Any>
   ) : CaptureResult {
@@ -78,8 +78,8 @@ sealed interface CaptureResult {
     override val timestampNs: Long,
     @SerialName("diff_percentage")
     val diffPercentage: Float?,
-    @SerialName("ai_result")
-    val aiComparisonResult: AiComparisonResult?,
+    @SerialName("ai_assertion_results")
+    val aiAssertionResults: AiAssertionResults?,
     @SerialName("context_data")
     override val contextData: Map<String, @Contextual Any>
   ) : CaptureResult {
@@ -137,15 +137,21 @@ sealed interface CaptureResult {
 }
 
 @Serializable
-data class AiComparisonResult(
-  val aiConditionResults: List<AiConditionResult> = emptyList()
+data class AiAssertionResults(
+  @SerialName("ai_assertion_results")
+  val aiAssertionResults: List<AiAssertionResult> = emptyList()
 )
 
 @Serializable
-data class AiConditionResult(
+data class AiAssertionResult(
+  @SerialName("assert_prompt")
   val assertPrompt: String,
   @SerialName("required_fulfillment_percent")
   val requiredFulfillmentPercent: Int?,
+  @SerialName("fail_if_not_fulfilled")
+  val failIfNotFulfilled: Boolean,
+  @SerialName("fulfillment_percent")
   val fulfillmentPercent: Int,
+  @SerialName("explanation")
   val explanation: String?,
 )
\ No newline at end of file
diff --git a/roborazzi-ai-gemini/build.gradle b/roborazzi-ai-gemini/build.gradle
index 79f156e0..474ef728 100644
--- a/roborazzi-ai-gemini/build.gradle
+++ b/roborazzi-ai-gemini/build.gradle
@@ -26,7 +26,7 @@ kotlin {
     commonMain {
       dependencies {
         implementation "io.github.takahirom.roborazzi:roborazzi-core:$VERSION_NAME"
-        implementation libs.generativeai.google
+        api libs.generativeai.google
         implementation(libs.kotlinx.coroutines.core)
         implementation(libs.kotlinx.serialization.json)
       }
diff --git a/roborazzi-ai-gemini/src/commonMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.kt b/roborazzi-ai-gemini/src/commonMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.kt
index 8a77425f..fa903d85 100644
--- a/roborazzi-ai-gemini/src/commonMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.kt
+++ b/roborazzi-ai-gemini/src/commonMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.kt
@@ -1,8 +1,11 @@
 package com.github.takahirom.roborazzi
 
-import com.github.takahirom.roborazzi.AiCompareOptions.AiModel
+import com.github.takahirom.roborazzi.AiAssertionOptions.AiAssertionModel
+import com.github.takahirom.roborazzi.AiAssertionOptions.AiAssertionModel.Companion.DefaultMaxOutputTokens
+import com.github.takahirom.roborazzi.AiAssertionOptions.AiAssertionModel.Companion.DefaultTemperature
 import dev.shreyaspatil.ai.client.generativeai.GenerativeModel
 import dev.shreyaspatil.ai.client.generativeai.type.FunctionType
+import dev.shreyaspatil.ai.client.generativeai.type.GenerationConfig
 import dev.shreyaspatil.ai.client.generativeai.type.PlatformImage
 import dev.shreyaspatil.ai.client.generativeai.type.Schema
 import dev.shreyaspatil.ai.client.generativeai.type.content
@@ -11,15 +14,22 @@ import kotlinx.coroutines.runBlocking
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 
-class GeminiAiModel(
+@ExperimentalRoborazziApi
+class GeminiAiAssertionModel(
   private val apiKey: String,
-  private val modelName: String = "gemini-1.5-pro"
-) : AiModel, AiComparisonResultFactory {
-  override fun invoke(
+  private val modelName: String = "gemini-1.5-flash",
+  private val generationConfigBuilder: GenerationConfig.Builder.() -> Unit = {
+    maxOutputTokens = DefaultMaxOutputTokens
+    temperature = DefaultTemperature
+  }
+) : AiAssertionModel {
+  override fun assert(
+    referenceImageFilePath: String,
     comparisonImageFilePath: String,
-    aiCompareOptions: AiCompareOptions
-  ): AiComparisonResult {
-    val systemPrompt = aiCompareOptions.systemPrompt
+    actualImageFilePath: String,
+    aiAssertionOptions: AiAssertionOptions
+  ): AiAssertionResults {
+    val systemPrompt = aiAssertionOptions.systemPrompt
     val generativeModel = GenerativeModel(
       modelName = modelName,
       apiKey = apiKey,
@@ -27,7 +37,6 @@ class GeminiAiModel(
         text(systemPrompt)
       },
       generationConfig = generationConfig {
-        maxOutputTokens = 8192
         responseMimeType = "application/json"
         responseSchema = Schema(
           name = "content",
@@ -52,12 +61,13 @@ class GeminiAiModel(
             required = listOf("fulfillment_percent")
           ),
         )
+        generationConfigBuilder()
       },
     )
 
-    val template = aiCompareOptions.promptTemplate
+    val template = aiAssertionOptions.promptTemplate
 
-    val inputPrompt = aiCompareOptions.inputPrompt(aiCompareOptions)
+    val inputPrompt = aiAssertionOptions.inputPrompt(aiAssertionOptions)
     val inputContent = content {
       image(readByteArrayFromFile(comparisonImageFilePath))
       val prompt = template.replace("INPUT_PROMPT", inputPrompt)
@@ -77,15 +87,16 @@ class GeminiAiModel(
         response.text
       )
     )
-    return AiComparisonResult(
-      aiConditionResults = aiCompareOptions.aiConditions.mapIndexed { index, it ->
+    return AiAssertionResults(
+      aiAssertionResults = aiAssertionOptions.aiAssertions.mapIndexed { index, condition ->
         val assertResult = geminiResult.getOrNull(index) ?: GeminiAiConditionResult(
           fulfillmentPercent = 0,
           explanation = "AI model did not return a result for this assertion"
         )
-        AiConditionResult(
-          assertPrompt = it.assertPrompt,
-          requiredFulfillmentPercent = it.requiredFulfillmentPercent,
+        AiAssertionResult(
+          assertPrompt = condition.assertPrompt,
+          requiredFulfillmentPercent = condition.requiredFulfillmentPercent,
+          failIfNotFulfilled = condition.failIfNotFulfilled,
           fulfillmentPercent = assertResult.fulfillmentPercent,
           explanation = assertResult.explanation,
         )
@@ -96,10 +107,10 @@ class GeminiAiModel(
 
 
 @Serializable
-data class GeminiAiConditionResult(
+private data class GeminiAiConditionResult(
   @SerialName("fulfillment_percent")
   val fulfillmentPercent: Int,
   val explanation: String?,
 )
 
-expect fun readByteArrayFromFile(filePath: String): PlatformImage
+internal expect fun readByteArrayFromFile(filePath: String): PlatformImage
diff --git a/roborazzi-ai-gemini/src/jvmMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.jvm.kt b/roborazzi-ai-gemini/src/jvmMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.jvm.kt
index c9c7d13a..a961f781 100644
--- a/roborazzi-ai-gemini/src/jvmMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.jvm.kt
+++ b/roborazzi-ai-gemini/src/jvmMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.jvm.kt
@@ -3,6 +3,6 @@ package com.github.takahirom.roborazzi
 import dev.shreyaspatil.ai.client.generativeai.type.PlatformImage
 import java.io.File
 
-actual fun readByteArrayFromFile(filePath: String): PlatformImage {
+internal actual fun readByteArrayFromFile(filePath: String): PlatformImage {
   return PlatformImage(File(filePath).readBytes())
 }
\ No newline at end of file
diff --git a/roborazzi-ai-gemini/src/nativeMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.native.kt b/roborazzi-ai-gemini/src/nativeMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.native.kt
index bd5561b3..d5c5d842 100644
--- a/roborazzi-ai-gemini/src/nativeMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.native.kt
+++ b/roborazzi-ai-gemini/src/nativeMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.native.kt
@@ -4,6 +4,6 @@ import dev.shreyaspatil.ai.client.generativeai.type.PlatformImage
 import dev.shreyaspatil.ai.client.generativeai.type.asPlatformImage
 import platform.UIKit.UIImage
 
-actual fun readByteArrayFromFile(filePath: String): PlatformImage {
+internal actual fun readByteArrayFromFile(filePath: String): PlatformImage {
   return UIImage.imageWithContentsOfFile(filePath)!!.asPlatformImage()
 }
\ No newline at end of file
diff --git a/roborazzi-ai-openai/build.gradle b/roborazzi-ai-openai/build.gradle
index daf99416..804208fe 100644
--- a/roborazzi-ai-openai/build.gradle
+++ b/roborazzi-ai-openai/build.gradle
@@ -28,7 +28,7 @@ kotlin {
         implementation "io.github.takahirom.roborazzi:roborazzi-core:$VERSION_NAME"
         implementation(libs.kotlinx.coroutines.core)
         implementation(libs.ktor.serialization.json)
-        api(libs.ktor.client.core)
+        implementation(libs.ktor.client.core)
         implementation(libs.ktor.client.cio)
         implementation(libs.ktor.client.logging)
         implementation(libs.ktor.client.contentnegotiation)
diff --git a/roborazzi-ai-openai/src/commonMain/kotlin/com/github/takahirom/roborazzi/OpenAiRoborazziAi.kt b/roborazzi-ai-openai/src/commonMain/kotlin/com/github/takahirom/roborazzi/OpenAiRoborazziAi.kt
index 042de446..1d20bee0 100644
--- a/roborazzi-ai-openai/src/commonMain/kotlin/com/github/takahirom/roborazzi/OpenAiRoborazziAi.kt
+++ b/roborazzi-ai-openai/src/commonMain/kotlin/com/github/takahirom/roborazzi/OpenAiRoborazziAi.kt
@@ -1,5 +1,7 @@
 package com.github.takahirom.roborazzi
 
+import com.github.takahirom.roborazzi.AiAssertionOptions.AiAssertionModel.Companion.DefaultMaxOutputTokens
+import com.github.takahirom.roborazzi.AiAssertionOptions.AiAssertionModel.Companion.DefaultTemperature
 import com.github.takahirom.roborazzi.CaptureResults.Companion.json
 import io.ktor.client.HttpClient
 import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
@@ -30,11 +32,19 @@ import kotlinx.serialization.json.jsonObject
 import kotlin.io.encoding.Base64
 import kotlin.io.encoding.ExperimentalEncodingApi
 
-class OpenAiAiModel(
+@ExperimentalRoborazziApi
+class OpenAiAiAssertionModel(
   private val apiKey: String,
   private val modelName: String = "gpt-4o",
   private val baseUrl: String = "https://api.openai.com/v1/",
   private val loggingEnabled: Boolean = false,
+  private val temperature: Float = DefaultTemperature,
+  private val maxTokens: Int = DefaultMaxOutputTokens,
+  private val seed: Int = 1566,
+  private val requestBuilderModifier: (HttpRequestBuilder.() -> Unit) = {
+    header("Authorization", "Bearer $apiKey")
+  }
+) : AiAssertionOptions.AiAssertionModel {
   private val httpClient: HttpClient = HttpClient {
     install(ContentNegotiation) {
       json(
@@ -48,19 +58,17 @@ class OpenAiAiModel(
         level = LogLevel.ALL
       }
     }
-  },
-  private val requestBuilderModifier: (HttpRequestBuilder.() -> Unit) = {
-    header("Authorization", "Bearer $apiKey")
   }
-) : AiCompareOptions.AiModel, AiComparisonResultFactory {
 
-  override fun invoke(
+  override fun assert(
+    referenceImageFilePath: String,
     comparisonImageFilePath: String,
-    aiCompareOptions: AiCompareOptions
-  ): AiComparisonResult {
-    val systemPrompt = aiCompareOptions.systemPrompt
-    val template = aiCompareOptions.promptTemplate
-    val inputPrompt = aiCompareOptions.inputPrompt(aiCompareOptions)
+    actualImageFilePath: String,
+    aiAssertionOptions: AiAssertionOptions
+  ): AiAssertionResults {
+    val systemPrompt = aiAssertionOptions.systemPrompt
+    val template = aiAssertionOptions.promptTemplate
+    val inputPrompt = aiAssertionOptions.inputPrompt(aiAssertionOptions)
     val imageBytes = readByteArrayFromFile(comparisonImageFilePath)
     val imageBase64 = imageBytes.encodeBase64()
     val messages = listOf(
@@ -92,34 +100,32 @@ class OpenAiAiModel(
     val responseText = runBlocking {
       chatCompletion(
         messages = messages,
-        apiKey = apiKey,
         model = modelName
       )
     }
     debugLog {
       "OpenAiAiModel: response: $responseText"
     }
-    val aiConditionResults = parseOpenAiResponse(responseText, aiCompareOptions)
-    return AiComparisonResult(
-      aiConditionResults = aiConditionResults
+    val aiConditionResults = parseOpenAiResponse(responseText, aiAssertionOptions)
+    return AiAssertionResults(
+      aiAssertionResults = aiConditionResults
     )
   }
 
   private suspend fun chatCompletion(
     messages: List<Message>,
-    apiKey: String,
     model: String
   ): String {
     val requestBody = ChatCompletionRequest(
       model = model,
       messages = messages,
-      temperature = 0.7F,
-      maxTokens = 100,
+      temperature = temperature,
+      maxTokens = maxTokens,
       responseFormat = ResponseFormat(
         type = "json_schema",
         jsonSchema = buildJsonSchema(),
       ),
-      seed = 1566
+      seed = seed
     )
     val response: HttpResponse = httpClient.post(baseUrl + "chat/completions") {
       requestBuilderModifier()
@@ -171,12 +177,12 @@ class OpenAiAiModel(
 
 private fun parseOpenAiResponse(
   responseText: String,
-  aiCompareOptions: AiCompareOptions
-): List<AiConditionResult> {
+  aiAssertionOptions: AiAssertionOptions
+): List<AiAssertionResult> {
   val openAiResult = try {
     val element = json.parseToJsonElement(responseText)
     val resultsElement = element.jsonObject["results"]
-    val results = if (resultsElement != null) {
+    val results = if (resultsElement!=null) {
       json.decodeFromJsonElement<List<OpenAiConditionResult>>(resultsElement)
     } else {
       emptyList()
@@ -186,13 +192,14 @@ private fun parseOpenAiResponse(
     debugLog { "Failed to parse OpenAI response: ${e.message}" }
     OpenAiResponse(results = emptyList())
   }
-  return aiCompareOptions.aiConditions.mapIndexed { index, condition ->
+  return aiAssertionOptions.aiAssertions.mapIndexed { index, condition ->
     val result = openAiResult.results.getOrNull(index)
     val fulfillmentPercent = result?.fulfillmentPercent ?: 0
     val explanation = result?.explanation ?: "AI model did not return a result for this assertion"
-    AiConditionResult(
+    AiAssertionResult(
       assertPrompt = condition.assertPrompt,
       requiredFulfillmentPercent = condition.requiredFulfillmentPercent,
+      failIfNotFulfilled = condition.failIfNotFulfilled,
       fulfillmentPercent = fulfillmentPercent,
       explanation = explanation,
     )
@@ -211,41 +218,41 @@ private fun ByteArray.encodeBase64(): String {
 // Request
 
 @Serializable
-data class ChatCompletionRequest(
+private data class ChatCompletionRequest(
   val model: String,
   val messages: List<Message>,
   val temperature: Float,
-  @SerialName("max_tokens") val maxTokens: Int,
+  @SerialName("max_completion_tokens") val maxTokens: Int,
   @SerialName("response_format") val responseFormat: ResponseFormat?,
   val seed: Int,
 )
 
 @Serializable
-data class ResponseFormat(
+private data class ResponseFormat(
   val type: String,
   @SerialName("json_schema") val jsonSchema: JsonObject
 )
 
 @Serializable
-data class Message(
+private data class Message(
   val role: String,
   val content: List<Content>
 )
 
 @Serializable
-data class Content(
+private data class Content(
   val type: String,
   val text: String? = null,
   @SerialName("image_url") val imageUrl: ImageUrl? = null
 )
 
 @Serializable
-data class ImageUrl(
+private data class ImageUrl(
   val url: String
 )
 
 @Serializable
-data class ChatCompletionResponse(
+private data class ChatCompletionResponse(
   val id: String,
   val `object`: String,
   val created: Long,
@@ -255,20 +262,20 @@ data class ChatCompletionResponse(
 )
 
 @Serializable
-data class Choice(
+private data class Choice(
   val index: Int,
   val message: ChoiceMessage,
   @SerialName("finish_reason") val finishReason: String? = null,
 )
 
 @Serializable
-data class ChoiceMessage(
+private data class ChoiceMessage(
   val role: String,
   val content: String
 )
 
 @Serializable
-data class Usage(
+private data class Usage(
   @SerialName("prompt_tokens") val promptTokens: Int,
   @SerialName("completion_tokens") val completionTokens: Int? = null,
   @SerialName("total_tokens") val totalTokens: Int,
@@ -278,12 +285,12 @@ data class Usage(
 // Response
 
 @Serializable
-data class OpenAiResponse(
+private data class OpenAiResponse(
   val results: List<OpenAiConditionResult>
 )
 
 @Serializable
-data class OpenAiConditionResult(
+private data class OpenAiConditionResult(
   @SerialName("fulfillment_percent")
   val fulfillmentPercent: Int,
   val explanation: String?,
diff --git a/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt b/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt
index 3999f746..54035b6f 100644
--- a/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt
+++ b/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt
@@ -523,7 +523,7 @@ fun SemanticsNodeInteraction.captureRoboImage(
           actualFile = actualFilePath,
           goldenFile = goldenFilePath,
           timestampNs = getNanoTime(),
-          aiComparisonResult = null,
+          aiAssertionResults = null,
           contextData = emptyMap()
         )
         writeJson(result, resultsDir, nameWithoutExtension)
@@ -541,7 +541,7 @@ fun SemanticsNodeInteraction.captureRoboImage(
           goldenFile = goldenFilePath,
           timestampNs = getNanoTime(),
           diffPercentage = Float.NaN,
-          aiComparisonResult = null,
+          aiAssertionResults = null,
           contextData = emptyMap()
         )
         writeJson(result, resultsDir, nameWithoutExtension)
@@ -568,7 +568,7 @@ fun SemanticsNodeInteraction.captureRoboImage(
           actualFile = actualFilePath,
           goldenFile = goldenFilePath,
           timestampNs = getNanoTime(),
-          aiComparisonResult = null,
+          aiAssertionResults = null,
           contextData = emptyMap()
         )
         writeJson(result, resultsDir, nameWithoutExtension)
@@ -586,7 +586,7 @@ fun SemanticsNodeInteraction.captureRoboImage(
           goldenFile = goldenFilePath,
           timestampNs = getNanoTime(),
           diffPercentage = Float.NaN,
-          aiComparisonResult = null,
+          aiAssertionResults = null,
           contextData = emptyMap()
         )
         writeJson(result, resultsDir, nameWithoutExtension)
@@ -613,7 +613,7 @@ fun SemanticsNodeInteraction.captureRoboImage(
           actualFile = goldenFilePath,
           goldenFile = goldenFilePath,
           timestampNs = getNanoTime(),
-          aiComparisonResult = null,
+          aiAssertionResults = null,
           contextData = emptyMap()
         )
         writeJson(result, resultsDir, nameWithoutExtension)
@@ -631,7 +631,7 @@ fun SemanticsNodeInteraction.captureRoboImage(
           goldenFile = goldenFilePath,
           timestampNs = getNanoTime(),
           diffPercentage = Float.NaN,
-          aiComparisonResult = null,
+          aiAssertionResults = null,
           contextData = emptyMap()
         )
         writeJson(result, resultsDir, nameWithoutExtension)
@@ -658,7 +658,7 @@ fun SemanticsNodeInteraction.captureRoboImage(
           actualFile = goldenFilePath,
           goldenFile = goldenFilePath,
           timestampNs = getNanoTime(),
-          aiComparisonResult = null,
+          aiAssertionResults = null,
           contextData = emptyMap()
         )
         writeJson(result, resultsDir, nameWithoutExtension)
@@ -676,7 +676,7 @@ fun SemanticsNodeInteraction.captureRoboImage(
           goldenFile = goldenFilePath,
           timestampNs = getNanoTime(),
           diffPercentage = Float.NaN,
-          aiComparisonResult = null,
+          aiAssertionResults = null,
           contextData = emptyMap()
         )
         writeJson(result, resultsDir, nameWithoutExtension)


Test patch:
diff --git a/include-build/roborazzi-core/src/jvmTest/kotlin/io/github/takahirom/roborazzi/CaptureResultTest.kt b/include-build/roborazzi-core/src/jvmTest/kotlin/io/github/takahirom/roborazzi/CaptureResultTest.kt
index 411e1fad..e16038bd 100644
--- a/include-build/roborazzi-core/src/jvmTest/kotlin/io/github/takahirom/roborazzi/CaptureResultTest.kt
+++ b/include-build/roborazzi-core/src/jvmTest/kotlin/io/github/takahirom/roborazzi/CaptureResultTest.kt
@@ -36,7 +36,7 @@ class CaptureResultTest {
         actualFile = "/actual_file",
         goldenFile = "/golden_file",
         timestampNs = 123456789,
-        aiComparisonResult = null,
+        aiAssertionResults = null,
         contextData = mapOf(
           "key" to 2,
           "keyDouble" to 2.5,
@@ -48,7 +48,7 @@ class CaptureResultTest {
         actualFile = "/actual_file",
         timestampNs = 123456789,
         diffPercentage = 0.123f,
-        aiComparisonResult = null,
+        aiAssertionResults = null,
         contextData = mapOf("key" to Long.MAX_VALUE - 100),
       ),
       CaptureResult.Unchanged(
diff --git a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/GeminiAiAiTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/GeminiAiAiTest.kt
index c16d0269..44b9117a 100644
--- a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/GeminiAiAiTest.kt
+++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/GeminiAiAiTest.kt
@@ -4,19 +4,23 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
 import androidx.test.espresso.Espresso.onView
 import androidx.test.espresso.matcher.ViewMatchers
 import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.github.takahirom.roborazzi.AiCompareOptions
-import com.github.takahirom.roborazzi.GeminiAiModel
+import com.github.takahirom.roborazzi.AiAssertionOptions
+import com.github.takahirom.roborazzi.DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH
+import com.github.takahirom.roborazzi.GeminiAiAssertionModel
 import com.github.takahirom.roborazzi.ROBORAZZI_DEBUG
 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
 import com.github.takahirom.roborazzi.RoborazziOptions
 import com.github.takahirom.roborazzi.RoborazziRule
+import com.github.takahirom.roborazzi.RoborazziTaskType
 import com.github.takahirom.roborazzi.captureRoboImage
 import com.github.takahirom.roborazzi.provideRoborazziContext
+import com.github.takahirom.roborazzi.roboOutputName
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
 import org.robolectric.annotation.GraphicsMode
+import java.io.File
 
 @RunWith(AndroidJUnit4::class)
 @GraphicsMode(GraphicsMode.Mode.NATIVE)
@@ -32,9 +36,10 @@ class GeminiAiAiTest {
   val roborazziRule = RoborazziRule(
     options = RoborazziRule.Options(
       roborazziOptions = RoborazziOptions(
+        taskType =  RoborazziTaskType.Compare,
         compareOptions = RoborazziOptions.CompareOptions(
-          aiCompareOptions = AiCompareOptions(
-            aiModel = GeminiAiModel(
+          aiAssertionOptions = AiAssertionOptions(
+            aiAssertionModel = GeminiAiAssertionModel(
               apiKey = System.getenv("gemini_api_key") ?: ""
             ),
           )
@@ -50,18 +55,20 @@ class GeminiAiAiTest {
       println("Skip the test because gemini_api_key is not set.")
       return
     }
+    File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + File.separator + roboOutputName() + ".png").delete()
     onView(ViewMatchers.isRoot())
       .captureRoboImage(
-        roborazziOptions = provideRoborazziContext().options.addedCompareAiAssertions(
-          AiCompareOptions.AiCondition(
+        roborazziOptions = provideRoborazziContext().options.addedAiAssertions(
+          AiAssertionOptions.AiAssertion(
             assertPrompt = "it should have PREVIOUS button",
             requiredFulfillmentPercent = 90,
           ),
-          AiCompareOptions.AiCondition(
+          AiAssertionOptions.AiAssertion(
             assertPrompt = "it should show First Fragment",
             requiredFulfillmentPercent = 90,
           )
         )
       )
+    File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + File.separator + roboOutputName() + "_compare.png").delete()
   }
 }
\ No newline at end of file
diff --git a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ManualTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ManualTest.kt
index a4be307a..ea80db93 100644
--- a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ManualTest.kt
+++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ManualTest.kt
@@ -19,9 +19,9 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.dropbox.differ.ImageComparator
 import com.dropbox.differ.SimpleImageComparator
-import com.github.takahirom.roborazzi.AiCompareOptions
+import com.github.takahirom.roborazzi.AiAssertionOptions
 import com.github.takahirom.roborazzi.Dump
-import com.github.takahirom.roborazzi.GeminiAiModel
+import com.github.takahirom.roborazzi.GeminiAiAssertionModel
 import com.github.takahirom.roborazzi.RoboComponent
 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
 import com.github.takahirom.roborazzi.RoborazziOptions
@@ -66,18 +66,18 @@ class ManualTest {
         roborazziOptions = RoborazziOptions(
           compareOptions = if (System.getenv("gemini_api_key")?.isNotBlank() == true) {
             RoborazziOptions.CompareOptions(
-              aiCompareOptions = AiCompareOptions(
-                aiConditions = listOf(
-                  AiCompareOptions.AiCondition(
+              aiAssertionOptions = AiAssertionOptions(
+                aiAssertions = listOf(
+                  AiAssertionOptions.AiAssertion(
                     assertPrompt = "it should have PREVIOUS button",
                     requiredFulfillmentPercent = 90,
                   ),
-                  AiCompareOptions.AiCondition(
+                  AiAssertionOptions.AiAssertion(
                     assertPrompt = "it should show First Fragment",
                     requiredFulfillmentPercent = 90,
                   ),
                 ),
-                aiModel = GeminiAiModel(
+                aiAssertionModel = GeminiAiAssertionModel(
                   apiKey = System.getenv("gemini_api_key")!!,
                 ),
               )
diff --git a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/OpenAiTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/OpenAiTest.kt
index d71a5200..188ce835 100644
--- a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/OpenAiTest.kt
+++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/OpenAiTest.kt
@@ -4,19 +4,23 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
 import androidx.test.espresso.Espresso.onView
 import androidx.test.espresso.matcher.ViewMatchers
 import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.github.takahirom.roborazzi.AiCompareOptions
-import com.github.takahirom.roborazzi.OpenAiAiModel
+import com.github.takahirom.roborazzi.AiAssertionOptions
+import com.github.takahirom.roborazzi.DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH
+import com.github.takahirom.roborazzi.OpenAiAiAssertionModel
 import com.github.takahirom.roborazzi.ROBORAZZI_DEBUG
 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
 import com.github.takahirom.roborazzi.RoborazziOptions
 import com.github.takahirom.roborazzi.RoborazziRule
+import com.github.takahirom.roborazzi.RoborazziTaskType
 import com.github.takahirom.roborazzi.captureRoboImage
 import com.github.takahirom.roborazzi.provideRoborazziContext
+import com.github.takahirom.roborazzi.roboOutputName
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
 import org.robolectric.annotation.GraphicsMode
+import java.io.File
 
 @RunWith(AndroidJUnit4::class)
 @GraphicsMode(GraphicsMode.Mode.NATIVE)
@@ -32,9 +36,10 @@ class OpenAiTest {
   val roborazziRule = RoborazziRule(
     options = RoborazziRule.Options(
       roborazziOptions = RoborazziOptions(
+        taskType =  RoborazziTaskType.Compare,
         compareOptions = RoborazziOptions.CompareOptions(
-          aiCompareOptions = AiCompareOptions(
-            aiModel = OpenAiAiModel(
+          aiAssertionOptions = AiAssertionOptions(
+            aiAssertionModel = OpenAiAiAssertionModel(
               apiKey = System.getenv("openai_api_key").orEmpty(),
               modelName = "gpt-4o",
             ),
@@ -45,24 +50,26 @@ class OpenAiTest {
   )
 
   @Test
-  fun captureWithAi2() {
+  fun captureWithAi() {
     ROBORAZZI_DEBUG = true
     if (System.getenv("openai_api_key") == null) {
       println("Skip the test because openai_api_key is not set.")
       return
     }
+    File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + File.separator + roboOutputName() + ".png").delete()
     onView(ViewMatchers.isRoot())
       .captureRoboImage(
-        roborazziOptions = provideRoborazziContext().options.addedCompareAiAssertions(
-          AiCompareOptions.AiCondition(
+        roborazziOptions = provideRoborazziContext().options.addedAiAssertions(
+          AiAssertionOptions.AiAssertion(
             assertPrompt = "it should have PREVIOUS button",
             requiredFulfillmentPercent = 90,
           ),
-          AiCompareOptions.AiCondition(
+          AiAssertionOptions.AiAssertion(
             assertPrompt = "it should show First Fragment",
             requiredFulfillmentPercent = 90,
           )
         )
       )
+    File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + File.separator + roboOutputName() + "_compare.png").delete()
   }
 }
\ No newline at end of file
diff --git a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/boxed/AiManualTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/boxed/AiManualTest.kt
index 9a4dfe6a..ad903933 100644
--- a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/boxed/AiManualTest.kt
+++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/boxed/AiManualTest.kt
@@ -3,20 +3,23 @@ package com.github.takahirom.roborazzi.sample.boxed
 import androidx.compose.ui.test.junit4.createAndroidComposeRule
 import androidx.compose.ui.test.onRoot
 import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.github.takahirom.roborazzi.AiCompareOptions
-import com.github.takahirom.roborazzi.AiComparisonResult
-import com.github.takahirom.roborazzi.AiConditionResult
+import com.github.takahirom.roborazzi.AiAssertionOptions
+import com.github.takahirom.roborazzi.AiAssertionResult
+import com.github.takahirom.roborazzi.AiAssertionResults
+import com.github.takahirom.roborazzi.DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH
 import com.github.takahirom.roborazzi.ROBORAZZI_DEBUG
 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
 import com.github.takahirom.roborazzi.RoborazziOptions
 import com.github.takahirom.roborazzi.RoborazziTaskType
 import com.github.takahirom.roborazzi.captureRoboImage
+import com.github.takahirom.roborazzi.roboOutputName
 import com.github.takahirom.roborazzi.sample.MainActivity
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
 import org.robolectric.annotation.GraphicsMode
+import java.io.File
 
 @RunWith(AndroidJUnit4::class)
 @GraphicsMode(GraphicsMode.Mode.NATIVE)
@@ -33,11 +36,15 @@ class AiManualTest {
   )
   fun whenAiReturnError() {
     boxedEnvironment {
-      ROBORAZZI_DEBUG = true
-      composeTestRule.onRoot()
-        .captureRoboImage(
-          roborazziOptions = createOptionsFulfillmentPercent(0)
-        )
+      try {
+        ROBORAZZI_DEBUG = true
+        composeTestRule.onRoot()
+          .captureRoboImage(
+            roborazziOptions = createOptionsFulfillmentPercent(0)
+          )
+      }finally {
+        File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH +File.separator+ roboOutputName() + "_compare.png").delete()
+      }
     }
   }
 
@@ -49,6 +56,7 @@ class AiManualTest {
         .captureRoboImage(
           roborazziOptions = createOptionsFulfillmentPercent(100)
         )
+      File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + File.separator + roboOutputName() + "_compare.png").delete()
     }
   }
 
@@ -56,26 +64,29 @@ class AiManualTest {
     // Even compare task, it will be failed because of the manual AI model.
     taskType = RoborazziTaskType.Compare,
     compareOptions = RoborazziOptions.CompareOptions(
-      aiCompareOptions = AiCompareOptions(
-        aiModel = object : AiCompareOptions.AiModel.Manual {
-          override fun invoke(
+      aiAssertionOptions = AiAssertionOptions(
+        aiAssertionModel = object : AiAssertionOptions.AiAssertionModel {
+          override fun assert(
+            referenceImageFilePath: String,
             comparisonImageFilePath: String,
-            aiCompareOptions: AiCompareOptions
-          ): AiComparisonResult {
-            return AiComparisonResult(
-              aiConditionResults = aiCompareOptions.aiConditions.map {
-                AiConditionResult(
-                  assertPrompt = it.assertPrompt,
+            actualImageFilePath: String,
+            aiAssertionOptions: AiAssertionOptions
+          ): AiAssertionResults {
+            return AiAssertionResults(
+              aiAssertionResults = aiAssertionOptions.aiAssertions.map { assertion ->
+                AiAssertionResult(
+                  assertPrompt = assertion.assertPrompt,
                   fulfillmentPercent = fulfillmentPercent,
-                  requiredFulfillmentPercent = it.requiredFulfillmentPercent,
+                  requiredFulfillmentPercent = assertion.requiredFulfillmentPercent,
+                  failIfNotFulfilled = assertion.failIfNotFulfilled,
                   explanation = "This is a manual test.",
                 )
               }
             )
           }
         },
-        aiConditions = listOf(
-          AiCompareOptions.AiCondition(
+        aiAssertions = listOf(
+          AiAssertionOptions.AiAssertion(
             assertPrompt = "it should have PREVIOUS button",
             requiredFulfillmentPercent = 90,
           ),