[03-Developer] #218 Dev round 2 — AC-6 RAM 4GB gate + AC-10 docs cleanup
QA round 1 (commit 9a9eb2a) FAIL 시 누락된 두 AC 보강.
AC-6: device_info_plus 만으론 4GB 임계 측정 불가 (isLowRamDevice 는
~1GB 기준). MethodChannel `life_helper/device_caps` 신설 + MainActivity.kt
에서 ActivityManager.MemoryInfo.totalMem 노출. data/ai/device_capabilities.dart
는 DeviceCapabilities abstract + PlatformDeviceCapabilities + 4 GiB
임계. deviceMeetsAiRamProvider (FutureProvider<bool>, fail-closed).
SettingsScreen 토글 disabled + "RAM 부족" 안내 (RAM < 4GB).
AC-10: docs/reference/215-ai-frame-suggest.md 의 OQ-1/placeholder
6곳을 실 구현 표현으로 갱신. §8 알려진 제약 = AC-6 device gate +
AC-7 실 단말 E2E + F1 unload + #221 corpus 평가. §9 다음 단계 =
#219~#222 follow-up 목록. 신규 테스트 합계 41 / 전체 88 통과.
테스트: device_capabilities_test.dart 7 신규 (kAiMinRamBytes 동등,
null/0/3.9GB/4GB-1/4GB/8GB 경계). flutter analyze 무이슈, 전체 88 통과
(71 기존 + 10 gemma + 7 RAM gate).
Architect 설계서 §4 의 "RAM 4GB 차단 = AC-9 재활용" 문구는 사실 #215
미구현 사항이라 본 라운드에서 신규 추가했음을 README 에 명기.
Refs #218
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
app/lib/data/ai/device_capabilities.dart
Normal file
58
app/lib/data/ai/device_capabilities.dart
Normal file
@@ -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<int?> totalRamBytes();
|
||||
|
||||
/// Convenience: `true` iff [totalRamBytes] returns ≥ [kAiMinRamBytes].
|
||||
/// `null` from [totalRamBytes] → `false` (fail-closed).
|
||||
Future<bool> 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<int?> 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<int>('totalMemoryBytes');
|
||||
return v;
|
||||
} on PlatformException {
|
||||
return null;
|
||||
} on MissingPluginException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> meetsAiMinRam() async {
|
||||
final bytes = await totalRamBytes();
|
||||
if (bytes == null) return false;
|
||||
return bytes >= kAiMinRamBytes;
|
||||
}
|
||||
}
|
||||
@@ -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<DeviceCapabilities>((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<bool>((ref) async {
|
||||
return ref.watch(deviceCapabilitiesProvider).meetsAiMinRam();
|
||||
});
|
||||
|
||||
final modelLifecycleProvider = Provider<ModelLifecycle>((ref) {
|
||||
return ModelLifecycle(
|
||||
meta: ref.watch(metaDaoProvider),
|
||||
|
||||
@@ -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('상태 확인 중...')),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
54
app/test/data/ai/device_capabilities_test.dart
Normal file
54
app/test/data/ai/device_capabilities_test.dart
Normal file
@@ -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<int?> totalRamBytes() async => bytes;
|
||||
|
||||
@override
|
||||
Future<bool> 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);
|
||||
});
|
||||
}
|
||||
@@ -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<bool>, 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 그대로).
|
||||
|
||||
@@ -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<ModelAvailability>` | `ready` / `missing` / `corrupt` / `downloading`. 옵트인 OFF → 항상 `missing`. SHA mismatch → `corrupt`. 어떤 I/O 예외든 catch → `corrupt`. |
|
||||
| `download` | `Stream<DownloadProgress>` | Range 기반 resume. SHA-256 검증 후 `.tmp → final.rename()`. 모든 실패는 `state: failed` 로 emit (throw X). |
|
||||
| `purge` | `Future<int>` | 해제된 byte 수. 파일 + `.tmp` + meta_kv 4키 (`ai_opt_in` 제외) 삭제. **현재 `File.delete()` try/catch 미감쌈** (F2, placeholder URL 라 도달 불가). |
|
||||
| `purge` | `Future<int>` | 해제된 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<ModelLifecycle>` | placeholder URL+SHA (OQ-1) |
|
||||
| `modelLifecycleProvider` | `Provider<ModelLifecycle>` | 실 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<bool>` | meta_kv 읽어서 옵트인 상태 |
|
||||
| `aiSettingsControllerProvider` | `Provider<AiSettingsController>` | `setOptIn(bool) → int(freed)` |
|
||||
| `modelDownloadControllerProvider` | `StateNotifierProvider<ModelDownloadController, DownloadProgress?>` | 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 후보.
|
||||
|
||||
Reference in New Issue
Block a user