Skip to content
Merged
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
120 changes: 117 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import groovy.json.JsonSlurper
import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

Expand All @@ -12,9 +13,9 @@ fun String.asBuildConfigStringLiteral(): String =

val releaseVersionCode = providers.gradleProperty("ZEST_VERSION_CODE")
.map(String::toInt)
.orElse(1)
.orElse(2)
val releaseVersionName = providers.gradleProperty("ZEST_VERSION_NAME")
.orElse("1.0.0")
.orElse("1.0.1")
val localProperties = Properties().apply {
val localPropertiesFile = rootProject.layout.projectDirectory.file("local.properties").asFile
if (localPropertiesFile.isFile) {
Expand Down Expand Up @@ -240,10 +241,123 @@ val verifySessionOnlyStorage = tasks.register("verifySessionOnlyStorage") {
}
}

val verifyModuleVersionManifest = tasks.register("verifyModuleVersionManifest") {
group = "verification"
description = "Fails if a Zest module is missing from the production version manifest or docs."
doLast {
val manifestFile = rootProject.file("backend/module_versions.json")
val documentationFile = rootProject.file("documentation/12-module-versioning.md")
if (!manifestFile.isFile) {
throw GradleException("Missing module version manifest: ${manifestFile.relativeTo(rootDir)}")
}
if (!documentationFile.isFile) {
throw GradleException(
"Missing module version documentation: ${documentationFile.relativeTo(rootDir)}"
)
}

val manifest = JsonSlurper().parse(manifestFile) as Map<*, *>
val releaseVersion = manifest["releaseVersion"] as? String
?: throw GradleException("module_versions.json must define releaseVersion.")
val versionPattern = Regex("""^\d+\.\d+\.\d+([+-][0-9A-Za-z.-]+)?$""")
if (!versionPattern.matches(releaseVersion)) {
throw GradleException("releaseVersion must be semantic versioning: $releaseVersion")
}

val modules = manifest["modules"] as? List<*>
?: throw GradleException("module_versions.json must define modules[].")
val moduleMaps = modules.mapIndexed { index, item ->
item as? Map<*, *> ?: throw GradleException("modules[$index] must be a JSON object.")
}
val moduleIds = mutableSetOf<String>()
val docsText = documentationFile.readText()

fun moduleValue(module: Map<*, *>, key: String): String =
module[key] as? String
?: throw GradleException("Every module must define string field '$key': $module")

moduleMaps.forEach { module ->
val id = moduleValue(module, "id")
val kind = moduleValue(module, "kind")
val path = moduleValue(module, "path")
val version = moduleValue(module, "version")

if (!moduleIds.add(id)) {
throw GradleException("Duplicate module version entry: $id")
}
if (!versionPattern.matches(version)) {
throw GradleException("Module $id has non-semver version: $version")
}
if (!docsText.contains("`$id`") || !docsText.contains("`$version`")) {
throw GradleException(
"Module version documentation is missing module $id version $version."
)
}
if (kind != "android-gradle-module" && path.startsWith(":")) {
throw GradleException("Only android-gradle-module entries can use Gradle paths: $id")
}
if (!path.startsWith(":") && !rootProject.file(path).exists()) {
throw GradleException("Module $id points to missing path: $path")
}
}

val manifestGradlePaths = moduleMaps
.filter { moduleValue(it, "kind") == "android-gradle-module" }
.map { moduleValue(it, "path") }
.toSet()
val actualGradlePaths = rootProject.allprojects
.map { it.path }
.filter { it != ":" }
.toSet()
val missingGradleModules = actualGradlePaths - manifestGradlePaths
if (missingGradleModules.isNotEmpty()) {
throw GradleException(
"Gradle modules missing from backend/module_versions.json: " +
missingGradleModules.joinToString()
)
}

val packageRoot = layout.projectDirectory
.dir("src/main/java/com/b2/ultraprocessed")
.asFile
val actualAndroidPackagePaths = packageRoot
.listFiles { file -> file.isDirectory }
.orEmpty()
.map { it.relativeTo(rootDir).invariantSeparatorsPath }
.toSet()
val manifestAndroidPackagePaths = moduleMaps
.filter { moduleValue(it, "kind") == "android-package" }
.map { moduleValue(it, "path") }
.toSet()
val missingAndroidPackages = actualAndroidPackagePaths - manifestAndroidPackagePaths
val staleAndroidPackages = manifestAndroidPackagePaths - actualAndroidPackagePaths
if (missingAndroidPackages.isNotEmpty() || staleAndroidPackages.isNotEmpty()) {
throw GradleException(
buildString {
append("Android package module version manifest mismatch.")
if (missingAndroidPackages.isNotEmpty()) {
append("\nMissing package entries: ")
append(missingAndroidPackages.joinToString())
}
if (staleAndroidPackages.isNotEmpty()) {
append("\nStale package entries: ")
append(staleAndroidPackages.joinToString())
}
}
)
}
}
}

val verifySourceTreeForBuild = tasks.register("verifySourceTreeForBuild") {
group = "verification"
description = "Guards the Android source tree from retired files and macOS dataless placeholders."
dependsOn(verifyNoRetiredSourceFiles, verifyNoDatalessSources, verifySessionOnlyStorage)
dependsOn(
verifyNoRetiredSourceFiles,
verifyNoDatalessSources,
verifySessionOnlyStorage,
verifyModuleVersionManifest,
)
}

tasks.named("preBuild") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ class FoodAnalysisPipeline(

companion object {
const val MIN_NORMALIZED_LENGTH: Int = 12
const val DEFAULT_MODEL_ID: String = "gemini-2.5-flash"
const val DEFAULT_MODEL_ID: String = "gemini-3.5-flash"
private const val PROXY_ANALYSIS_TIMEOUT_MILLIS = 110_000L
private const val CLASSIFICATION_TIMEOUT_MILLIS = PROXY_ANALYSIS_TIMEOUT_MILLIS
private const val INGREDIENT_LIST_TIMEOUT_MILLIS = PROXY_ANALYSIS_TIMEOUT_MILLIS
Expand Down
30 changes: 28 additions & 2 deletions app/src/main/java/com/b2/ultraprocessed/ui/AnalyzingScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,13 @@ fun AnalyzingScreen(

LaunchedEffect(scanSessionId) {
statusMessage = null
currentStep = 0
progress = 0f
val startAt = System.currentTimeMillis()
val outcome = runCatching {
val onStage: (AnalysisStage) -> Unit = { stage ->
currentStep = stage.stepIndex()
progress = stage.progressPercent()
progress = maxOf(progress, stage.progressPercent())
}
val onStatus: (String) -> Unit = { message ->
statusMessage = message
Expand Down Expand Up @@ -173,6 +175,14 @@ fun AnalyzingScreen(
)
}

LaunchedEffect(scanSessionId, currentStep) {
val ceiling = currentStep.progressCeiling()
while (progress < ceiling) {
delay(450L)
progress = minOf(ceiling, progress + currentStep.progressIncrement())
}
}

val transition = rememberInfiniteTransition(label = "analysis-rings")
val rotation by transition.animateFloat(
initialValue = 0f,
Expand Down Expand Up @@ -351,6 +361,22 @@ private fun AnalysisStage.progressPercent(): Float =
when (this) {
AnalysisStage.AnalysingImage -> 8f
AnalysisStage.ExtractingIngredients -> 35f
AnalysisStage.AnalysingIngredients -> 72f
AnalysisStage.AnalysingIngredients -> 40f
AnalysisStage.Completed -> 100f
}

private fun Int.progressCeiling(): Float =
when (this) {
0 -> 34f
1 -> 39f
2 -> 96f
else -> 100f
}

private fun Int.progressIncrement(): Float =
when (this) {
0 -> 3.5f
1 -> 1f
2 -> 0.45f
else -> 0f
}
4 changes: 2 additions & 2 deletions app/src/main/java/com/b2/ultraprocessed/ui/AppModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ enum class AppDestination {
object AppCatalog {
val modelOptions = listOf(
ModelOption(
id = "gemini-2.5-flash",
name = "Gemini 2.5 Flash",
id = "gemini-3.5-flash",
name = "Gemini 3.5 Flash",
provider = "Gemini (Google)",
description = "Backend proxy analysis model for on-device OCR output",
supportsImages = false,
Expand Down
23 changes: 19 additions & 4 deletions app/src/main/java/com/b2/ultraprocessed/ui/UltraProcessedApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ fun UltraProcessedApp(
destination = nextDestination
}

fun clearCurrentResultAndImage() {
deleteLocalScanImage(appContext, currentScanResult?.labelImagePath)
currentScanResult = null
lastCapturedPhotoPath = null
barcodeValue = null
}

fun navigateBackWithinApp() {
when (destination) {
AppDestination.Splash -> Unit
Expand All @@ -107,8 +114,16 @@ fun UltraProcessedApp(
}
}
AppDestination.Scanner -> Unit
AppDestination.Analyzing -> navigateTo(AppDestination.Scanner)
AppDestination.Results -> navigateTo(AppDestination.Scanner)
AppDestination.Analyzing -> {
deleteLocalScanImage(appContext, lastCapturedPhotoPath)
lastCapturedPhotoPath = null
barcodeValue = null
navigateTo(AppDestination.Scanner)
}
AppDestination.Results -> {
clearCurrentResultAndImage()
navigateTo(AppDestination.Scanner)
}
AppDestination.AnalysisError -> {
analysisErrorMessage = ""
navigateTo(AppDestination.Scanner)
Expand Down Expand Up @@ -223,8 +238,7 @@ fun UltraProcessedApp(
?: selectedModelId,
onSuccess = { result ->
barcodeValue = null
deleteLocalScanImage(appContext, result.labelImagePath)
currentScanResult = result.copy(labelImagePath = null)
currentScanResult = result
lastCapturedPhotoPath = null
playSound(AppSoundEvent.Success)
navigateTo(AppDestination.Results)
Expand All @@ -245,6 +259,7 @@ fun UltraProcessedApp(
ResultsScreen(
result = result,
onScanAgain = {
clearCurrentResultAndImage()
navigateTo(AppDestination.Scanner)
},
chatEnabled = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ class ProxyFoodLabelLlmWorkflowTest {
.build()

companion object {
private const val MODEL = "gemini-2.5-flash"
private const val MODEL = "gemini-3.5-flash"

private val SUCCESS_BODY = """
{
Expand All @@ -142,7 +142,7 @@ class ProxyFoodLabelLlmWorkflowTest {
"confidence": 0.6,
"warnings": []
},
"model": "gemini-2.5-flash",
"model": "gemini-3.5-flash",
"usage": {"inputTokens": 12, "outputTokens": 34, "totalTokens": 46}
}
""".trimIndent()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class ProxyResultChatWorkflowTest {
val reply = workflow.askAboutResult(
result = chatContext,
question = "What is concerning?",
modelId = "gemini-2.5-flash",
modelId = "gemini-3.5-flash",
history = listOf(
ResultChatHistoryMessage(role = "user", text = "Why NOVA 4?"),
ResultChatHistoryMessage(role = "assistant", text = "Because additives were detected."),
Expand Down Expand Up @@ -84,7 +84,7 @@ class ProxyResultChatWorkflowTest {
workflow.askAboutResult(
result = chatContext,
question = "What is concerning?",
modelId = "gemini-2.5-flash",
modelId = "gemini-3.5-flash",
).getOrThrow()

assertFalse(hasAuthorization)
Expand Down Expand Up @@ -113,7 +113,7 @@ class ProxyResultChatWorkflowTest {
val result = workflow.askAboutResult(
result = chatContext,
question = "What is concerning?",
modelId = "gemini-2.5-flash",
modelId = "gemini-3.5-flash",
)

assertTrue(result.isFailure)
Expand Down Expand Up @@ -145,7 +145,7 @@ class ProxyResultChatWorkflowTest {
val result = workflow.askAboutResult(
result = chatContext,
question = "What is concerning?",
modelId = "gemini-2.5-flash",
modelId = "gemini-3.5-flash",
)

val message = result.exceptionOrNull()?.message.orEmpty()
Expand Down Expand Up @@ -187,7 +187,7 @@ class ProxyResultChatWorkflowTest {
"answer": "Soy lecithin is the main marker.",
"reason": ""
},
"model": "gemini-2.5-flash",
"model": "gemini-3.5-flash",
"usage": {"inputTokens": 1, "outputTokens": 2, "totalTokens": 3}
}
""".trimIndent()
Expand Down
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY main.py prompt.py ./
COPY main.py prompt.py module_versions.json ./
COPY prompts ./prompts

EXPOSE 8080
Expand Down
Loading
Loading