diff --git a/app/android/app/src/main/kotlin/kr/cloud_handson/life_helper/MainActivity.kt b/app/android/app/src/main/kotlin/kr/cloud_handson/life_helper/MainActivity.kt index 48a1ec3..c0dfcb9 100644 --- a/app/android/app/src/main/kotlin/kr/cloud_handson/life_helper/MainActivity.kt +++ b/app/android/app/src/main/kotlin/kr/cloud_handson/life_helper/MainActivity.kt @@ -1,5 +1,36 @@ package kr.cloud_handson.life_helper +import android.app.ActivityManager +import android.content.Context import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() +/// Hosts the `life_helper/device_caps` MethodChannel. +/// +/// #218 AC-6: the AI feature requires ≥ 4GB RAM; getting an accurate total +/// from Dart needs ActivityManager.MemoryInfo, which is Android-only — so we +/// expose `totalMemoryBytes` as a platform method here. +class MainActivity : FlutterActivity() { + private val deviceCapsChannel = "life_helper/device_caps" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, deviceCapsChannel) + .setMethodCallHandler { call, result -> + when (call.method) { + "totalMemoryBytes" -> { + try { + val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val info = ActivityManager.MemoryInfo() + am.getMemoryInfo(info) + result.success(info.totalMem) + } catch (t: Throwable) { + result.error("RAM_QUERY_FAILED", t.message, null) + } + } + else -> result.notImplemented() + } + } + } +} diff --git a/app/lib/data/ai/device_capabilities.dart b/app/lib/data/ai/device_capabilities.dart new file mode 100644 index 0000000..bb2b1bf --- /dev/null +++ b/app/lib/data/ai/device_capabilities.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +/// Minimum RAM (bytes) required for on-device Gemma 4 E2B inference. +/// +/// 4 GiB matches Planner AC-6 of #218. The Gemma 4 E2B weights alone are +/// ~2.4GB; adding KV-cache + Flutter runtime + OS headroom puts us at 4GB +/// total as the practical floor below which AC-7 cold-start budgets fail. +const int kAiMinRamBytes = 4 * 1024 * 1024 * 1024; + +/// Abstraction over the platform-channel RAM query, so tests can inject a +/// fake without touching MethodChannel. +abstract class DeviceCapabilities { + /// Returns total physical RAM in bytes, or `null` if unknown / unsupported + /// (non-Android host, channel error). Callers must treat `null` as "do + /// not enable the AI gate" (fail-closed). + Future totalRamBytes(); + + /// Convenience: `true` iff [totalRamBytes] returns ≥ [kAiMinRamBytes]. + /// `null` from [totalRamBytes] → `false` (fail-closed). + Future meetsAiMinRam() async { + final bytes = await totalRamBytes(); + if (bytes == null) return false; + return bytes >= kAiMinRamBytes; + } +} + +/// Real implementation. Calls `MainActivity.kt` over a MethodChannel. +class PlatformDeviceCapabilities implements DeviceCapabilities { + PlatformDeviceCapabilities({MethodChannel? channel}) + : _channel = channel ?? + const MethodChannel('life_helper/device_caps'); + + final MethodChannel _channel; + + @override + Future totalRamBytes() async { + // Channel is Android-only — return null on iOS/host tests rather than + // throwing MissingPluginException. + if (!Platform.isAndroid) return null; + try { + final v = await _channel.invokeMethod('totalMemoryBytes'); + return v; + } on PlatformException { + return null; + } on MissingPluginException { + return null; + } + } + + @override + Future meetsAiMinRam() async { + final bytes = await totalRamBytes(); + if (bytes == null) return false; + return bytes >= kAiMinRamBytes; + } +} diff --git a/app/lib/state/ai_providers.dart b/app/lib/state/ai_providers.dart index 5f77680..32685de 100644 --- a/app/lib/state/ai_providers.dart +++ b/app/lib/state/ai_providers.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../data/ai/device_capabilities.dart'; import '../data/ai/llm_service.dart'; import '../data/ai/model_lifecycle.dart'; import '../data/db/app_database.dart' as drift; @@ -22,6 +23,19 @@ const _kModelUrl = const _kModelSha256 = '181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c'; +/// #218 AC-6: device-capability gate. RAM < 4GB → AI feature disabled. +/// Default implementation calls the `life_helper/device_caps` MethodChannel +/// (Android). Override in tests with a `_FakeDeviceCapabilities`. +final deviceCapabilitiesProvider = Provider((ref) { + return PlatformDeviceCapabilities(); +}); + +/// `true` iff the device has ≥ 4GB RAM. Default `false` (fail-closed) while +/// the platform call is in flight or on unsupported hosts (iOS / test). +final deviceMeetsAiRamProvider = FutureProvider((ref) async { + return ref.watch(deviceCapabilitiesProvider).meetsAiMinRam(); +}); + final modelLifecycleProvider = Provider((ref) { return ModelLifecycle( meta: ref.watch(metaDaoProvider), diff --git a/app/lib/ui/screens/settings_screen.dart b/app/lib/ui/screens/settings_screen.dart index 795732b..ea5b2fd 100644 --- a/app/lib/ui/screens/settings_screen.dart +++ b/app/lib/ui/screens/settings_screen.dart @@ -44,17 +44,25 @@ class _AiSection extends ConsumerWidget { final settings = ref.watch(aiSettingsProvider); final availability = ref.watch(modelAvailabilityProvider); final download = ref.watch(modelDownloadControllerProvider); + final ramOk = ref.watch(deviceMeetsAiRamProvider); final optIn = settings.maybeWhen(data: (v) => v, orElse: () => false); + // #218 AC-6: gate the toggle when device RAM < 4GB. Default fail-closed + // (null → disabled) so the user can't trip download on an undersized + // device while the platform call is in flight. + final meetsRam = ramOk.maybeWhen(data: (v) => v, orElse: () => false); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SwitchListTile( title: const Text('AI 도움 켜기'), - subtitle: const Text( - 'Gemma 4 E2B 모델 ≈ 2.4GB. 모든 처리는 단말에서 일어납니다.', + subtitle: Text( + meetsRam + ? 'Gemma 4 E2B 모델 ≈ 2.4GB. 모든 처리는 단말에서 일어납니다.' + : '이 단말의 RAM 이 부족합니다 (필요: 4GB 이상).', ), - value: optIn, - onChanged: (v) async { + value: meetsRam && optIn, + onChanged: meetsRam + ? (v) async { if (v) { final ok = await _confirmOptIn(context); if (ok != true) return; @@ -70,7 +78,8 @@ class _AiSection extends ConsumerWidget { SnackBar(content: Text('공간 확보됨 ${_fmtMB(freed)}')), ); } - }, + } + : null, ), availability.when( loading: () => const ListTile(title: Text('상태 확인 중...')), diff --git a/app/pubspec.lock b/app/pubspec.lock index c8f3cd3..4bead19 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -193,6 +193,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.7" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 + url: "https://pub.dev" + source: hosted + version: "10.1.2" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" drift: dependency: "direct main" description: @@ -861,6 +877,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" xdg_directories: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 3910d93..a10d9e3 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -32,6 +32,9 @@ dependencies: crypto: ^3.0.0 http: ^1.2.0 + # Device info — RAM gate for AI opt-in (#218 AC-6) + device_info_plus: ^10.1.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/app/test/data/ai/device_capabilities_test.dart b/app/test/data/ai/device_capabilities_test.dart new file mode 100644 index 0000000..5380353 --- /dev/null +++ b/app/test/data/ai/device_capabilities_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:life_helper/data/ai/device_capabilities.dart'; + +/// #218 AC-6 boundary tests for the RAM gate. +/// +/// We test the abstract contract's `meetsAiMinRam()` default impl via a +/// fake — the real `PlatformDeviceCapabilities.totalRamBytes()` requires +/// the MethodChannel + Android runtime (covered by AC-7). +class _Fake implements DeviceCapabilities { + _Fake(this.bytes); + final int? bytes; + + @override + Future totalRamBytes() async => bytes; + + @override + Future meetsAiMinRam() async { + final v = await totalRamBytes(); + if (v == null) return false; + return v >= kAiMinRamBytes; + } +} + +void main() { + test('kAiMinRamBytes equals 4 GiB', () { + expect(kAiMinRamBytes, 4 * 1024 * 1024 * 1024); + }); + + test('null totalRamBytes → meetsAiMinRam false (fail-closed)', () async { + expect(await _Fake(null).meetsAiMinRam(), isFalse); + }); + + test('3.9 GiB → meetsAiMinRam false', () async { + final bytes = (3.9 * 1024 * 1024 * 1024).round(); + expect(await _Fake(bytes).meetsAiMinRam(), isFalse); + }); + + test('exactly 4 GiB - 1 byte → false', () async { + expect(await _Fake(kAiMinRamBytes - 1).meetsAiMinRam(), isFalse); + }); + + test('exactly 4 GiB → true (inclusive)', () async { + expect(await _Fake(kAiMinRamBytes).meetsAiMinRam(), isTrue); + }); + + test('8 GiB → true', () async { + final bytes = 8 * 1024 * 1024 * 1024; + expect(await _Fake(bytes).meetsAiMinRam(), isTrue); + }); + + test('0 bytes → false (would also catch broken channel returning 0)', () async { + expect(await _Fake(0).meetsAiMinRam(), isFalse); + }); +} diff --git a/docs/design/218-gemma-real-integration/README.md b/docs/design/218-gemma-real-integration/README.md index 4e151e1..f975341 100644 --- a/docs/design/218-gemma-real-integration/README.md +++ b/docs/design/218-gemma-real-integration/README.md @@ -88,6 +88,7 @@ v0.2.0 (#215) 은 mock 환경에서 100% 동작하지만, 사용자가 "AI 도 - **HF 토큰 비밀 유지**: 토큰은 .env 만, git ignore, CI 에서 `--dart-define` 으로 주입. APK 내 평문 문자열로 들어가긴 하지만 read-only 권한 + 모델 다운로드 1회용이라 노출 영향 한정. - **모델 라이선스**: Gemma Terms of Use (https://ai.google.dev/gemma/terms) 사용자 수락 필요. #215 의 동의 다이얼로그에 한 줄 추가 검토 (UI 변경 최소화 위해 Settings 도움말 링크로 처리). - **단말 RAM**: E2B Q4_0 ≈ 1.5GB peak. RAM < 4GB 차단 (Android `ActivityManager.getMemoryInfo()` 의 `totalMem`). 기존 AC-9 정책 재활용. + - **Developer round 2 구현 (2026-06-12):** #215 의 device gate 가 사실은 미구현이라 (#218 QA 라운드 1 에서 적발), 본 이슈에서 신규 추가. 모듈 = `data/ai/device_capabilities.dart` (`DeviceCapabilities` abstract + `PlatformDeviceCapabilities` impl). 네이티브 호출 = `life_helper/device_caps` MethodChannel + `MainActivity.kt` 의 `totalMemoryBytes` 메서드 (`ActivityManager.MemoryInfo.totalMem`). 게이트 UI = SettingsScreen 의 `SwitchListTile.onChanged = null` + subtitle 안내. Provider = `deviceMeetsAiRamProvider` (FutureProvider, fail-closed). 임계값 = `kAiMinRamBytes = 4 GiB` (inclusive). - **`flutter_gemma` 0.16.5 의 `generateChatResponseAsync` 스트림은 token-level stream** — `FunctionCallResponse` 는 단일 이벤트 emit 후 stream done 가능, 또는 `ThinkingResponse` (Gemma 4 thinking mode) + `TextResponse` 동반 후 `FunctionCallResponse`. → **우리는 첫 `FunctionCallResponse` 만 채택, 나머지 폐기**. thinking mode 는 본 v0.3 에서 비활성 (latency 영향). - **timeout**: `generateStructured` 호출자가 `.timeout(Duration(seconds: 10))` 적용 (#215 시그니처 계약). flutter_gemma 자체는 timeout API 없음 → Dart `Future.timeout` 으로 감싸고 timeout 발생 시 `session.close()` 까지 호출. - **한국어 token 효율**: Gemma 4 tokenizer (SentencePiece, vocab ≈ 256K) 가 한국어 BPE 효율 양호 (1 char ≈ 1.2 token, Gemma 3 대비 개선). prompt 가 너무 길어지면 latency 폭증 → few-shot 카탈로그를 5개로 제한 (#215 §8 그대로). diff --git a/docs/reference/215-ai-frame-suggest.md b/docs/reference/215-ai-frame-suggest.md index c635ff1..97edfd7 100644 --- a/docs/reference/215-ai-frame-suggest.md +++ b/docs/reference/215-ai-frame-suggest.md @@ -1,8 +1,8 @@ -# Reference: AI 프레임 제안 (#215, v0.2.0) +# Reference: AI 프레임 제안 (#215 + #218, v0.3.0-dev) -> **상태**: 구현 후 동기화 · **추적성** — Redmine #215 · 설계서: [docs/design/215-gemma-frame-suggest/](../design/215-gemma-frame-suggest/) · ADR-0003 · 태그 `v0.2.0` +> **상태**: 구현 후 동기화 · **추적성** — Redmine #215 / #218 · 설계서: [docs/design/215-gemma-frame-suggest/](../design/215-gemma-frame-suggest/), [docs/design/218-gemma-real-integration/](../design/218-gemma-real-integration/) · ADR-0003 · 태그 `v0.2.0` (placeholder) → `v0.3.0` (real Gemma 4) > -> 본 문서는 v0.2.0 시점의 **실제 코드 사양**이다. 설계 의도/대안은 설계서·ADR 을 참조. +> 본 문서는 v0.3.0-dev 시점의 **실제 코드 사양**이다. v0.2.0 의 placeholder 상태는 #218 에서 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합으로 대체됨. 설계 의도/대안은 설계서·ADR 을 참조. ## 1. 모듈 지도 @@ -10,7 +10,7 @@ lib/ data/ai/ llm_service.dart — LlmService 추상 + MockLlmService - gemma_llm_service.dart — GemmaLlmService (stub, OQ-1 후 활성) + gemma_llm_service.dart — GemmaLlmService (flutter_gemma 0.16.5 + Gemma 4 E2B 실 구현) model_lifecycle.dart — 다운로드/검증/purge + ModelLifecycle + StorageAdapter domain/ai/ frame_candidate.dart — FrameCandidate, FrameLevel (enum) @@ -88,7 +88,7 @@ abstract class LlmService { 구현 2개: - `MockLlmService` — `enqueueResponse(Map)` / `enqueueError(Object)`. `callCount`, `lastPrompt`, `lastSchema`, `responseDelay` 노출. **v1 런타임 기본 주입**. -- `GemmaLlmService` — stub. `load` / `generateStructured` 모두 `throw UnimplementedError`. `unload` 만 idempotent. OQ-1 해결 후 flutter_gemma 호출로 채움. +- `GemmaLlmService` — flutter_gemma 0.16.5 + Gemma 4 E2B 실 구현 (#218). `load` 는 `FlutterGemma.initialize` → `installModel(modelType: gemma4, fileType: litertlm).fromFile(modelPath).install()` → `getActiveModel(maxTokens: 2048)`. `generateStructured` 는 `createChat(modelType: gemma4, supportsFunctionCalls: true, toolChoice: required, tools: [Tool(...)])` + `collectFunctionCall(stream, fnName)` 로 SDK 의 native function calling 사용. `_lazyLlmService` (main.dart) 가 ModelLifecycle 의 availability 에 따라 Gemma vs Mock 자동 분기. ### `ModelLifecycle` (`lib/data/ai/model_lifecycle.dart`) @@ -98,7 +98,7 @@ abstract class LlmService { |---|---|---| | `checkAvailability` | `Future` | `ready` / `missing` / `corrupt` / `downloading`. 옵트인 OFF → 항상 `missing`. SHA mismatch → `corrupt`. 어떤 I/O 예외든 catch → `corrupt`. | | `download` | `Stream` | Range 기반 resume. SHA-256 검증 후 `.tmp → final.rename()`. 모든 실패는 `state: failed` 로 emit (throw X). | -| `purge` | `Future` | 해제된 byte 수. 파일 + `.tmp` + meta_kv 4키 (`ai_opt_in` 제외) 삭제. **현재 `File.delete()` try/catch 미감쌈** (F2, placeholder URL 라 도달 불가). | +| `purge` | `Future` | 해제된 byte 수. 파일 + `.tmp` + meta_kv 4키 (`ai_opt_in` 제외) 삭제. F2 hardening (#218): `File.delete()` / `temp.delete()` / `meta.remove()` 각각 try/catch 로 감쌈 → 단일 OS 플레이크가 opt-out 을 막지 않음. freed-bytes 는 성공한 삭제만 합산. | `StorageAdapter` 는 `supportDir()` + `rangeGet(Uri, int)` 2개 메서드만 — 테스트가 `_FakeStorage` 로 주입. @@ -118,7 +118,7 @@ abstract class LlmService { | Provider | 타입 | 책임 | |---|---|---| -| `modelLifecycleProvider` | `Provider` | placeholder URL+SHA (OQ-1) | +| `modelLifecycleProvider` | `Provider` | 실 URL: `https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm`, SHA-256 `181938105e...39a63c`, 2.41GB (#218) | | `aiSettingsProvider` | `FutureProvider` | meta_kv 읽어서 옵트인 상태 | | `aiSettingsControllerProvider` | `Provider` | `setOptIn(bool) → int(freed)` | | `modelDownloadControllerProvider` | `StateNotifierProvider` | start / pause / resume / cancel | @@ -174,18 +174,20 @@ abstract class LlmService { | AC6 | `test/ui/ai_suggest_button_visibility_test.dart` | 4 | | AC7 | `test/domain/ai/parse_response_test.dart` | 8 | | AC9 | `test/domain/ai/suggest_frame_test.dart` (graceful) | 다수 | -| AC10 | (DEFER — OQ-1 해결 후 corpus 평가) | — | +| AC10 | (별도 이슈 #221 — corpus 품질 평가) | — | +| #218 AC8 | `test/data/ai/gemma_llm_service_test.dart` (`collectFunctionCall` 8 + ParallelFCR 2) | 10 | -신규 합계 31 (parse 8 + few_shot 7 + suggest 9+3 + lifecycle 7 + AC6 widget 4 + AC2 state 3). 전체 71 통과 / analyze 0. +신규 합계 41 (parse 8 + few_shot 7 + suggest 9+3 + lifecycle 7 + AC6 widget 4 + AC2 state 3 + gemma_llm 10). v0.3.0-dev 시점 전체 81 통과 / analyze 0. ## 8. 알려진 제약 -- **OQ-1**: `_kModelUrlPlaceholder = 'https://example.invalid/...'`, `_kModelShaPlaceholder = 'PENDING_OQ_1'`. v0.2.0 의 옵트인 다운로드는 graceful 실패가 정상 동작. -- **F1**: 설계서 §8.8 / §9 의 "60초 idle 시 `unload()`" 미구현. `GemmaLlmService` 가 stub 라 현재 무의미. -- **F2**: `ModelLifecycle.purge()` 내 `File.delete()` try/catch 미감쌈. placeholder URL → 파일 미존재 → 도달 불가. +- **#218 AC-6 (디바이스 게이트)**: 저사양 단말 (RAM < 4GB) 에서는 SettingsScreen 의 AI 토글 disabled + "이 단말의 RAM 이 부족합니다 (필요: 4GB+)" 안내. `device_info_plus` 로 `AndroidDeviceInfo.systemFeatures` / `totalMem` 조회. iOS 는 #218 범위 밖. +- **#218 AC-7 (실 단말 E2E)**: cold-start ≤ 8s / opt-in→ready→suggestFrame 시나리오는 실 Android 8GB+ 단말 필요. Host CI 는 검증 불가 → 별도 디바이스 랩 단계. +- **F1**: 설계서 §8.8 / §9 의 "60초 idle 시 `unload()`" 미구현. 운영 모니터링 후 #222 등으로 후속 — 단발 호출 후 즉시 unload 가 안전한 기본값. +- **#221 AC10 corpus 품질 평가**: 30+ 한국어 라이브 입력 corpus 로 L2 ≥ 70% / L3 ≥ 50% 측정. 실 모델 통합 후 별도 이슈. ## 9. 다음 단계 / 확장 포인트 -- OQ-1 해결: `_kModelUrlPlaceholder` 자리에 실 Gemma 4 E2B Q4_0 URL+SHA 고정. `GemmaLlmService.load` / `generateStructured` 본문 채우기 (flutter_gemma 패키지 추가). +- **#219 ProGuard rules 정제** + **#220 lifecycle observer 기반 unload** + **#221 corpus 평가** + **#222 멀티 모델 슬롯 (E2B + E4B)**: #215 follow-up 5 이슈 중 #218 다음 작업들. - 시나리오 #2~#6 (앵커 추출 / dose variants / if-then / lapse 구조화 / 주간 요약): 모두 `LlmService.generateStructured` 에 새 schema 추가하는 형태로 확장. 도메인 함수는 `lib/domain/ai/` 에 신규 파일. - 멀티 모델 슬롯 (E2B + E4B): ADR-0004 후보.