Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
6 changes: 4 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ jobs:
- name: Generate ObjectBox bindings
run: dart run build_runner build --delete-conflicting-outputs

- name: Build release APK
run: flutter build apk --release --split-per-abi --target-platform android-arm64,android-x64
- name: Build release APKs
run: |
flutter build apk --flavor lite -t lib/main_lite.dart --release --split-per-abi --target-platform android-arm64,android-x64
flutter build apk --flavor full -t lib/main_full.dart --release --split-per-abi --target-platform android-arm64,android-x64

- name: Rename APKs with version
run: |
Expand Down
31 changes: 26 additions & 5 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ android {
}

defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "dev.koshika.koshika"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
Expand All @@ -32,8 +29,6 @@ android {

buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
Expand All @@ -48,6 +43,32 @@ android {
excludes += "**/libimagegenerator_gpu.so"
}
}

flavorDimensions += "appType"
productFlavors {
create("lite") {
dimension = "appType"
applicationIdSuffix = ".lite"
versionNameSuffix = "-lite"
}
create("full") {
dimension = "appType"
}
}
}

// Strip llama.cpp native libs from the lite flavor APK.
// This runs after packaging to remove AI inference libraries,
// keeping the lite APK small (~15MB smaller).
androidComponents {
onVariants(selector().withFlavor("appType" to "lite")) { variant ->
variant.packaging.jniLibs.excludes.addAll(listOf(
"**/libllama.so",
"**/libggml*.so",
"**/libmtmd.so",
"**/libllamadart.so",
))
}
}

