mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-24 14:50:26 -04:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -51,5 +51,7 @@ kotlin {
|
||||
}
|
||||
|
||||
commonTest.dependencies { implementation(projects.core.testing) }
|
||||
|
||||
androidMain.dependencies { implementation(libs.mlkit.genai.prompt) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user