feat(discovery): wire Gemini Nano via ML Kit GenAI Prompt API

Replace the stub GeminiNanoSummaryProvider with a real implementation
that uses com.google.mlkit:genai-prompt:1.0.0-beta2 for on-device
AI-powered scan summaries on supported Android hardware.

Implementation:
- Generation.getClient() to obtain the GenerativeModel
- generateContentRequest with TextPart for structured prompts
- Temperature 0.3, topK 16, maxOutputTokens 200 for concise output
- Graceful fallback to DiscoverySummaryGenerator on any failure
- Lazy model initialization with error logging via Kermit

The existing buildSessionPrompt() and buildPresetPrompt() methods in
DiscoverySummaryGenerator provide the prompt text. On unsupported
devices or fdroid builds, the provider falls through to the
deterministic algorithmic summary seamlessly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-18 07:38:34 -05:00
parent 4bf8aaf0a9
commit f6bfefd439
4 changed files with 114 additions and 51 deletions

View File

@@ -39,9 +39,8 @@ import kotlin.test.assertTrue
/**
* Migration coverage for discovery tables (D011).
*
* Verifies that the discovery schema (version 38→39 auto-migration) creates
* the expected tables, supports CRUD operations, enforces foreign key cascade
* behavior, and respects column defaults.
* Verifies that the discovery schema (version 38→39 auto-migration) creates the expected tables, supports CRUD
* operations, enforces foreign key cascade behavior, and respects column defaults.
*/
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34])
@@ -71,12 +70,13 @@ class DiscoveryMigrationTest {
@Test
fun discoverySessionTable_insertAndRetrieve() = runTest {
val session = DiscoverySessionEntity(
timestamp = 1_000_000L,
presetsScanned = "LONG_FAST,SHORT_FAST",
homePreset = "LONG_FAST",
completionStatus = "complete",
)
val session =
DiscoverySessionEntity(
timestamp = 1_000_000L,
presetsScanned = "LONG_FAST,SHORT_FAST",
homePreset = "LONG_FAST",
completionStatus = "complete",
)
val id = discoveryDao.insertSession(session)
assertTrue(id > 0, "Insert should return positive auto-generated ID")
val loaded = discoveryDao.getSession(id)
@@ -88,14 +88,15 @@ class DiscoveryMigrationTest {
@Test
fun discoveryPresetResultTable_insertAndRetrieve() = runTest {
val sessionId = discoveryDao.insertSession(testSession())
val result = DiscoveryPresetResultEntity(
sessionId = sessionId,
presetName = "LONG_FAST",
dwellDurationSeconds = 30,
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
)
val result =
DiscoveryPresetResultEntity(
sessionId = sessionId,
presetName = "LONG_FAST",
dwellDurationSeconds = 30,
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
)
val resultId = discoveryDao.insertPresetResult(result)
assertTrue(resultId > 0)
val results = discoveryDao.getPresetResults(sessionId)
@@ -108,17 +109,18 @@ class DiscoveryMigrationTest {
fun discoveredNodeTable_insertAndRetrieve() = runTest {
val sessionId = discoveryDao.insertSession(testSession())
val presetId = discoveryDao.insertPresetResult(testPresetResult(sessionId))
val node = DiscoveredNodeEntity(
presetResultId = presetId,
nodeNum = 12345,
shortName = "TST",
longName = "Test Node",
neighborType = "direct",
latitude = 37.7749,
longitude = -122.4194,
snr = 8.5f,
rssi = -65,
)
val node =
DiscoveredNodeEntity(
presetResultId = presetId,
nodeNum = 12345,
shortName = "TST",
longName = "Test Node",
neighborType = "direct",
latitude = 37.7749,
longitude = -122.4194,
snr = 8.5f,
rssi = -65,
)
val nodeId = discoveryDao.insertDiscoveredNode(node)
assertTrue(nodeId > 0)
val nodes = discoveryDao.getDiscoveredNodes(presetId)
@@ -134,11 +136,7 @@ class DiscoveryMigrationTest {
@Test
fun sessionEntity_defaultValues() = runTest {
// Insert with only required fields — verify defaults
val session = DiscoverySessionEntity(
timestamp = 1L,
presetsScanned = "A",
homePreset = "A",
)
val session = DiscoverySessionEntity(timestamp = 1L, presetsScanned = "A", homePreset = "A")
val id = discoveryDao.insertSession(session)
val loaded = discoveryDao.getSession(id)!!
assertEquals(0, loaded.totalUniqueNodes)
@@ -263,4 +261,3 @@ class DiscoveryMigrationTest {
// endregion
}

View File

@@ -51,5 +51,7 @@ kotlin {
}
commonTest.dependencies { implementation(projects.core.testing) }
androidMain.dependencies { implementation(libs.mlkit.genai.prompt) }
}
}

View File

@@ -16,37 +16,99 @@
*/
package org.meshtastic.feature.discovery.ai
import co.touchlab.kermit.Logger
import com.google.mlkit.genai.prompt.Generation
import com.google.mlkit.genai.prompt.GenerativeModel
import com.google.mlkit.genai.prompt.TextPart
import com.google.mlkit.genai.prompt.generateContentRequest
import org.koin.core.annotation.Single
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.feature.discovery.DiscoverySummaryGenerator
// TODO: Replace with real Gemini Nano on-device implementation once
// `com.google.ai.edge:aicore` or `com.google.android.gms:play-services-generativeai`
// is added to libs.versions.toml. The implementation should:
// 1. Check model availability via GenerativeModel.isAvailable()
// 2. Build a structured prompt with session metrics (nodes, utilization, presets)
// 3. Call generateContent() with the prompt
// 4. Fall back to the algorithmic generator on any error
/**
* Android provider that will use Gemini Nano for on-device AI summaries.
* Android provider that uses Gemini Nano via ML Kit GenAI Prompt API for on-device AI summaries.
*
* Currently delegates to [DiscoverySummaryGenerator] because the Gemini Nano SDK dependency is not yet in the version
* catalog.
* Falls back to [DiscoverySummaryGenerator] when:
* - The on-device model is unavailable (unsupported hardware or not downloaded)
* - Generation fails for any reason
*/
@Single(binds = [DiscoverySummaryAiProvider::class])
class GeminiNanoSummaryProvider(private val generator: DiscoverySummaryGenerator) : DiscoverySummaryAiProvider {
// Delegates to DiscoverySummaryGenerator (algorithmic) so results are always available.
// When real Gemini Nano SDK is wired, this should check GenerativeModel.isAvailable() at runtime.
override val isAvailable: Boolean = true
private val log = Logger.withTag("GeminiNanoSummary")
private val generativeModel: GenerativeModel? by lazy {
@Suppress("TooGenericExceptionCaught") // ML Kit throws undocumented RuntimeExceptions
try {
Generation.getClient()
} catch (e: Exception) {
log.w(e) { "Failed to get GenerativeModel client" }
null
}
}
override val isAvailable: Boolean
get() = checkAvailability()
override suspend fun generateSessionSummary(
session: DiscoverySessionEntity,
presetResults: List<DiscoveryPresetResultEntity>,
): String = generator.generateSessionSummary(session, presetResults)
): String {
val model = generativeModel
if (model == null || !isAvailable) {
log.d { "Gemini Nano unavailable, using algorithmic fallback" }
return generator.generateSessionSummary(session, presetResults)
}
override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String =
generator.generatePresetSummary(result)
val prompt = generator.buildSessionPrompt(session, presetResults)
return generateOrFallback(model, prompt) { generator.generateSessionSummary(session, presetResults) }
}
override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String {
val model = generativeModel
if (model == null || !isAvailable) {
return generator.generatePresetSummary(result)
}
val prompt = generator.buildPresetPrompt(result)
return generateOrFallback(model, prompt) { generator.generatePresetSummary(result) }
}
private suspend fun generateOrFallback(model: GenerativeModel, prompt: String, fallback: () -> String): String =
try {
val request =
generateContentRequest(TextPart(prompt)) {
temperature = TEMPERATURE
topK = TOP_K
maxOutputTokens = MAX_OUTPUT_TOKENS
}
val response = model.generateContent(request)
val text = response.candidates.firstOrNull()?.text
if (text.isNullOrBlank()) {
log.w { "Gemini Nano returned empty response, using fallback" }
fallback()
} else {
text
}
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
log.w(e) { "Gemini Nano generation failed, using fallback" }
fallback()
}
private fun checkAvailability(): Boolean = try {
// FeatureStatus is an IntDef — check synchronously via the lazy model field.
// Note: checkStatus() is suspend in the API; we use a non-suspend heuristic here
// by catching and falling back if unavailable. The actual availability is confirmed
// in generateOrFallback when the suspend call succeeds.
generativeModel != null
} catch (_: Exception) {
false
}
private companion object {
const val TEMPERATURE = 0.3f
const val TOP_K = 16
const val MAX_OUTPUT_TOKENS = 200
}
}

View File

@@ -58,6 +58,7 @@ maps-compose = "8.3.0"
# ML Kit
mlkit-barcode-scanning = "17.3.0"
mlkit-genai-prompt = "1.0.0-beta2"
mlkit-translate = "17.0.3"
# CameraX
@@ -178,6 +179,7 @@ maps-compose = { module = "com.google.maps.android:maps-compose", version.ref =
maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" }
maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" }
mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" }
mlkit-genai-prompt = { module = "com.google.mlkit:genai-prompt", version.ref = "mlkit-genai-prompt" }
mlkit-translate = { module = "com.google.mlkit:translate", version.ref = "mlkit-translate" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" }
wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" }