flutter {
Expand Down
4 changes: 0 additions & 4 deletions android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
# MediaPipe optional proto classes (not needed at runtime)
-dontwarn com.google.mediapipe.proto.**
-dontwarn com.google.mediapipe.framework.GraphProfiler

# ML Kit optional language model classes (only Latin script is used)
-dontwarn com.google.mlkit.vision.text.chinese.**
-dontwarn com.google.mlkit.vision.text.devanagari.**
Expand Down
5 changes: 2 additions & 3 deletions lib/constants/ai_prompts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
/// Keeping prompts here (rather than inlined in service files) makes them
/// easy to review, iterate, and A/B test without touching service logic.
abstract final class AiPrompts {
/// System prompt for the Gemma chat model.
/// System prompt injected before every user query.
///
/// Injected as a [Message.systemInfo] turn before every user query.
/// Lab data context is added separately to the user turn so that
/// small (1B-param) models cannot ignore it.
static const String gemmaSystemPrompt = '''
static const String systemPrompt = '''
You are Koshika AI, a helpful on-device health assistant built into the Koshika app. You help users understand their lab report results.

CRITICAL RULES:
Expand Down
68 changes: 39 additions & 29 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter_gemma/flutter_gemma.dart';

import 'screens/chat_screen.dart';
import 'screens/dashboard_screen.dart';
Expand All @@ -11,22 +10,31 @@ import 'screens/settings_screen.dart';
import 'screens/splash_screen.dart';
import 'services/objectbox_store.dart';
import 'services/biomarker_dictionary.dart';
import 'services/embedding_service.dart';
import 'services/gemma_service.dart';
import 'services/llm_embedding_service.dart';
import 'services/llm_service.dart';
import 'services/vector_store_service.dart';
import 'theme/app_colors.dart';
import 'theme/koshika_design_system.dart';

/// Global references — initialized in SplashScreen before navigation.
late ObjectBoxStore objectbox;
late BiomarkerDictionary biomarkerDictionary;
late GemmaService gemmaService;
late EmbeddingService embeddingService;
late LlmService llmService;
late LlmEmbeddingService embeddingService;
late VectorStoreService vectorStoreService;

Future<void> main() async {
/// Whether AI features are enabled. Set by the entry point
/// (main_full.dart vs main_lite.dart).
bool kAiEnabled = true;

/// Default entry point — full flavor with AI enabled.
/// For flavor-specific builds, use main_full.dart or main_lite.dart.
Future<void> main() async => appMain(aiEnabled: true);

/// Shared app bootstrap — called from entry-point files.
Future<void> appMain({required bool aiEnabled}) async {
WidgetsFlutterBinding.ensureInitialized();
await FlutterGemma.initialize(maxDownloadRetries: 5);
kAiEnabled = aiEnabled;
runApp(const KoshikaApp());
}

Expand Down Expand Up @@ -123,19 +131,19 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
int _currentIndex = 0;

/// Screens available depends on whether AI is enabled.
List<Widget> get _screens => kAiEnabled
? const [
DashboardScreen(),
ReportsScreen(),
ChatScreen(),
SettingsScreen(),
]
: const [DashboardScreen(), ReportsScreen(), SettingsScreen()];

Widget _buildCurrentScreen() {
switch (_currentIndex) {
case 0:
return const DashboardScreen();
case 1:
return const ReportsScreen();
case 2:
return const ChatScreen();
case 3:
return const SettingsScreen();
default:
return const DashboardScreen();
}
if (_currentIndex < _screens.length) return _screens[_currentIndex];
return const DashboardScreen();
}

@override
Expand Down Expand Up @@ -175,21 +183,23 @@ class _HomeScreenState extends State<HomeScreen> {
label: 'Reports',
onTap: () => setState(() => _currentIndex = 1),
),
if (kAiEnabled)
_NavItem(
index: 2,
currentIndex: _currentIndex,
icon: Icons.chat_outlined,
activeIcon: Icons.chat,
label: 'Chat',
onTap: () => setState(() => _currentIndex = 2),
),
_NavItem(
index: 2,
currentIndex: _currentIndex,
icon: Icons.chat_outlined,
activeIcon: Icons.chat,
label: 'Chat',
onTap: () => setState(() => _currentIndex = 2),
),
_NavItem(
index: 3,
index: kAiEnabled ? 3 : 2,
currentIndex: _currentIndex,
icon: Icons.settings_outlined,
activeIcon: Icons.settings,
label: 'Settings',
onTap: () => setState(() => _currentIndex = 3),
onTap: () =>
setState(() => _currentIndex = kAiEnabled ? 3 : 2),
),
],
),
Expand Down
4 changes: 4 additions & 0 deletions lib/main_full.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import 'main.dart';

/// Entry point for the full flavor — includes all AI features.
Future<void> main() async => appMain(aiEnabled: true);
4 changes: 4 additions & 0 deletions lib/main_lite.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import 'main.dart';

/// Entry point for the lite flavor — no AI, no model downloads.
Future<void> main() async => appMain(aiEnabled: false);
3 changes: 2 additions & 1 deletion lib/models/biomarker_result.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ class BiomarkerResult {
@Index()
DateTime testDate;

/// Embedding vector for RAG (stored as float list, used with HNSW later)
/// Embedding vector for RAG (384-dim from bge-small-en-v1.5).
@Property(type: PropertyType.floatVector)
@HnswIndex(dimensions: 384)
List<double>? embedding;

BiomarkerResult({
Expand Down
110 changes: 110 additions & 0 deletions lib/models/llm_model_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/// Configuration for a downloadable GGUF model (curated or custom).
class LlmModelConfig {
final String id;
final String name;
final String downloadUrl;
final int estimatedSizeMB;
final String description;
final bool isCustom;

const LlmModelConfig({
required this.id,
required this.name,
required this.downloadUrl,
required this.estimatedSizeMB,
required this.description,
this.isCustom = false,
});

/// Derive the on-disk filename from the download URL.
String get filename {
final uri = Uri.parse(downloadUrl);
return uri.pathSegments.isNotEmpty ? uri.pathSegments.last : '$id.gguf';
}

/// Human-readable size string.
String get formattedSize {
if (estimatedSizeMB == 0) return 'Unknown';
if (estimatedSizeMB >= 1000) {
return '${(estimatedSizeMB / 1000).toStringAsFixed(1)} GB';
}
return '~$estimatedSizeMB MB';
}
}

/// Registry of curated chat models + factory for custom GGUF URLs.
abstract final class LlmModelRegistry {
static const defaultModelId = 'qwen3-0.6b';

static const List<LlmModelConfig> curated = [
LlmModelConfig(
id: 'smollm2-360m',
name: 'SmolLM2 360M',
downloadUrl:
'https://huggingface.co/bartowski/SmolLM2-360M-Instruct-GGUF/resolve/main/SmolLM2-360M-Instruct-Q4_K_M.gguf',
estimatedSizeMB: 230,
description: 'Smallest, fastest load, lowest RAM',
),
LlmModelConfig(
id: 'qwen3-0.6b',
name: 'Qwen3 0.6B',
downloadUrl:
'https://huggingface.co/bartowski/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-Q4_K_M.gguf',
estimatedSizeMB: 430,
description: 'Balanced quality and size',
),
LlmModelConfig(
id: 'llama3.2-1b',
name: 'Llama 3.2 1B',
downloadUrl:
'https://huggingface.co/bartowski/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q4_K_M.gguf',
estimatedSizeMB: 770,
description: 'Strong instruction following',
),
LlmModelConfig(
id: 'gemma3-1b',
name: 'Gemma 3 1B',
downloadUrl:
'https://huggingface.co/bartowski/gemma-3-1b-it-GGUF/resolve/main/gemma-3-1b-it-Q4_K_M.gguf',
estimatedSizeMB: 780,
description: 'Most capable curated model',
),
];

/// Embedding model — fixed, not user-selectable.
static const embeddingModel = LlmModelConfig(
id: 'bge-small-en',
name: 'bge-small-en-v1.5',
downloadUrl:
'https://huggingface.co/CompendiumLabs/bge-small-en-v1.5-gguf/resolve/main/bge-small-en-v1.5-q8_0.gguf',
estimatedSizeMB: 67,
description: 'Semantic search model',
);

/// Create a config for a user-provided GGUF URL.
static LlmModelConfig custom({
required String name,
required String downloadUrl,
}) {
return LlmModelConfig(
id: 'custom',
name: name,
downloadUrl: downloadUrl,
estimatedSizeMB: 0,
description: 'Custom GGUF model',
isCustom: true,
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Look up a curated model by id. Returns null for unknown ids.
static LlmModelConfig? findById(String id) {
for (final m in curated) {
if (m.id == id) return m;
}
return null;
}

/// The default curated model.
static LlmModelConfig get defaultModel =>
findById(defaultModelId) ?? curated.first;
}
2 changes: 1 addition & 1 deletion lib/models/model_info.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class ModelInfo {
final int estimatedSizeMB;
final ModelStatus status;

/// Download progress as an integer 0–100 (from flutter_gemma callback).
/// Download progress as an integer 0–100.
final int downloadProgress;
final String? errorMessage;

Expand Down
21 changes: 21 additions & 0 deletions lib/models/retrieval_result.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/// App-owned retrieval result for the RAG pipeline.
///
/// Replaces flutter_gemma's RetrievalResult so the pipeline is
/// decoupled from the inference engine.
class RetrievalResult {
final String id;
final String content;

/// JSON-encoded metadata (biomarkerKey, reportId, flag, testDate, labName).
final String? metadata;

/// Cosine similarity score in [0, 1].
final double score;

const RetrievalResult({
required this.id,
required this.content,
this.metadata,
this.score = 0.0,
});
}
6 changes: 4 additions & 2 deletions lib/objectbox-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@
{
"id": "16:557227151494494808",
"name": "embedding",
"type": 28
"indexId": "6:3191674845316224774",
"type": 28,
"flags": 8
}
],
"relations": []
Expand Down Expand Up @@ -268,7 +270,7 @@
}
],
"lastEntityId": "5:1397208082251365964",
"lastIndexId": "5:2477129363995053950",
"lastIndexId": "6:3191674845316224774",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 5,
Expand Down
Loading
Loading