diff --git a/lib/src/commonMain/kotlin/com/sagar/aicore/InMemoryModelCatalog.kt b/lib/src/commonMain/kotlin/com/sagar/aicore/InMemoryModelCatalog.kt index 1af3b72..953f15f 100644 --- a/lib/src/commonMain/kotlin/com/sagar/aicore/InMemoryModelCatalog.kt +++ b/lib/src/commonMain/kotlin/com/sagar/aicore/InMemoryModelCatalog.kt @@ -36,6 +36,7 @@ class InMemoryModelCatalog : ModelCatalog { // 6-9 GB RAM tier. Devices under 6 GB should be surfaced to the // user as "not supported" rather than attempting a failing init. minDeviceRamMb = 6000, + supportsCharts = true, ), ModelDescriptor( id = "gemma-4-e4b-it-litertlm", @@ -46,6 +47,7 @@ class InMemoryModelCatalog : ModelCatalog { role = ModelRole.LLM_PRIMARY, // 10+ GB RAM tier — flagship devices get the larger E4B variant. minDeviceRamMb = 10000, + supportsCharts = true, ), ModelDescriptor( id = "universal-sentence-encoder", diff --git a/lib/src/commonMain/kotlin/com/sagar/aicore/ModelCatalog.kt b/lib/src/commonMain/kotlin/com/sagar/aicore/ModelCatalog.kt index 2018a3a..4aa16da 100644 --- a/lib/src/commonMain/kotlin/com/sagar/aicore/ModelCatalog.kt +++ b/lib/src/commonMain/kotlin/com/sagar/aicore/ModelCatalog.kt @@ -37,6 +37,14 @@ data class ModelDescriptor( * skipping it on text-only models also frees memory. Defaults to false. */ val supportsVision: Boolean = false, + /** + * Whether this model reliably follows the optional chart-emitting instruction + * (see `chart.ChartInstruction`). Tiny models (≲1B) tend to parrot the in-prompt + * chart examples into unrelated answers instead of honoring the "only when it + * genuinely helps" guardrail, so consumers should append the chart instruction + * to the system prompt ONLY when this is true. Defaults to false (off). + */ + val supportsCharts: Boolean = false, ) enum class ModelFormat { diff --git a/lib/src/commonMain/kotlin/com/sagar/aicore/chart/ChartParser.kt b/lib/src/commonMain/kotlin/com/sagar/aicore/chart/ChartParser.kt index 72081de..9deb711 100644 --- a/lib/src/commonMain/kotlin/com/sagar/aicore/chart/ChartParser.kt +++ b/lib/src/commonMain/kotlin/com/sagar/aicore/chart/ChartParser.kt @@ -25,8 +25,11 @@ import kotlinx.serialization.json.doubleOrNull object ChartParser { fun parse(json: String): ChartSpec? = runCatching { - val obj = Json.parseToJsonElement(json.trim()) as? JsonObject ?: return@runCatching null - val type = str(obj["type"])?.lowercase()?.trim() + val root = Json.parseToJsonElement(json.trim()) as? JsonObject ?: return@runCatching null + // Resolve the chart object + its type. The strict top-level "type" path is + // tried FIRST (so well-formed `{"type":"donut",…}` is unchanged), then the + // tolerant fallbacks for models that wrap the chart or omit the type. + val (obj, type) = resolve(root) ?: return@runCatching null val title = str(obj["title"])?.ifBlank { null } when (type) { "donut", "doughnut", "pie" -> { @@ -51,6 +54,54 @@ object ChartParser { } }.getOrNull() + // ── chart resolution (tolerant of how different models shape the JSON) ── + + /** Wrapper keys some models nest the chart under, mapped to a type hint + * (empty = generic wrapper, infer the type from the inner object). */ + private val WRAPPERS = mapOf( + "donut" to "donut", "doughnut" to "donut", "pie" to "donut", + "bar" to "bar", "barchart" to "bar", "column" to "bar", "columns" to "bar", "histogram" to "bar", + "line" to "line", "linechart" to "line", "area" to "line", "trend" to "line", "growth" to "line", "spline" to "line", + "progress" to "progress", "gauge" to "progress", "ring" to "progress", + "chart" to "", "graph" to "", "plot" to "", "visualization" to "", "visualisation" to "", + ) + + /** Reads an explicit type, tolerating `chartType` / `kind` synonyms. */ + private fun typeOf(obj: JsonObject): String? = + (str(obj["type"]) ?: str(obj["chartType"]) ?: str(obj["kind"]))?.lowercase()?.trim()?.ifBlank { null } + + /** Whether an object carries chart-shaped payload (so a bare key isn't mistaken for a chart). */ + private fun hasData(obj: JsonObject): Boolean = + obj["data"] is JsonArray || obj["slices"] is JsonArray || obj["values"] is JsonArray || + obj["series"] is JsonArray || obj["value"] != null + + /** When the type is absent, infer it from the payload shape (proportions → donut). */ + private fun inferType(obj: JsonObject): String? = when { + obj["series"] is JsonArray -> "line" + obj["data"] is JsonArray || obj["slices"] is JsonArray || obj["values"] is JsonArray -> "donut" + obj["value"] != null -> "progress" + else -> null + } + + /** + * Resolves the (object, type) to read the chart from: + * 1) explicit type on the root (the strict, common case — unchanged); + * 2) the chart nested under a single wrapper key (`{"chart":{…}}`, `{"donut":{…}}`), + * taking the type from the wrapper name, else the inner type, else inferred; + * otherwise null (ordinary JSON stays a code block). + */ + private fun resolve(root: JsonObject): Pair? { + typeOf(root)?.let { return root to it } + for ((key, value) in root) { + val inner = value as? JsonObject ?: continue + val hint = WRAPPERS[key.lowercase().trim()] ?: continue + if (!hasData(inner)) continue + val type = hint.ifBlank { null } ?: typeOf(inner) ?: inferType(inner) ?: continue + return inner to type + } + return null + } + // ── helpers ── /** A flat list of labelled values under "data" / "slices" / "values". */ diff --git a/lib/src/commonTest/kotlin/com/sagar/aicore/chart/ChartParserTest.kt b/lib/src/commonTest/kotlin/com/sagar/aicore/chart/ChartParserTest.kt index 920d462..b115245 100644 --- a/lib/src/commonTest/kotlin/com/sagar/aicore/chart/ChartParserTest.kt +++ b/lib/src/commonTest/kotlin/com/sagar/aicore/chart/ChartParserTest.kt @@ -76,6 +76,31 @@ class ChartParserTest { assertTrue(bar.bars.map { it.label } == listOf("A", "B")) } + @Test fun unwrapsChartWrapperWithoutType() { + // DeepSeek-R1 1.5B shape: nested under "chart", no "type", proportional data → donut. + val spec = ChartParser.parse( + """{"chart":{"name":"Budget Breakdown","title":"Monthly Budget","data":[{"label":"Rent","value":40},{"label":"Food","value":25}]}}""", + ) + val donut = assertIs(spec) + assertEquals("Monthly Budget", donut.title) + assertEquals(2, donut.slices.size) + } + + @Test fun typeFromWrapperKey() { + assertIs(ChartParser.parse("""{"bar":{"data":[{"label":"A","value":1},{"label":"B","value":2}]}}""")) + assertIs(ChartParser.parse("""{"pie":{"data":[{"label":"A","value":1},{"label":"B","value":2}]}}""")) + } + + @Test fun acceptsChartTypeSynonymKey() { + assertIs(ChartParser.parse("""{"chartType":"bar","data":[{"label":"A","value":1}]}""")) + } + + @Test fun bareUntypedJsonStaysNull() { + // No type and no wrapper → ordinary JSON, must remain a code block. + assertNull(ChartParser.parse("""{"data":[{"label":"A","value":1},{"label":"B","value":2}]}""")) + assertNull(ChartParser.parse("""{"foo":1,"bar":[1,2,3]}""")) + } + // kotlin.test has no assertIs in older versions on all targets; provide a tiny helper. private inline fun assertIs(value: Any?): T { assertTrue(value is T, "expected ${T::class.simpleName} but was ${value?.let { it::class.simpleName }}") diff --git a/sample-app/src/main/java/com/nativelm/app/llm/NativeLmModelCatalog.kt b/sample-app/src/main/java/com/nativelm/app/llm/NativeLmModelCatalog.kt index 9c42983..f6bc785 100644 --- a/sample-app/src/main/java/com/nativelm/app/llm/NativeLmModelCatalog.kt +++ b/sample-app/src/main/java/com/nativelm/app/llm/NativeLmModelCatalog.kt @@ -62,6 +62,7 @@ class NativeLmModelCatalog : ModelCatalog { minDeviceRamMb = 6000, requiresAuth = false, supportsVision = false, + supportsCharts = true, ), // ── Mid+ (~7 GB) — Gemma 4 E2B, multimodal (~2.6 GB). 7000 excludes 6 GB // phones (which report ~5.9 GB) while keeping it on genuine 8 GB+ hardware. ── @@ -75,6 +76,7 @@ class NativeLmModelCatalog : ModelCatalog { minDeviceRamMb = 7000, requiresAuth = true, supportsVision = true, + supportsCharts = true, ), // ── High (~10 GB) — Gemma 4 E4B, multimodal (~3.7 GB). ── ModelDescriptor( @@ -87,6 +89,7 @@ class NativeLmModelCatalog : ModelCatalog { minDeviceRamMb = 10000, requiresAuth = true, supportsVision = true, + supportsCharts = true, ), // ── High (~10 GB) — Phi-4-mini, q8, text reasoning (~3.9 GB), ungated // (MIT). A non-Gemma high-tier option alongside E4B. ── @@ -100,6 +103,7 @@ class NativeLmModelCatalog : ModelCatalog { minDeviceRamMb = 10000, requiresAuth = false, supportsVision = false, + supportsCharts = true, ), // ── Flagship (~12 GB+) — Qwen3 4B, channelwise int8, text (~5.3 GB), // ungated (Apache-2.0). NOTE: reachable only on devices whose effective @@ -116,6 +120,7 @@ class NativeLmModelCatalog : ModelCatalog { minDeviceRamMb = 12000, requiresAuth = false, supportsVision = false, + supportsCharts = true, ), ModelDescriptor( id = "universal-sentence-encoder", diff --git a/sample-app/src/main/java/com/nativelm/app/llm/NativeLmViewModel.kt b/sample-app/src/main/java/com/nativelm/app/llm/NativeLmViewModel.kt index 927f8ca..dc64c13 100644 --- a/sample-app/src/main/java/com/nativelm/app/llm/NativeLmViewModel.kt +++ b/sample-app/src/main/java/com/nativelm/app/llm/NativeLmViewModel.kt @@ -68,9 +68,8 @@ import java.io.File private const val TAG = "NativeLmVM" -private val SYSTEM_INSTRUCTION = - "You are NativeLM, a helpful on-device assistant. Answer clearly and concisely.\n\n" + - ChartInstruction.SYSTEM +private const val BASE_SYSTEM_INSTRUCTION = + "You are NativeLM, a helpful on-device assistant. Answer clearly and concisely." const val ROUTE_SPLASH = "splash" const val ROUTE_ONBOARDING = "onboarding" @@ -772,6 +771,18 @@ class NativeLmViewModel(app: Application) : ViewModel() { } } + /** + * The system instruction for the active model. The chart-emitting fragment is + * appended ONLY for models flagged [ModelDescriptor.supportsCharts]; tiny models + * (e.g. Qwen3 0.6B) parrot the chart examples into unrelated answers, so they + * chat with the plain instruction instead. + */ + private fun systemInstruction(): String { + val supportsCharts = _activeModelId.value?.let { catalog.byId(it) }?.supportsCharts == true + return if (supportsCharts) "$BASE_SYSTEM_INSTRUCTION\n\n${ChartInstruction.SYSTEM}" + else BASE_SYSTEM_INSTRUCTION + } + /** * Open a stateful chat session, seeding it with [history] (re-prefilled once, * surfaced as [ChatState.isWarming]). Closes any prior session. The KV cache @@ -785,7 +796,7 @@ class NativeLmViewModel(app: Application) : ViewModel() { text = it.text, ) } - val session = engineHolder.openChatSession(turns, SYSTEM_INSTRUCTION) + val session = engineHolder.openChatSession(turns, systemInstruction()) chatSession = session if (!showWarming) { _chat.update { it.copy(isWarming = false) } diff --git a/sample-app/src/test/java/com/nativelm/app/llm/NativeLmModelCatalogTest.kt b/sample-app/src/test/java/com/nativelm/app/llm/NativeLmModelCatalogTest.kt index 918ea8f..ebacb7f 100644 --- a/sample-app/src/test/java/com/nativelm/app/llm/NativeLmModelCatalogTest.kt +++ b/sample-app/src/test/java/com/nativelm/app/llm/NativeLmModelCatalogTest.kt @@ -49,6 +49,18 @@ class NativeLmModelCatalogTest { assertTrue("entry is the smallest LLM tier", m.minDeviceRamMb <= 4000L) } + @Test fun chartsGatedToCapableModelsOnly() { + // Tiny models parrot the chart examples into unrelated answers, so the + // chart instruction is opt-in: the entry Qwen3 0.6B and Gemma 3 1B are OFF; + // the 1.5B-class and larger are ON. + assertFalse("Qwen3 0.6B too small for charts", catalog.byId("qwen3-0_6b-litertlm")!!.supportsCharts) + assertFalse("Gemma 3 1B too small for charts", catalog.byId("gemma3-1b-it-int4-litertlm")!!.supportsCharts) + assertTrue("DeepSeek 1.5B charts", catalog.byId("deepseek-r1-distill-qwen-1_5b-litertlm")!!.supportsCharts) + assertTrue("Gemma 4 E2B charts", catalog.byId("gemma-4-e2b-it-litertlm")!!.supportsCharts) + assertTrue("Phi-4 mini charts", catalog.byId("phi-4-mini-instruct-litertlm")!!.supportsCharts) + assertTrue("Qwen3 4B charts", catalog.byId("qwen3-4b-litertlm")!!.supportsCharts) + } + @Test fun catalogueSpansEntryToFlagship() { val llms = catalog.byRole(ModelRole.LLM_PRIMARY) // A real cross-device range: many tiers, all .litertlm, a mix of gated +