Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da60dd1a5a | |||
| 1b90f58585 | |||
| 14632e11df | |||
| f71d132fa3 | |||
| 9a9eb2abd5 | |||
| a1f3c5f85d | |||
| ed340839a0 |
37
CHANGELOG.md
37
CHANGELOG.md
@@ -3,6 +3,43 @@
|
|||||||
본 프로젝트의 모든 의미있는 변경은 본 파일에 기록한다.
|
본 프로젝트의 모든 의미있는 변경은 본 파일에 기록한다.
|
||||||
형식: [Keep a Changelog](https://keepachangelog.com/) · 버전: [SemVer](https://semver.org/).
|
형식: [Keep a Changelog](https://keepachangelog.com/) · 버전: [SemVer](https://semver.org/).
|
||||||
|
|
||||||
|
## [0.3.0] — 2026-06-12
|
||||||
|
|
||||||
|
### Added — Phase 2-A OQ-1 resolved: real Gemma 4 E2B inference (Redmine #218)
|
||||||
|
- `GemmaLlmService` 본문 구현 — `flutter_gemma` 0.16.5 위에 Gemma 4 E2B 실 추론. `InferenceModel.createChat(modelType: gemma4, supportsFunctionCalls: true, toolChoice: required, tools: [...])` + `collectFunctionCall(stream)` 로 structured JSON 강제.
|
||||||
|
- `_LazyLlmService` (main.dart) — Mock ↔ Gemma 런타임 어댑터. 매 호출마다 `checkAvailability` 재평가 → opt-in/opt-out 즉시 반영 (앱 재시작 불필요).
|
||||||
|
- 실 모델 핀: `gemma-4-E2B-it.litertlm` 2.41GB, SHA-256 `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c` (HF `litert-community/gemma-4-E2B-it-litert-lm`).
|
||||||
|
- HF_TOKEN `--dart-define` 주입 — 빈 기본값으로 빌드 안전.
|
||||||
|
|
||||||
|
### Added — Device gate (AC-6)
|
||||||
|
- 플랫폼 채널 `life_helper/device_caps` (`MainActivity.kt` → `ActivityManager.MemoryInfo.totalMem`) — Android 단말 실 RAM 측정. `device_info_plus` 의 `isLowRamDevice` (~1GB) 로는 4GB 임계치 불가하여 채널 도입.
|
||||||
|
- `DeviceCapabilities` 추상 + `PlatformDeviceCapabilities` 구현 (테스트 주입 가능). `kAiMinRamBytes = 4 GiB`. fail-closed (`null` → false).
|
||||||
|
- `deviceMeetsAiRamProvider` (Riverpod `FutureProvider`) — `SettingsScreen` 토글 disabled + 안내 문구.
|
||||||
|
|
||||||
|
### Added — Tests
|
||||||
|
- 88/88 통과 — 신규 10 (`device_capabilities_test.dart` 7 + lazy resolve regression 3).
|
||||||
|
|
||||||
|
### Polish (Designer)
|
||||||
|
- AC-6 게이트 안내 톤 정렬 — "RAM 부족" → "이 단말에서는 AI 도움을 사용할 수 없어요 (RAM 4GB 이상 필요)".
|
||||||
|
- `_describe(missing, meetsRam:)` 분기 — 토글 disabled 상황에서 "토글 켜면" 모순 제거.
|
||||||
|
- 옵트아웃 다이얼로그 "다시 다시" 중복 → "처음부터".
|
||||||
|
|
||||||
|
### Fixed (Reviewer)
|
||||||
|
- `_LazyLlmService._delegate` sticky cache — 첫 호출 시점의 delegate 종류가 앱 재시작까지 유지되던 버그 (Mock → Gemma 전환 안 됨). re-resolve + (kind + modelPath) 일치 시만 캐시 재사용.
|
||||||
|
- Reference 문서 nit 3건 — `215-ai-frame-suggest.md` (L184 채널 사실 정정 / L186 F1 follow-up 매핑 / L191 OOS 기준).
|
||||||
|
|
||||||
|
### Release artifact
|
||||||
|
- `app-release.apk` 286MB, SHA-256 `4a237d5124bfcd56aaa8c0ae89060a9ecf9ce7cc739f0b056ce66e9b9ca6b54a`.
|
||||||
|
|
||||||
|
### Known limitations (deferred to #219~#222)
|
||||||
|
- **AC-7** (실 단말 cold-start 예산) — DEFER. 실기기 E2E 검증은 본 릴리스 후 권고.
|
||||||
|
- **#219** F1: 60초 idle auto-unload.
|
||||||
|
- **#220** GemmaLlmService.load 동시성 가드 + `isThinking:false` 명시.
|
||||||
|
- **#221** AC-10 한국어 corpus ≥70%.
|
||||||
|
- **#222** HF_TOKEN keystore 기반 secret 전환.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.2.0] — 2026-06-12
|
## [0.2.0] — 2026-06-12
|
||||||
|
|
||||||
### Added — Phase 2-A: On-device Gemma 4 frame suggestion (Redmine #215)
|
### Added — Phase 2-A: On-device Gemma 4 frame suggestion (Redmine #215)
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ android {
|
|||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
// #218: keep flutter_gemma JNI bindings — see proguard-rules.pro.
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
app/android/app/proguard-rules.pro
vendored
Normal file
30
app/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# flutter_gemma 0.16.5 — keep MediaPipe + LiteRT native bindings (#218)
|
||||||
|
# Without these the release build (R8 minify) strips JNI entry points
|
||||||
|
# and the first inference call crashes with NoSuchMethodError.
|
||||||
|
|
||||||
|
# MediaPipe LLM (.task path)
|
||||||
|
-keep class com.google.mediapipe.** { *; }
|
||||||
|
-dontwarn com.google.mediapipe.**
|
||||||
|
|
||||||
|
# LiteRT runtime (.litertlm path used by Gemma 4 E2B)
|
||||||
|
-keep class com.google.ai.edge.** { *; }
|
||||||
|
-keep class com.google.ai.litert.** { *; }
|
||||||
|
-dontwarn com.google.ai.edge.**
|
||||||
|
-dontwarn com.google.ai.litert.**
|
||||||
|
|
||||||
|
# TensorFlow Lite (used by LiteRT under the hood)
|
||||||
|
-keep class org.tensorflow.lite.** { *; }
|
||||||
|
-dontwarn org.tensorflow.lite.**
|
||||||
|
|
||||||
|
# Protobuf-lite (LiteRT message classes referenced via reflection)
|
||||||
|
-keep class com.google.protobuf.** { *; }
|
||||||
|
-dontwarn com.google.protobuf.**
|
||||||
|
|
||||||
|
# flutter_gemma plugin's own native bridge
|
||||||
|
-keep class dev.flutterberlin.flutter_gemma.** { *; }
|
||||||
|
-dontwarn dev.flutterberlin.flutter_gemma.**
|
||||||
|
|
||||||
|
# Generic JNI methods — covers any LiteRT/MediaPipe class loaded dynamically
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- #218: flutter_gemma downloads ≈ 2.4GB model checkpoint. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
<!-- Foreground service for large downloads (>500MB auto-detect). -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<application
|
<application
|
||||||
android:label="life_helper"
|
android:label="life_helper"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
package kr.cloud_handson.life_helper
|
package kr.cloud_handson.life_helper
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.content.Context
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,40 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_gemma/flutter_gemma.dart';
|
||||||
|
|
||||||
import 'llm_service.dart';
|
import 'llm_service.dart';
|
||||||
|
|
||||||
/// Stub for the real `flutter_gemma` integration.
|
/// HuggingFace access token injected at build time via
|
||||||
|
/// `--dart-define=HF_TOKEN=hf_xxx`. Empty string is permitted —
|
||||||
|
/// flutter_gemma will only need it for the initial network download,
|
||||||
|
/// which our `ModelLifecycle` handles separately; activation from a
|
||||||
|
/// local file path generally does not require the token.
|
||||||
|
const String _hfToken = String.fromEnvironment('HF_TOKEN', defaultValue: '');
|
||||||
|
|
||||||
|
/// One-shot guard so [FlutterGemma.initialize] runs at most once per
|
||||||
|
/// isolate. Re-init is unsupported by the underlying plugin.
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
/// Real on-device LLM backend using flutter_gemma 0.16.5 + Gemma 4 E2B.
|
||||||
///
|
///
|
||||||
/// Wired up only after OQ-1 (exact model URL + SHA + flutter_gemma API
|
/// Wired into the existing #215 pipeline: `ModelLifecycle` downloads &
|
||||||
/// surface) is confirmed in Developer phase. Today this throws
|
/// SHA-verifies the .litertlm file, then [load] registers that file with
|
||||||
/// `UnimplementedError` from every method — the rest of the system
|
/// flutter_gemma as the active model. [generateStructured] opens a
|
||||||
/// (suggestFrame, ModelLifecycle, Riverpod providers) is built against the
|
/// short-lived chat with a single [Tool] (Gemma 4 native function
|
||||||
/// `LlmService` abstract above and runs end-to-end with `MockLlmService`.
|
/// calling) and returns the first matching [FunctionCallResponse]'s args.
|
||||||
///
|
///
|
||||||
/// When the package is added, replace the bodies with calls into
|
/// Function-calling design notes (see fn-gemma_llm_service.md §B v2):
|
||||||
/// FlutterGemma.init / generateWithFunctionCalling per the package docs.
|
/// - Gemma 4 SDK injects the tool declaration via its chat template, so
|
||||||
/// Existing tests + UI hooks remain unchanged.
|
/// we pass [Tool] to `createChat(tools: ...)` rather than appending a
|
||||||
|
/// schema instruction to the prompt (double-wrap risk).
|
||||||
|
/// - `ToolChoice.required` forces the model to emit a function call.
|
||||||
class GemmaLlmService implements LlmService {
|
class GemmaLlmService implements LlmService {
|
||||||
final String modelPath;
|
final String modelPath;
|
||||||
|
|
||||||
GemmaLlmService({required this.modelPath});
|
GemmaLlmService({required this.modelPath});
|
||||||
|
|
||||||
|
InferenceModel? _model;
|
||||||
bool _loaded = false;
|
bool _loaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -23,14 +42,35 @@ class GemmaLlmService implements LlmService {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
throw UnimplementedError(
|
if (_loaded) return;
|
||||||
'GemmaLlmService.load: pending OQ-1 (model URL + flutter_gemma).',
|
if (!await File(modelPath).exists()) {
|
||||||
);
|
throw FileSystemException('model file missing', modelPath);
|
||||||
|
}
|
||||||
|
if (!_initialized) {
|
||||||
|
await FlutterGemma.initialize(huggingFaceToken: _hfToken);
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
await FlutterGemma.installModel(
|
||||||
|
modelType: ModelType.gemma4,
|
||||||
|
fileType: ModelFileType.litertlm,
|
||||||
|
).fromFile(modelPath).install();
|
||||||
|
final model = await FlutterGemma.getActiveModel(maxTokens: 2048);
|
||||||
|
_model = model;
|
||||||
|
_loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> unload() async {
|
Future<void> unload() async {
|
||||||
|
final m = _model;
|
||||||
|
_model = null;
|
||||||
_loaded = false;
|
_loaded = false;
|
||||||
|
if (m != null) {
|
||||||
|
try {
|
||||||
|
await m.close();
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort cleanup — runtime may already be torn down.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -38,8 +78,90 @@ class GemmaLlmService implements LlmService {
|
|||||||
String prompt,
|
String prompt,
|
||||||
Map<String, dynamic> schema,
|
Map<String, dynamic> schema,
|
||||||
) async {
|
) async {
|
||||||
throw UnimplementedError(
|
if (!_loaded || _model == null) {
|
||||||
'GemmaLlmService.generateStructured: pending OQ-1.',
|
throw StateError('LlmService not loaded');
|
||||||
|
}
|
||||||
|
final fnName = schema['name'];
|
||||||
|
final fnParams = schema['parameters'];
|
||||||
|
if (fnName is! String || fnName.isEmpty) {
|
||||||
|
throw ArgumentError('schema.name missing');
|
||||||
|
}
|
||||||
|
if (fnParams is! Map) {
|
||||||
|
throw ArgumentError('schema.parameters missing');
|
||||||
|
}
|
||||||
|
final fnDesc = (schema['description'] as String?) ?? '';
|
||||||
|
final tool = Tool(
|
||||||
|
name: fnName,
|
||||||
|
description: fnDesc,
|
||||||
|
parameters: Map<String, dynamic>.from(fnParams),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final chat = await _model!.createChat(
|
||||||
|
modelType: ModelType.gemma4,
|
||||||
|
supportsFunctionCalls: true,
|
||||||
|
toolChoice: ToolChoice.required,
|
||||||
|
tools: [tool],
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await chat.addQueryChunk(Message.text(text: prompt, isUser: true));
|
||||||
|
final stream = chat.generateChatResponseAsync();
|
||||||
|
return await collectFunctionCall(stream, fnName);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await chat.close();
|
||||||
|
} catch (_) {
|
||||||
|
// Native session close failure is non-fatal — log + continue.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extracts the first `FunctionCallResponse(name == expectedName)` from
|
||||||
|
/// a flutter_gemma response stream. `TextResponse` / `ThinkingResponse`
|
||||||
|
/// events are skipped. A mismatched name throws fast.
|
||||||
|
///
|
||||||
|
/// File-private under `_collectFunctionCall` from [GemmaLlmService];
|
||||||
|
/// exposed as a top-level via `@visibleForTesting` so unit tests can
|
||||||
|
/// feed synthetic streams (see fn-spec §D, 8 test cases).
|
||||||
|
@visibleForTesting
|
||||||
|
Future<Map<String, dynamic>> collectFunctionCall(
|
||||||
|
Stream<ModelResponse> stream,
|
||||||
|
String expectedName,
|
||||||
|
) async {
|
||||||
|
Map<String, dynamic>? result;
|
||||||
|
String? wrongName;
|
||||||
|
try {
|
||||||
|
await for (final event in stream) {
|
||||||
|
if (event is FunctionCallResponse) {
|
||||||
|
if (event.name == expectedName) {
|
||||||
|
result = Map<String, dynamic>.from(event.args);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
wrongName = event.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event is ParallelFunctionCallResponse && event.calls.isNotEmpty) {
|
||||||
|
final first = event.calls.first;
|
||||||
|
if (first.name == expectedName) {
|
||||||
|
result = Map<String, dynamic>.from(first.args);
|
||||||
|
} else {
|
||||||
|
wrongName = first.name;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// TextResponse / ThinkingResponse: skip.
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Discard raw error to avoid leaking prompt content in logs/crash
|
||||||
|
// reports — the caller surfaces a generic message.
|
||||||
|
throw const FormatException('stream error');
|
||||||
|
}
|
||||||
|
if (wrongName != null) {
|
||||||
|
throw FormatException('unexpected function: $wrongName');
|
||||||
|
}
|
||||||
|
if (result == null) {
|
||||||
|
throw const FormatException('no function call emitted');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -233,21 +233,37 @@ class ModelLifecycle {
|
|||||||
/// opt-out: delete model file + clear all ai_* meta keys (except opt_in
|
/// opt-out: delete model file + clear all ai_* meta keys (except opt_in
|
||||||
/// which the caller toggles). Returns freed bytes (0 if nothing existed).
|
/// which the caller toggles). Returns freed bytes (0 if nothing existed).
|
||||||
/// Idempotent.
|
/// Idempotent.
|
||||||
|
///
|
||||||
|
/// F2 hardening (#218): per-file try/catch so a single OS-level delete
|
||||||
|
/// failure (locked file, permission flake) does not abort the whole
|
||||||
|
/// purge — meta keys still get cleared and the orphan file becomes a
|
||||||
|
/// background storage concern rather than a stuck "opt-out failed"
|
||||||
|
/// state. The freed-bytes count only reflects successful deletes.
|
||||||
Future<int> purge() async {
|
Future<int> purge() async {
|
||||||
int freed = 0;
|
int freed = 0;
|
||||||
final pathStr = await meta.find(AiMetaKeys.modelPath);
|
final pathStr = await meta.find(AiMetaKeys.modelPath);
|
||||||
if (pathStr != null) {
|
if (pathStr != null) {
|
||||||
|
try {
|
||||||
final f = File(pathStr);
|
final f = File(pathStr);
|
||||||
if (f.existsSync()) {
|
if (f.existsSync()) {
|
||||||
freed += await f.length();
|
final size = await f.length();
|
||||||
await f.delete();
|
await f.delete();
|
||||||
|
freed += size;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort; leave orphan file, continue purging meta.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
final tempPath = '${await _modelPath()}.tmp';
|
final tempPath = '${await _modelPath()}.tmp';
|
||||||
final temp = File(tempPath);
|
final temp = File(tempPath);
|
||||||
if (temp.existsSync()) {
|
if (temp.existsSync()) {
|
||||||
freed += await temp.length();
|
final size = await temp.length();
|
||||||
await temp.delete();
|
await temp.delete();
|
||||||
|
freed += size;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Same as above — best-effort cleanup of the .tmp partial.
|
||||||
}
|
}
|
||||||
for (final k in [
|
for (final k in [
|
||||||
AiMetaKeys.modelPath,
|
AiMetaKeys.modelPath,
|
||||||
@@ -255,7 +271,12 @@ class ModelLifecycle {
|
|||||||
AiMetaKeys.downloadState,
|
AiMetaKeys.downloadState,
|
||||||
AiMetaKeys.downloadBytes,
|
AiMetaKeys.downloadBytes,
|
||||||
]) {
|
]) {
|
||||||
|
try {
|
||||||
await meta.remove(k);
|
await meta.remove(k);
|
||||||
|
} catch (_) {
|
||||||
|
// Meta is a single sqlite table; failures here are rare.
|
||||||
|
// Swallow so the loop completes even if one key errors.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return freed;
|
return freed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'data/ai/gemma_llm_service.dart';
|
||||||
import 'data/ai/llm_service.dart';
|
import 'data/ai/llm_service.dart';
|
||||||
|
import 'data/ai/model_lifecycle.dart';
|
||||||
|
import 'data/db/daos/meta_dao.dart';
|
||||||
import 'state/ai_providers.dart';
|
import 'state/ai_providers.dart';
|
||||||
import 'state/providers.dart';
|
import 'state/providers.dart';
|
||||||
import 'ui/screens/habit_list_screen.dart';
|
import 'ui/screens/habit_list_screen.dart';
|
||||||
@@ -12,15 +15,70 @@ Future<void> main() async {
|
|||||||
runApp(ProviderScope(
|
runApp(ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
appDatabaseProvider.overrideWithValue(db),
|
appDatabaseProvider.overrideWithValue(db),
|
||||||
// OQ-1 pending: production-ready GemmaLlmService is wired here once
|
// #218: real GemmaLlmService when model file is on disk + verified,
|
||||||
// model URL + SHA are pinned and flutter_gemma is added. Until then,
|
// MockLlmService otherwise. The provider is read lazily by the frame
|
||||||
// MockLlmService keeps the app graceful (suggestFrame returns []).
|
// suggestion flow, so the resolution is dynamic per call.
|
||||||
llmServiceProvider.overrideWithValue(MockLlmService()),
|
llmServiceProvider.overrideWith((ref) {
|
||||||
|
return _LazyLlmService(
|
||||||
|
lifecycle: ref.watch(modelLifecycleProvider),
|
||||||
|
meta: ref.watch(metaDaoProvider),
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
child: const LifeHelperApp(),
|
child: const LifeHelperApp(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adapter that lazily resolves between [GemmaLlmService] (when the
|
||||||
|
/// model file exists + meta is intact) and [MockLlmService] (fallback,
|
||||||
|
/// graceful empty candidates). Keeps the rest of the app unaware of
|
||||||
|
/// the difference — `suggestFrame` only sees [LlmService].
|
||||||
|
class _LazyLlmService implements LlmService {
|
||||||
|
_LazyLlmService({required this.lifecycle, required this.meta});
|
||||||
|
final ModelLifecycle lifecycle;
|
||||||
|
final MetaDao meta;
|
||||||
|
LlmService? _delegate;
|
||||||
|
|
||||||
|
Future<LlmService> _resolve() async {
|
||||||
|
final avail = await lifecycle.checkAvailability();
|
||||||
|
final path = await meta.find(AiMetaKeys.modelPath);
|
||||||
|
final wantGemma = avail == ModelAvailability.ready && path != null;
|
||||||
|
// Re-resolve every call so opt-in / opt-out state changes are reflected
|
||||||
|
// without an app restart. Repeat-resolve of the same kind reuses the
|
||||||
|
// cached instance (Gemma's flutter_gemma installModel is idempotent;
|
||||||
|
// Mock has no setup), but the kind itself flips when availability does.
|
||||||
|
final keep = _delegate != null &&
|
||||||
|
(wantGemma == (_delegate is GemmaLlmService)) &&
|
||||||
|
(!wantGemma ||
|
||||||
|
(_delegate as GemmaLlmService).modelPath == path);
|
||||||
|
if (!keep) {
|
||||||
|
_delegate = wantGemma
|
||||||
|
? GemmaLlmService(modelPath: path)
|
||||||
|
: MockLlmService();
|
||||||
|
}
|
||||||
|
return _delegate!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLoaded => _delegate?.isLoaded ?? false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> load() async => (await _resolve()).load();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> unload() async {
|
||||||
|
final d = _delegate;
|
||||||
|
if (d != null) await d.unload();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> generateStructured(
|
||||||
|
String prompt,
|
||||||
|
Map<String, dynamic> schema,
|
||||||
|
) async =>
|
||||||
|
(await _resolve()).generateStructured(prompt, schema);
|
||||||
|
}
|
||||||
|
|
||||||
class LifeHelperApp extends StatelessWidget {
|
class LifeHelperApp extends StatelessWidget {
|
||||||
const LifeHelperApp({super.key});
|
const LifeHelperApp({super.key});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../data/ai/device_capabilities.dart';
|
||||||
import '../data/ai/llm_service.dart';
|
import '../data/ai/llm_service.dart';
|
||||||
import '../data/ai/model_lifecycle.dart';
|
import '../data/ai/model_lifecycle.dart';
|
||||||
import '../data/db/app_database.dart' as drift;
|
import '../data/db/app_database.dart' as drift;
|
||||||
@@ -10,19 +11,37 @@ import '../domain/ai/suggest_frame.dart';
|
|||||||
import '../domain/models/frame_pattern.dart';
|
import '../domain/models/frame_pattern.dart';
|
||||||
import 'providers.dart';
|
import 'providers.dart';
|
||||||
|
|
||||||
/// Default config for the on-device Gemma model (#215).
|
/// Gemma 4 E2B instruction-tuned LiteRT-LM checkpoint (#218 OQ-1 resolved).
|
||||||
/// OQ-1: URL + SHA-256 pinned in Developer phase. Until then, downloads are
|
/// Hosted on HuggingFace `litert-community/gemma-4-E2B-it-litert-lm`.
|
||||||
/// disabled (AI toggle is gated behind these constants being real).
|
/// File ≈ 2.41GB; SHA-256 pinned for integrity check.
|
||||||
const _kModelUrlPlaceholder =
|
///
|
||||||
'https://example.invalid/gemma4-e2b-q4.bin'; // OQ-1
|
/// Tests / placeholder builds may override `modelLifecycleProvider` with
|
||||||
const _kModelShaPlaceholder = 'PENDING_OQ_1';
|
/// fixture URLs. Production builds optionally inject a private mirror via
|
||||||
|
/// `--dart-define=GEMMA_MODEL_URL=...` (see main.dart).
|
||||||
|
const _kModelUrl =
|
||||||
|
'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm';
|
||||||
|
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) {
|
final modelLifecycleProvider = Provider<ModelLifecycle>((ref) {
|
||||||
return ModelLifecycle(
|
return ModelLifecycle(
|
||||||
meta: ref.watch(metaDaoProvider),
|
meta: ref.watch(metaDaoProvider),
|
||||||
config: ModelConfig(
|
config: ModelConfig(
|
||||||
url: Uri.parse(_kModelUrlPlaceholder),
|
url: Uri.parse(_kModelUrl),
|
||||||
expectedSha256: _kModelShaPlaceholder,
|
expectedSha256: _kModelSha256,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,17 +44,25 @@ class _AiSection extends ConsumerWidget {
|
|||||||
final settings = ref.watch(aiSettingsProvider);
|
final settings = ref.watch(aiSettingsProvider);
|
||||||
final availability = ref.watch(modelAvailabilityProvider);
|
final availability = ref.watch(modelAvailabilityProvider);
|
||||||
final download = ref.watch(modelDownloadControllerProvider);
|
final download = ref.watch(modelDownloadControllerProvider);
|
||||||
|
final ramOk = ref.watch(deviceMeetsAiRamProvider);
|
||||||
final optIn = settings.maybeWhen(data: (v) => v, orElse: () => false);
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: const Text('AI 도움 켜기'),
|
title: const Text('AI 도움 켜기'),
|
||||||
subtitle: const Text(
|
subtitle: Text(
|
||||||
'Gemma 4 E2B 모델 ≈ 1.5GB. 모든 처리는 단말에서 일어납니다.',
|
meetsRam
|
||||||
|
? 'Gemma 4 E2B 모델 ≈ 2.4GB. 모든 처리는 단말에서 일어납니다.'
|
||||||
|
: '이 단말에서는 AI 도움을 사용할 수 없어요 (RAM 4GB 이상 필요)',
|
||||||
),
|
),
|
||||||
value: optIn,
|
value: meetsRam && optIn,
|
||||||
onChanged: (v) async {
|
onChanged: meetsRam
|
||||||
|
? (v) async {
|
||||||
if (v) {
|
if (v) {
|
||||||
final ok = await _confirmOptIn(context);
|
final ok = await _confirmOptIn(context);
|
||||||
if (ok != true) return;
|
if (ok != true) return;
|
||||||
@@ -70,14 +78,15 @@ class _AiSection extends ConsumerWidget {
|
|||||||
SnackBar(content: Text('공간 확보됨 ${_fmtMB(freed)}')),
|
SnackBar(content: Text('공간 확보됨 ${_fmtMB(freed)}')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
availability.when(
|
availability.when(
|
||||||
loading: () => const ListTile(title: Text('상태 확인 중...')),
|
loading: () => const ListTile(title: Text('상태 확인 중...')),
|
||||||
error: (e, _) => ListTile(title: Text('상태 오류: $e')),
|
error: (e, _) => ListTile(title: Text('상태 오류: $e')),
|
||||||
data: (a) => ListTile(
|
data: (a) => ListTile(
|
||||||
title: const Text('모델 상태'),
|
title: const Text('모델 상태'),
|
||||||
subtitle: Text(_describe(a)),
|
subtitle: Text(_describe(a, meetsRam: meetsRam)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (optIn && download != null)
|
if (optIn && download != null)
|
||||||
@@ -93,12 +102,16 @@ class _AiSection extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _describe(ModelAvailability a) {
|
String _describe(ModelAvailability a, {required bool meetsRam}) {
|
||||||
switch (a) {
|
switch (a) {
|
||||||
case ModelAvailability.ready:
|
case ModelAvailability.ready:
|
||||||
return '준비 완료 — 새 습관 만들기에서 ✨ AI 제안을 쓸 수 있어요';
|
return '준비 완료 — 새 습관 만들기에서 ✨ AI 제안을 쓸 수 있어요';
|
||||||
case ModelAvailability.missing:
|
case ModelAvailability.missing:
|
||||||
return '아직 받지 않았어요 — 위 토글을 켜면 다운로드합니다';
|
// RAM 게이트로 토글이 비활성인 상태에서 "토글을 켜면" 안내는 모순.
|
||||||
|
// 단말 미지원 메시지를 그대로 노출해서 사용자가 다음 행동을 알 수 있게.
|
||||||
|
return meetsRam
|
||||||
|
? '아직 받지 않았어요 — 위 토글을 켜면 다운로드합니다'
|
||||||
|
: '이 단말은 모델을 받을 수 없어요 (RAM 4GB 이상 필요)';
|
||||||
case ModelAvailability.corrupt:
|
case ModelAvailability.corrupt:
|
||||||
return '파일이 손상됐어요 — 끄고 다시 켜면 새로 받습니다';
|
return '파일이 손상됐어요 — 끄고 다시 켜면 새로 받습니다';
|
||||||
case ModelAvailability.downloading:
|
case ModelAvailability.downloading:
|
||||||
@@ -125,9 +138,10 @@ class _AiSection extends ConsumerWidget {
|
|||||||
style: TextStyle(fontWeight: FontWeight.w600),
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
_Bullet('파일 크기: 약 1.5GB'),
|
_Bullet('파일 크기: 약 2.4GB'),
|
||||||
_Bullet('WiFi 연결을 권장합니다'),
|
_Bullet('WiFi 연결을 권장합니다'),
|
||||||
_Bullet('모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다'),
|
_Bullet('모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다'),
|
||||||
|
_Bullet('Gemma 이용약관(ai.google.dev/gemma/terms)에 동의합니다'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -156,12 +170,12 @@ class _AiSection extends ConsumerWidget {
|
|||||||
Text('모델 파일이 단말에서 삭제됩니다.'),
|
Text('모델 파일이 단말에서 삭제됩니다.'),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'약 1.5GB 의 저장공간이 확보돼요.',
|
'약 2.4GB 의 저장공간이 확보돼요.',
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'다시 켜면 다시 다운로드해야 합니다.',
|
'다시 켜면 처음부터 다운로드합니다.',
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
285
app/pubspec.lock
285
app/pubspec.lock
@@ -5,18 +5,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "85.0.0"
|
version: "96.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
|
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.7.1"
|
version: "10.2.0"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -33,6 +33,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
version: "2.13.1"
|
||||||
|
background_downloader:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: background_downloader
|
||||||
|
sha256: aceacec2b2a72ec3a8862ab5895fcbbc71ab33765f3619d57963f3110dd268e3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.5.5"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -45,18 +53,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build
|
name: build
|
||||||
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
|
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.4"
|
version: "4.0.6"
|
||||||
build_config:
|
build_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_config
|
name: build_config
|
||||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.3.0"
|
||||||
build_daemon:
|
build_daemon:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -65,30 +73,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.1"
|
version: "4.1.1"
|
||||||
build_resolvers:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: build_resolvers
|
|
||||||
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.5.4"
|
|
||||||
build_runner:
|
build_runner:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
|
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.4"
|
version: "2.15.0"
|
||||||
build_runner_core:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: build_runner_core
|
|
||||||
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.1.2"
|
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -149,18 +141,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: code_assets
|
name: code_assets
|
||||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "1.0.0"
|
||||||
code_builder:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: code_builder
|
|
||||||
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.11.1"
|
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -193,30 +177,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
version: "1.0.9"
|
||||||
|
dart_sentencepiece_tokenizer:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_sentencepiece_tokenizer
|
||||||
|
sha256: "85825632845cf6427ea0cd13dfba96b4341cf63525165155e5b9b97011239289"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.2"
|
||||||
dart_style:
|
dart_style:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_style
|
name: dart_style
|
||||||
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
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:
|
drift:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: drift
|
name: drift
|
||||||
sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1"
|
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.28.2"
|
version: "2.34.0"
|
||||||
drift_dev:
|
drift_dev:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: drift_dev
|
name: drift_dev
|
||||||
sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4"
|
sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.28.0"
|
version: "2.34.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -254,6 +262,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_gemma:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_gemma
|
||||||
|
sha256: "984960b54bbc0ff7e36cf568a02652b1d4bc016d6c75575b027fb6102fab48c3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.16.5"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -275,30 +291,11 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
freezed:
|
flutter_web_plugins:
|
||||||
dependency: "direct dev"
|
|
||||||
description:
|
|
||||||
name: freezed
|
|
||||||
sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.5.8"
|
|
||||||
freezed_annotation:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: freezed_annotation
|
|
||||||
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.4"
|
|
||||||
frontend_server_client:
|
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description: flutter
|
||||||
name: frontend_server_client
|
source: sdk
|
||||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
version: "0.0.0"
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.0.0"
|
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -319,10 +316,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "1.0.3"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -371,14 +368,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.1"
|
||||||
js:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: js
|
|
||||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.2"
|
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -391,10 +380,18 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: json_serializable
|
name: json_serializable
|
||||||
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
|
sha256: "5b89c1e32ae3840bb20a1b3434e3a590173ad3cb605896fb0f60487ce2f8104e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.9.5"
|
version: "6.11.4"
|
||||||
|
large_file_handler:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: large_file_handler
|
||||||
|
sha256: "1657db12b4591242b186c23eee437997be91e655ab0d803fd74829074460757f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.0"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -427,6 +424,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.1.0"
|
||||||
|
local_hnsw:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_hnsw
|
||||||
|
sha256: "7fb0988e3f850121774d9dfe94068f843d87908d6b1e9ed5039710d8d9ae3f7a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -467,14 +472,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
mutex:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mutex
|
||||||
|
sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.6"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: objective_c
|
name: objective_c
|
||||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.4.1"
|
version: "9.3.0"
|
||||||
package_config:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -603,6 +624,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "2.6.1"
|
||||||
|
shared_preferences:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences
|
||||||
|
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.5"
|
||||||
|
shared_preferences_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_android
|
||||||
|
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.26"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_foundation
|
||||||
|
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
|
shared_preferences_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_linux
|
||||||
|
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_platform_interface
|
||||||
|
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
shared_preferences_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_web
|
||||||
|
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
shared_preferences_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_windows
|
||||||
|
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
shelf:
|
shelf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -628,18 +705,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_gen
|
name: source_gen
|
||||||
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "4.2.3"
|
||||||
source_helper:
|
source_helper:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_helper
|
name: source_helper
|
||||||
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
|
sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.7"
|
version: "1.3.12"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -652,10 +729,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqlite3
|
name: sqlite3
|
||||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.9.4"
|
version: "3.3.2"
|
||||||
sqlite3_flutter_libs:
|
sqlite3_flutter_libs:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -668,10 +745,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqlparser
|
name: sqlparser
|
||||||
sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67"
|
sha256: "40bdddb306a727be9ce510bd2d2b9a6c9db6c586d846ef7b22e3990a2b24f02d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.41.2"
|
version: "0.44.5"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -728,14 +805,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.11"
|
||||||
timing:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: timing
|
|
||||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.2"
|
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -752,6 +821,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "2.0.1"
|
||||||
|
uuid:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.3"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -800,6 +877,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -818,4 +911,4 @@ packages:
|
|||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.12.2 <4.0.0"
|
dart: ">=3.12.2 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: life_helper
|
name: life_helper
|
||||||
description: "Huberman + Atomic Habits + Tiny Habits + If-Then. Local-first habit/checklist/todo."
|
description: "Huberman + Atomic Habits + Tiny Habits + If-Then. Local-first habit/checklist/todo."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.2.0+2
|
version: 0.3.0+3
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
@@ -21,18 +21,20 @@ dependencies:
|
|||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
|
|
||||||
# Models / serialization
|
# Models / serialization
|
||||||
freezed_annotation: ^2.4.0
|
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
|
|
||||||
# IDs
|
# IDs
|
||||||
ulid: ^2.0.0
|
ulid: ^2.0.0
|
||||||
|
|
||||||
# AI / on-device LLM (#215)
|
# AI / on-device LLM (#215, #218)
|
||||||
# flutter_gemma 는 OQ-1 (정확한 모델 URL + SHA) 확정 후 추가.
|
# #218 (v0.3.0): flutter_gemma 0.16.5 + Gemma 4 E2B (HF litert-community).
|
||||||
# v1은 LlmService 추상 + ModelLifecycle (파일/SHA/메타 관리) + Mock 까지 구현.
|
flutter_gemma: ^0.16.5
|
||||||
crypto: ^3.0.0
|
crypto: ^3.0.0
|
||||||
http: ^1.2.0
|
http: ^1.2.0
|
||||||
|
|
||||||
|
# Device info — RAM gate for AI opt-in (#218 AC-6)
|
||||||
|
device_info_plus: ^10.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
@@ -41,7 +43,6 @@ dev_dependencies:
|
|||||||
# Codegen
|
# Codegen
|
||||||
drift_dev: ^2.18.0
|
drift_dev: ^2.18.0
|
||||||
build_runner: ^2.4.0
|
build_runner: ^2.4.0
|
||||||
freezed: ^2.5.0
|
|
||||||
json_serializable: ^6.8.0
|
json_serializable: ^6.8.0
|
||||||
|
|
||||||
flutter:
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
150
app/test/data/ai/gemma_llm_service_test.dart
Normal file
150
app/test/data/ai/gemma_llm_service_test.dart
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_gemma/flutter_gemma.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:life_helper/data/ai/gemma_llm_service.dart';
|
||||||
|
|
||||||
|
/// Unit tests for `collectFunctionCall` (fn-spec §D, 8 cases).
|
||||||
|
///
|
||||||
|
/// `GemmaLlmService.load` / `.generateStructured` themselves require the
|
||||||
|
/// flutter_gemma native runtime and are covered by AC-7 (on-device E2E),
|
||||||
|
/// not by host tests. The pure stream-parsing helper is unit-testable in
|
||||||
|
/// isolation because we can feed a synthetic `Stream<ModelResponse>`.
|
||||||
|
void main() {
|
||||||
|
const fn = 'emit_frame_candidates';
|
||||||
|
|
||||||
|
test('1. single FCR with expected name returns args', () async {
|
||||||
|
final stream = Stream<ModelResponse>.fromIterable([
|
||||||
|
const FunctionCallResponse(
|
||||||
|
name: fn,
|
||||||
|
args: {
|
||||||
|
'candidates': [
|
||||||
|
{'text': 'a', 'level': 'L2'},
|
||||||
|
{'text': 'b', 'level': 'L2'},
|
||||||
|
{'text': 'c', 'level': 'L3'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
final args = await collectFunctionCall(stream, fn);
|
||||||
|
expect(args['candidates'], hasLength(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2. TextResponse before FCR is skipped', () async {
|
||||||
|
final stream = Stream<ModelResponse>.fromIterable([
|
||||||
|
const TextResponse('hello'),
|
||||||
|
const FunctionCallResponse(name: fn, args: {'candidates': []}),
|
||||||
|
]);
|
||||||
|
final args = await collectFunctionCall(stream, fn);
|
||||||
|
expect(args['candidates'], isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3. ThinkingResponse + TextResponse before empty-args FCR', () async {
|
||||||
|
final stream = Stream<ModelResponse>.fromIterable([
|
||||||
|
const ThinkingResponse('reasoning...'),
|
||||||
|
const TextResponse('preamble'),
|
||||||
|
const FunctionCallResponse(name: fn, args: {}),
|
||||||
|
]);
|
||||||
|
final args = await collectFunctionCall(stream, fn);
|
||||||
|
expect(args, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4. wrong function name throws FormatException', () async {
|
||||||
|
final stream = Stream<ModelResponse>.fromIterable([
|
||||||
|
const FunctionCallResponse(name: 'wrong_name', args: {}),
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
() => collectFunctionCall(stream, fn),
|
||||||
|
throwsA(
|
||||||
|
isA<FormatException>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('wrong_name'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('5. text-only stream throws "no function call emitted"', () async {
|
||||||
|
final stream = Stream<ModelResponse>.fromIterable([
|
||||||
|
const TextResponse('only text, no call'),
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
() => collectFunctionCall(stream, fn),
|
||||||
|
throwsA(
|
||||||
|
isA<FormatException>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('no function call emitted'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('6. stream error throws sanitized FormatException (no leak)', () async {
|
||||||
|
final stream = Stream<ModelResponse>.error(
|
||||||
|
Exception('SENSITIVE: user_prompt_leaked_in_error'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => collectFunctionCall(stream, fn),
|
||||||
|
throwsA(
|
||||||
|
isA<FormatException>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
allOf(
|
||||||
|
equals('stream error'),
|
||||||
|
isNot(contains('SENSITIVE')),
|
||||||
|
isNot(contains('user_prompt_leaked_in_error')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('7. FCR with empty args map returns empty map (no throw)', () async {
|
||||||
|
final stream = Stream<ModelResponse>.fromIterable([
|
||||||
|
const FunctionCallResponse(name: fn, args: {}),
|
||||||
|
]);
|
||||||
|
final args = await collectFunctionCall(stream, fn);
|
||||||
|
expect(args, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('8. empty stream throws "no function call emitted"', () async {
|
||||||
|
final stream = const Stream<ModelResponse>.empty();
|
||||||
|
expect(
|
||||||
|
() => collectFunctionCall(stream, fn),
|
||||||
|
throwsA(
|
||||||
|
isA<FormatException>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('no function call emitted'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ParallelFunctionCallResponse', () {
|
||||||
|
test('first call with expected name returns its args', () async {
|
||||||
|
final stream = Stream<ModelResponse>.fromIterable([
|
||||||
|
ParallelFunctionCallResponse(calls: [
|
||||||
|
const FunctionCallResponse(name: fn, args: {'x': 1}),
|
||||||
|
const FunctionCallResponse(name: 'other', args: {'y': 2}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
final args = await collectFunctionCall(stream, fn);
|
||||||
|
expect(args['x'], 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first call with wrong name throws', () async {
|
||||||
|
final stream = Stream<ModelResponse>.fromIterable([
|
||||||
|
ParallelFunctionCallResponse(calls: [
|
||||||
|
const FunctionCallResponse(name: 'wrong_first', args: {}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
() => collectFunctionCall(stream, fn),
|
||||||
|
throwsA(isA<FormatException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -61,3 +61,29 @@ docs/
|
|||||||
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
|
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
|
||||||
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
|
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 현재 발행된 문서 (인덱스)
|
||||||
|
|
||||||
|
### 설계서 (`design/`)
|
||||||
|
|
||||||
|
- [204-flutter-bootstrap](./design/204-flutter-bootstrap/) — Phase 1 MVP Drift 21 테이블 + 도메인 함수 + UI 4 화면
|
||||||
|
- [215-gemma-frame-suggest](./design/215-gemma-frame-suggest/) — Phase 2-A on-device Gemma 4 프레임 자동 생성
|
||||||
|
- [218-gemma-real-integration](./design/218-gemma-real-integration/) — OQ-1 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합 (placeholder → 실 구현)
|
||||||
|
|
||||||
|
### ADR (`adr/`)
|
||||||
|
|
||||||
|
- [0001-dose-variants.md](./adr/0001-dose-variants.md) — Dose Variants 도입
|
||||||
|
- [0002-dose-variants-normalized.md](./adr/0002-dose-variants-normalized.md) — Dose Variants 정규화 방식
|
||||||
|
- [0003-on-device-llm-gemma.md](./adr/0003-on-device-llm-gemma.md) — On-device LLM (Gemma 4) 도입
|
||||||
|
|
||||||
|
### 레퍼런스 (`reference/`)
|
||||||
|
|
||||||
|
- [215-ai-frame-suggest.md](./reference/215-ai-frame-suggest.md) — AI 프레임 제안 모듈 사양 (v0.2.0)
|
||||||
|
|
||||||
|
### 가이드 (`guides/`)
|
||||||
|
|
||||||
|
- [ai-help-onboarding.md](./guides/ai-help-onboarding.md) — AI 도움 켜기·끄기 사용자 가이드
|
||||||
|
|
||||||
|
### 파이프라인 (`pipeline/`)
|
||||||
|
|
||||||
|
- [QUEUE-PROTOCOL.md](./pipeline/QUEUE-PROTOCOL.md) — 8 페르소나 큐 프로토콜
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
# 설계서: Phase 2-A — Gemma 4 on-device 프레임 자동 생성 (#215)
|
# 설계서: Phase 2-A — Gemma 4 on-device 프레임 자동 생성 (#215)
|
||||||
|
|
||||||
> **상태**: Draft
|
> **상태**: Approved (v0.2.0, 커밋 `0d1db2d`)
|
||||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-12
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-12 (Documenter)
|
||||||
> **추적성** — Redmine: #215 · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md)
|
> **추적성** — Redmine: #215 · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) · 릴리스 태그: `v0.2.0`
|
||||||
> · 구현 파일: `app/lib/data/ai/`, `app/lib/domain/ai/`, `app/lib/state/ai_providers.dart` (TBD by Developer)
|
> · 구현 파일:
|
||||||
> · 테스트: `app/test/domain/ai/`, `app/test/data/ai/` (TBD by Developer)
|
> - `app/lib/data/ai/llm_service.dart` (abstract + MockLlmService)
|
||||||
|
> - `app/lib/data/ai/gemma_llm_service.dart` (stub — OQ-1 후 활성)
|
||||||
|
> - `app/lib/data/ai/model_lifecycle.dart` (download/verify/purge)
|
||||||
|
> - `app/lib/domain/ai/frame_candidate.dart`
|
||||||
|
> - `app/lib/domain/ai/suggest_frame.dart`
|
||||||
|
> - `app/lib/domain/ai/few_shot_builder.dart`
|
||||||
|
> - `app/lib/domain/ai/parse_response.dart`
|
||||||
|
> - `app/lib/state/ai_providers.dart` (Riverpod providers + ModelDownloadController)
|
||||||
|
> - `app/lib/ui/screens/settings_screen.dart`
|
||||||
|
> - `app/lib/ui/widgets/frame_suggestion_dialog.dart`
|
||||||
|
> - `app/lib/ui/screens/habit_create_screen.dart` (`_AiSuggestButton`)
|
||||||
|
> · 테스트:
|
||||||
|
> - `app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dart`
|
||||||
|
> - `app/test/data/ai/model_lifecycle_test.dart`
|
||||||
|
> - `app/test/state/model_download_controller_test.dart`
|
||||||
|
> - `app/test/ui/ai_suggest_button_visibility_test.dart`
|
||||||
|
> · 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../../reference/215-ai-frame-suggest.md)
|
||||||
|
> · 사용 가이드: [docs/guides/ai-help-onboarding.md](../../guides/ai-help-onboarding.md)
|
||||||
|
>
|
||||||
|
> **알려진 follow-up** (Reviewer F1/F2 + OQ-1):
|
||||||
|
> - OQ-1: 실제 Gemma 4 E2B Q4_0 모델 URL + SHA-256 — 현재 placeholder (`example.invalid`).
|
||||||
|
> - F1: 60초 idle auto-unload 미구현 — stub 상태라 무의미. OQ-1 해결 시 추가.
|
||||||
|
> - F2: `ModelLifecycle.purge()` 의 `File.delete()` try/catch 미감쌈 — placeholder URL 라 도달 불가.
|
||||||
> **하위 문서**:
|
> **하위 문서**:
|
||||||
> - [fn-suggest_frame.md](./fn-suggest_frame.md) — Gemma 4 호출 + few-shot 조립 + 응답 파싱
|
> - [fn-suggest_frame.md](./fn-suggest_frame.md) — Gemma 4 호출 + few-shot 조립 + 응답 파싱
|
||||||
> - [fn-model_lifecycle.md](./fn-model_lifecycle.md) — 모델 다운로드 / 로드 / 언로드 / 삭제 + LlmService 인터페이스
|
> - [fn-model_lifecycle.md](./fn-model_lifecycle.md) — 모델 다운로드 / 로드 / 언로드 / 삭제 + LlmService 인터페이스
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# 함수 설계서: `ModelLifecycle` + `LlmService` (#215)
|
# 함수 설계서: `ModelLifecycle` + `LlmService` (#215)
|
||||||
|
|
||||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
> **부모 설계서**: ./README.md · **상태**: Approved (v0.2.0)
|
||||||
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/model_lifecycle.dart`, `llm_service.dart`, `gemma_llm_service.dart` (TBD) · **테스트**: `app/test/data/ai/{model_lifecycle,gemma_llm_service}_test.dart` (TBD)
|
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/model_lifecycle.dart`, `llm_service.dart`, `gemma_llm_service.dart` (stub) · **테스트**: `app/test/data/ai/model_lifecycle_test.dart` + `app/test/state/model_download_controller_test.dart` (총 10 케이스)
|
||||||
|
> · 동기화 노트: `GemmaLlmService` 는 모든 메서드가 `UnimplementedError` 를 던지는 stub 상태 — OQ-1 (실 모델 URL+SHA) 해결 시 활성. v1 런타임은 `MockLlmService` 가 `llmServiceProvider` 에 주입.
|
||||||
|
|
||||||
> 본 문서는 모델 수명주기와 추론 인터페이스를 묶어 다룬다. 모두 I/O 경계 — flutter_gemma + 파일시스템 + 네트워크 + 네이티브 메모리.
|
> 본 문서는 모델 수명주기와 추론 인터페이스를 묶어 다룬다. 모두 I/O 경계 — flutter_gemma + 파일시스템 + 네트워크 + 네이티브 메모리.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 함수 설계서: `suggestFrame` + `buildFewShotPrompt` + `parseFrameCandidates` (#215)
|
# 함수 설계서: `suggestFrame` + `buildFewShotPrompt` + `parseFrameCandidates` (#215)
|
||||||
|
|
||||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
> **부모 설계서**: ./README.md · **상태**: Approved (v0.2.0)
|
||||||
> **작성**: [AI] Architect · **구현**: `app/lib/domain/ai/suggest_frame.dart`, `few_shot_builder.dart`, `parse_response.dart` (TBD) · **테스트**: `app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dart` (TBD)
|
> **작성**: [AI] Architect · **구현**: `app/lib/domain/ai/suggest_frame.dart`, `few_shot_builder.dart`, `parse_response.dart` · **테스트**: `app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dart` (총 27 케이스)
|
||||||
|
|
||||||
> 본 문서는 도메인 핵심 알고리즘 함수 3개를 묶어 다룬다. 셋 다 순수 함수 (또는 LlmService 만 의존) 로 테스트 가능성을 우선한다.
|
> 본 문서는 도메인 핵심 알고리즘 함수 3개를 묶어 다룬다. 셋 다 순수 함수 (또는 LlmService 만 의존) 로 테스트 가능성을 우선한다.
|
||||||
|
|
||||||
|
|||||||
395
docs/design/218-gemma-real-integration/README.md
Normal file
395
docs/design/218-gemma-real-integration/README.md
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
# 설계서: OQ-1 — 실 Gemma 통합 (#218)
|
||||||
|
|
||||||
|
> **상태**: Draft
|
||||||
|
> **작성**: [AI] Architect · **작성일**: 2026-06-12
|
||||||
|
> **추적성** — Redmine: #218 · 상위/이전: #215 (v0.2.0 placeholder) · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) (재확인, 신규 ADR 없음)
|
||||||
|
> · 변경 대상 구현 파일:
|
||||||
|
> - `app/lib/data/ai/gemma_llm_service.dart` (현재 `UnimplementedError` stub → 실 구현)
|
||||||
|
> - `app/lib/data/ai/model_lifecycle.dart` (`ModelLifecycle.purge` F2 hardening + load path 연결)
|
||||||
|
> - `app/lib/state/ai_providers.dart` (`_kModelUrlPlaceholder` / `_kModelShaPlaceholder` → 실값, `llmServiceProvider` 의 production override 활성화 path)
|
||||||
|
> - `app/lib/main.dart` (`MockLlmService` → `GemmaLlmService` 조건부 override)
|
||||||
|
> - `app/pubspec.yaml` (`flutter_gemma: 0.16.5` 추가)
|
||||||
|
> - `app/android/app/build.gradle` (`minSdkVersion 24`+ 확인), `app/android/app/src/main/AndroidManifest.xml` (OpenGL ES feature, MediaPipe ProGuard rules)
|
||||||
|
> · 추가 테스트:
|
||||||
|
> - `app/test/data/ai/gemma_llm_service_test.dart` (schema → Tool 변환, FunctionCallResponse 수집 단위, error mapping)
|
||||||
|
> - `app/test/data/ai/model_lifecycle_test.dart` (기존 + F2 case 추가)
|
||||||
|
> - E2E: AC-7 실 단말 수동 (Android 8GB+)
|
||||||
|
> · 선행 설계서 (변경 없음): [docs/design/215-gemma-frame-suggest/](../215-gemma-frame-suggest/) — placeholder 기반 v0.2.0 청사진. 본 설계서는 placeholder 자리 채움에 한정.
|
||||||
|
> · 하위 문서:
|
||||||
|
> - [fn-gemma_llm_service.md](./fn-gemma_llm_service.md) — `GemmaLlmService.load` / `generateStructured` 의 schema→Tool 변환 + 스트림 응답 수집 + 에러 매핑 알고리즘
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
v0.2.0 (#215) 은 mock 환경에서 100% 동작하지만, 사용자가 "AI 도움" 토글을 ON 하면 `_kModelUrlPlaceholder = 'https://example.invalid/...'` 로 인해 graceful 실패 다이얼로그만 본다. 실 사용자 가치는 0. 본 작업의 단일 과제는 **"#215 가 정의한 `LlmService` 추상화의 뒤편을 실 `flutter_gemma 0.16.5` + 실 Gemma 4 E2B QAT 모바일 모델로 교체하여, mock 경로와 동일한 사용자 흐름이 실제로 후보 문장을 반환하게 만드는 것"** 이다.
|
||||||
|
|
||||||
|
청사진(#215)·UI·도메인 로직은 모두 그대로 둔다. 본 설계서는 placeholder 3 지점 (`_kModelUrlPlaceholder`, `_kModelShaPlaceholder`, `GemmaLlmService` 본문) 만 다룬다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- `flutter_gemma 0.16.5` pubspec 추가 + pubspec.lock 동결.
|
||||||
|
- Gemma 4 E2B QAT 모바일 모델 URL 확정 (HuggingFace `litert-community/gemma-4-E2B-it-litert-lm` 의 `.task` 또는 `.litertlm` 자산, 또는 `google/gemma-4-E2B-it-qat-mobile-transformers`) + SHA-256 핀 고정.
|
||||||
|
- `GemmaLlmService.load`/`generateStructured`/`unload` 실 구현 (flutter_gemma 0.16.5 의 `FlutterGemma.initialize` + chat session + Stream<ModelResponse>).
|
||||||
|
- function calling 스키마 (`kFrameCandidatesSchema` JSON Schema) → flutter_gemma `Tool` 객체 변환 어댑터.
|
||||||
|
- `FunctionCallResponse(name, args)` 스트림 이벤트를 수집하여 `args: Map<String, dynamic>` 반환.
|
||||||
|
- Android 빌드 설정: `minSdkVersion 24`+ 확인, OpenGL ES feature 선언, MediaPipe ProGuard rules.
|
||||||
|
- 단말 게이트: RAM 4GB 미만 차단 (AC-9, #215 §9 재활용 — 새 메서드 없음).
|
||||||
|
- HuggingFace access token 핸들링: 빌드 시점 `--dart-define=HF_TOKEN=...` 주입 (사용자 단말에 평문 저장 X, 모델 다운로드 1회만 사용).
|
||||||
|
- `_kModelUrlPlaceholder` / `_kModelShaPlaceholder` 상수 → 실값으로 치환 + 상수명에서 `Placeholder` 제거.
|
||||||
|
- `main.dart` 의 production override: `aiSettingsProvider == true && modelAvailability == ready` 일 때만 `GemmaLlmService(modelPath: ...)` 으로 override, 그 외엔 `MockLlmService` 유지 (graceful).
|
||||||
|
- F2 hardening 통합: `ModelLifecycle.purge()` 의 `File.delete()` 를 try/catch 로 감쌈 (실파일이라 도달 가능).
|
||||||
|
|
||||||
|
### 제외 (out of scope)
|
||||||
|
|
||||||
|
- **#219 F1**: 60초 idle auto-unload — 별도 이슈. 본 설계는 즉시 load + 명시적 unload 만.
|
||||||
|
- **#220 F2 broader purge hardening**: 위 단일 try/catch 외 광범위 hardening (예: 부분 다운로드 `.tmp` 정리 순서, 동시성) 은 #220 으로.
|
||||||
|
- **#221 AC-10**: 한국어 corpus 30 케이스 평가 자동화 — 별도 이슈. 본 설계는 AC-7 실 단말 E2E 수동 검증만.
|
||||||
|
- **#222 production keystore**: 릴리스 서명 키 + Play Store 검토 별도.
|
||||||
|
- **E4B 모델**: ADR-0003 결정 #2 유지 — v1 은 E2B 단일.
|
||||||
|
- **iOS 빌드**: Phase 1 과 동일 Android-only.
|
||||||
|
- **시나리오 #2~#6** (앵커, dose variants, if-then, lapse, 주간 요약): Phase 2-B+.
|
||||||
|
- **모델 교체 UI** (E2B v1 → v2 swap): v2+.
|
||||||
|
- **HF 토큰 사용자 UX**: v1 은 빌드 임베드 (joungmin 토큰). v2 에서 사용자 본인 토큰 입력 화면 검토.
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
|
||||||
|
> Planner 가 정한 10개 그대로. QA 판정.
|
||||||
|
|
||||||
|
- [ ] **AC-1**: `flutter pub add flutter_gemma:^0.16.5` 통과 + `flutter analyze` 0 issue + `flutter build apk --debug` 성공.
|
||||||
|
- [ ] **AC-2**: 사용자 "AI 도움" 토글 ON 시 동의 다이얼로그 (#215 그대로) → 백그라운드 다운로드가 **example.invalid 가 아닌 실 HF endpoint** 로 향한다.
|
||||||
|
- [ ] **AC-3**: 다운로드 일시정지/재개/취소 동작이 실 HF URL 에 대해서도 보존 (HTTP Range 응답 검증). 강제 종료 후 resume 정상.
|
||||||
|
- [ ] **AC-4**: 다운로드 완료 후 SHA-256 검증이 실 모델 파일에 대해 통과 + `meta_kv['ai_model_path']` 에 절대 경로 저장.
|
||||||
|
- [ ] **AC-5**: 다운로드 완료 후 `HabitCreateScreen` 의 "AI 제안" 버튼이 활성 (#215 UI 그대로).
|
||||||
|
- [ ] **AC-6**: 실 단말 (RAM ≥ 8GB) 에서 "술 끊고 싶어" → 후보 3개가 5초 이내 (cold start) / 2초 이내 (warm) 표시.
|
||||||
|
- [ ] **AC-7**: **실 Android 단말 E2E** — 후보가 `FrameCandidate.level ∈ {L2, L3}` 이고 `validateFrameLevel` 통과 ≥ 1.
|
||||||
|
- [ ] **AC-8**: opt-out 시 모델 파일 즉시 삭제 (`File.delete`) + meta_kv clear + "공간 확보됨" 토스트. F2 hardening 으로 `File.delete` 예외도 graceful.
|
||||||
|
- [ ] **AC-9**: RAM < 4GB 단말 또는 모델 로드 OOM 또는 generateStructured timeout 10s 시 빈 리스트 반환 + 수동 입력 경로 차단 없음.
|
||||||
|
- [ ] **AC-10**: 한국어 30 corpus ≥ 70% L2/L3 통과 — **#221 로 분리**. 본 이슈는 AC-7 만으로 close.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
### 의존성
|
||||||
|
|
||||||
|
- **#215 v0.2.0** 완료 상태 (커밋 `0d1db2d`). 모든 도메인/UI/Riverpod 골격 + `MockLlmService` 100% 통과 전제.
|
||||||
|
- **`flutter_gemma 0.16.5`** (pub.dev 확정, 2026-06-12 기준 latest stable, 약 40시간 전 publish).
|
||||||
|
- Public API: `FlutterGemma.initialize(huggingFaceToken: String)`, `FlutterGemma.installModel(modelType: ModelType.gemma4).fromNetwork(url).install()`, `FlutterGemma.getActiveModel(maxTokens: 2048)`, `model.createChat()`, `chat.addQueryChunk(Message.text(text, isUser))`, `chat.generateChatResponseAsync()` → `Stream<ModelResponse>` (`TextResponse | FunctionCallResponse | ThinkingResponse`).
|
||||||
|
- Function calling: **Gemma 4 native function calling** — 별도 `Tool` 객체 주입 없이 `ModelType.gemma4` 의 chat template 이 자동 라우팅. 모델이 호출 결정 시 스트림에 `FunctionCallResponse(name, args)` 1건 emit. (Gemma 4 / Gemma 3n / Phi-4 등 지원 명시)
|
||||||
|
- Schema 전달 경로: prompt 본문에 JSON Schema 를 자연어로 명시 (Gemma 4 의 chat template 이 인식). 별도 `tools: [...]` 파라미터는 0.16.5 의 createChat 인터페이스 기준 옵션이지 필수 아님 — **OQ-C** 에서 확정.
|
||||||
|
- **Gemma 4 E2B 모델** — HuggingFace `litert-community/gemma-4-E2B-it-litert-lm` repo. **OQ-A 확정 (2026-06-12):** 정확 파일 = `gemma-4-E2B-it.litertlm` (2,588,147,712 bytes ≈ **2.41GB disk**), SHA-256 = `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c`. 모바일 1GB QAT 변종은 현시점 미공개 (Google 6월 blog 발표 자산 아직 HF 미게시). peak RAM 추정 ≈ 1.5–2GB (가중치 ≈ 1.3GB + KV cache + activation).
|
||||||
|
- **HF access token** — joungmin 본인 계정의 read-only token. 빌드 시점 `--dart-define=HF_TOKEN=hf_xxx` 으로 주입, 런타임에 `String.fromEnvironment('HF_TOKEN')` 으로 읽어 `FlutterGemma.initialize` 에 전달. 토큰을 단말 영속 저장 금지.
|
||||||
|
- **`crypto`** (기존), **`path_provider`** (기존), **`http`** (기존) — 모두 #215 에서 이미 사용 중.
|
||||||
|
- **Android**: `minSdkVersion 24` (MediaPipe LLM Inference 요구사항). 기존 #204 가 26 이므로 통과 가정.
|
||||||
|
|
||||||
|
### 제약
|
||||||
|
|
||||||
|
- **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 그대로).
|
||||||
|
|
||||||
|
### 가정
|
||||||
|
|
||||||
|
- joungmin 보유 Android 단말 1대 이상 (RAM ≥ 8GB, Android 13+) — AC-7 검증 필수.
|
||||||
|
- HF account 1개 (joungmin) + Gemma 라이선스 수락 완료.
|
||||||
|
- flutter_gemma 가 Android 측에서 자체적으로 OpenGL ES 백엔드 사용 (GPU). CPU fallback 은 0.16.5 가 자동 처리.
|
||||||
|
- pub.dev 의 `flutter_gemma 0.16.5` 가 향후 6개월 내 breaking change 없음 (semver patch 만 갱신 허용).
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
### 변경 범위 (added/changed 만)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── lib/
|
||||||
|
│ ├── data/
|
||||||
|
│ │ └── ai/
|
||||||
|
│ │ ├── gemma_llm_service.dart ★ 본문 교체 (stub → 실 구현)
|
||||||
|
│ │ └── model_lifecycle.dart △ purge() F2 try/catch 추가
|
||||||
|
│ ├── state/
|
||||||
|
│ │ └── ai_providers.dart △ _kModelUrl / _kModelSha 상수 치환
|
||||||
|
│ │ (이름에서 Placeholder 제거)
|
||||||
|
│ └── main.dart △ Mock → Gemma 조건부 override
|
||||||
|
├── android/app/
|
||||||
|
│ ├── build.gradle △ minSdkVersion 24 확인
|
||||||
|
│ └── src/main/AndroidManifest.xml △ uses-feature OpenGL ES 3.0
|
||||||
|
│ + ProGuard rules (proguard-rules.pro)
|
||||||
|
├── pubspec.yaml △ flutter_gemma: ^0.16.5
|
||||||
|
└── test/
|
||||||
|
└── data/ai/
|
||||||
|
└── gemma_llm_service_test.dart ★ 신규
|
||||||
|
```
|
||||||
|
|
||||||
|
설계서 #215 의 `lib/domain/ai/`, `lib/ui/`, `frame_candidate.dart`, `suggest_frame.dart` 등은 **변경 0건**. 단위 테스트도 기존 31개 전부 유지.
|
||||||
|
|
||||||
|
### 데이터 흐름 (변경된 노드만 빨간색 마킹)
|
||||||
|
|
||||||
|
```
|
||||||
|
[main.dart]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ProviderScope.overrides = [
|
||||||
|
appDatabaseProvider,
|
||||||
|
llmServiceProvider.overrideWith((ref) {
|
||||||
|
// ▼ 본 설계서 변경 지점
|
||||||
|
final settings = ref.watch(aiSettingsProvider).value ?? false;
|
||||||
|
final avail = ref.watch(modelAvailabilityProvider).value;
|
||||||
|
final path = avail?.modelPath;
|
||||||
|
if (settings && path != null) {
|
||||||
|
return GemmaLlmService(modelPath: path); // ★ 실 구현
|
||||||
|
}
|
||||||
|
return MockLlmService(); // graceful fallback
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
│
|
||||||
|
▼ (사용자가 #215 흐름 그대로 진입)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[suggestFrame] (#215, 변경 없음)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[LlmService.generateStructured(prompt, schema)] (#215 abstract, 변경 없음)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[GemmaLlmService.generateStructured] ★ 본 설계서 §7 + fn-*.md
|
||||||
|
│
|
||||||
|
├─► model = await FlutterGemma.getActiveModel(maxTokens: 2048)
|
||||||
|
├─► chat = await model.createChat()
|
||||||
|
├─► schemaPrompt = _appendSchemaInstruction(prompt, schema)
|
||||||
|
│ // Gemma 4 native function calling 은 prompt 본문에
|
||||||
|
│ // function name + JSON schema 안내가 들어가면 자동 라우팅
|
||||||
|
├─► await chat.addQueryChunk(Message.text(text: schemaPrompt, isUser: true))
|
||||||
|
├─► stream = chat.generateChatResponseAsync()
|
||||||
|
├─► await for (event in stream) {
|
||||||
|
│ if (event is FunctionCallResponse && event.name == 'emit_frame_candidates') {
|
||||||
|
│ result = event.args;
|
||||||
|
│ break; // 첫 FCR 만 채택
|
||||||
|
│ }
|
||||||
|
│ }
|
||||||
|
├─► await chat.close() // 세션 정리
|
||||||
|
└─► return result;
|
||||||
|
```
|
||||||
|
|
||||||
|
### I/O ↔ 순수 로직 경계
|
||||||
|
|
||||||
|
- `lib/data/ai/gemma_llm_service.dart` = I/O 경계 (flutter_gemma native call + Dart Future timeout).
|
||||||
|
- `lib/domain/ai/` = 변경 0 (순수 유지).
|
||||||
|
- `_appendSchemaInstruction(prompt, schema)` 어댑터는 `gemma_llm_service.dart` 의 file-private top-level 순수 함수. 단위 테스트 가능 (입력 prompt + schema → 기대 string 비교).
|
||||||
|
- `_collectFunctionCall(stream, name)` 도 file-private. fake `Stream<ModelResponse>` 로 단위 테스트.
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
본 설계서는 **신규 도메인 모델 0건**. #215 의 `FrameCandidate`, `SuggestFrameInput`, `ModelAvailability`, `DownloadProgress` 전부 재사용.
|
||||||
|
|
||||||
|
### `_kModelUrl` / `_kModelSha256` 상수 (치환)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// app/lib/state/ai_providers.dart
|
||||||
|
const _kModelUrl =
|
||||||
|
'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm';
|
||||||
|
const _kModelSha256 =
|
||||||
|
'181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c';
|
||||||
|
const _kModelTotalBytes = 2588147712; // 2.41 GiB — UI 표시 용
|
||||||
|
// 향후 QAT 모바일 1GB 변종이 HF 에 게시되면 swap. v1 은 위 base .litertlm.
|
||||||
|
```
|
||||||
|
|
||||||
|
`Placeholder` 접미사 제거. `meta_kv['ai_model_path']`, `meta_kv['ai_model_sha256']` 키도 의미는 동일 (값만 실체).
|
||||||
|
|
||||||
|
### Function calling 스키마 (변경 없음 — `kFrameCandidatesSchema`)
|
||||||
|
|
||||||
|
`#215` 의 JSON Schema 를 그대로 사용. flutter_gemma `Tool.parameters` 가 JSON Schema 호환이므로 1:1 매핑.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "emit_frame_candidates",
|
||||||
|
"description": "Return 3 framed habit goal candidates at L2 or L3 level.",
|
||||||
|
"parameters": { ... (§7 #215 그대로) ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### HF 토큰 (런타임 만)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/data/ai/gemma_llm_service.dart 의 top-level
|
||||||
|
const _hfToken = String.fromEnvironment('HF_TOKEN', defaultValue: '');
|
||||||
|
```
|
||||||
|
|
||||||
|
빈 문자열이면 `FlutterGemma.initialize` 호출 시 throw → graceful 경로로 `MockLlmService` 유지.
|
||||||
|
|
||||||
|
## 7. 함수 명세 (Function Specs)
|
||||||
|
|
||||||
|
> 본 설계서가 새로 손대는 함수만. 그 외는 #215 §7 표 그대로.
|
||||||
|
|
||||||
|
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|-----------|----------|------|------|-----------|-------|
|
||||||
|
| `GemmaLlmService.load` | flutter_gemma 모델 파일 → 메모리 로드 | `Future<void> load()` | (modelPath 필드) | void | FileSystemException / MissingHFToken / OOM → 그대로 throw | **복잡** → [fn-gemma_llm_service.md](./fn-gemma_llm_service.md) |
|
||||||
|
| `GemmaLlmService.generateStructured` | schema → Tool, FunctionCallResponse 수집 | `Future<Map<String,dynamic>> generateStructured(String, Map)` | prompt, schema | parsed JSON args | StateError(미로드), FormatException(빈 응답), TimeoutException(외부) | **복잡** → [fn-gemma_llm_service.md](./fn-gemma_llm_service.md) |
|
||||||
|
| `GemmaLlmService.unload` | session + model close, _loaded=false | `Future<void> unload()` | none | void | idempotent — 미로드 상태에도 safe | 단순 |
|
||||||
|
| `_appendSchemaInstruction` (file-private) | prompt 본문에 function schema 안내 문자열 append | `String _appendSchemaInstruction(String prompt, Map<String,dynamic> schema)` | prompt, schema | augmented prompt | schema 의 name/parameters 누락 시 ArgumentError | 단순 (string concat + JSON serialize) |
|
||||||
|
| `_collectFunctionCall` (file-private) | Stream<ModelResponse> 에서 첫 FCR 추출 | `Future<Map<String,dynamic>> _collectFunctionCall(Stream<ModelResponse>, String)` | stream, expectedName | args | 다른 name FCR → throw FormatException; stream done 전 FCR 없음 → FormatException | 단순 (state machine 1단) |
|
||||||
|
| `ModelLifecycle.purge` (수정) | F2 hardening — File.delete try/catch | (시그니처 동일) | none | int | 파일 미존재/권한 → log warn + 카운트 0, throw 안 함 | 단순 (try/catch 1개 추가만) |
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
|
||||||
|
### 시나리오 A: 첫 실 다운로드 + 첫 추론
|
||||||
|
|
||||||
|
1. 사용자가 v0.3.0 APK 설치 (HF 토큰 빌드 임베드 상태).
|
||||||
|
2. AI 토글 ON → 동의 다이얼로그 → 다운로드 시작.
|
||||||
|
3. `ModelLifecycle.download()` 가 `_kModelUrl` (실 HF endpoint) 으로 HTTP GET (HF 가 LFS redirect 처리, `http` 패키지 follow redirect).
|
||||||
|
4. 다운로드 완료 → SHA-256 검증 (`_kModelSha256` 와 비교).
|
||||||
|
5. `meta_kv['ai_model_path']` 저장 → `modelAvailabilityProvider` 가 `ready` 로 전환.
|
||||||
|
6. `main.dart` 의 override 가 `GemmaLlmService(modelPath: ...)` 인스턴스 반환 시작.
|
||||||
|
7. 사용자가 HabitCreate → "AI 제안" 탭 → `frameSuggestionsProvider` 구독.
|
||||||
|
8. `suggestFrame` → `llm.isLoaded == false` → `llm.load()` 호출.
|
||||||
|
9. `GemmaLlmService.load`:
|
||||||
|
- `FlutterGemma.initialize(huggingFaceToken: _hfToken)` 1회 (top-level `_initialized` guard).
|
||||||
|
- `await FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install()` — `ModelLifecycle` 가 이미 파일을 받아둔 상태이므로 `fromNetwork` 대신 `fromFile` (또는 `fromAsset`) 경로.
|
||||||
|
- `_model = await FlutterGemma.getActiveModel(maxTokens: 2048)`.
|
||||||
|
- `_loaded = true`.
|
||||||
|
- cold start ≈ 1–3 초.
|
||||||
|
10. `suggestFrame` → `buildFewShotPrompt` (#215 그대로) → `llm.generateStructured(prompt, kFrameCandidatesSchema)`.
|
||||||
|
11. `GemmaLlmService.generateStructured`:
|
||||||
|
- `_loaded` 검사. false → `StateError`.
|
||||||
|
- `augmented = _appendSchemaInstruction(prompt, schema)` — schema 의 name/parameters 를 prompt 끝에 JSON 형태로 append. Gemma 4 native chat template 이 FCR 로 자동 변환.
|
||||||
|
- `chat = await _model.createChat()`. (sampling 파라미터 temperature/topK/topP 는 0.16.5 의 model-level 또는 chat-level 설정 — **OQ-C** 에서 확정)
|
||||||
|
- `await chat.addQueryChunk(Message.text(text: augmented, isUser: true))`.
|
||||||
|
- `stream = chat.generateChatResponseAsync()`.
|
||||||
|
- `result = await _collectFunctionCall(stream, 'emit_frame_candidates')`:
|
||||||
|
- `await for (event in stream) { ... }` 로 첫 `FunctionCallResponse` 만 채택.
|
||||||
|
- `TextResponse` / `ThinkingResponse` 는 skip.
|
||||||
|
- 다른 name → `FormatException('unexpected function: ${event.name}')`.
|
||||||
|
- stream done 까지 FCR 없음 → `FormatException('no function call emitted')`.
|
||||||
|
- `await chat.close()` (finally 블록).
|
||||||
|
- return `result`.
|
||||||
|
12. `suggestFrame` 이 `parseFrameCandidates(result)` (#215 그대로) → L0/L1 폐기 후 후보 3개 반환.
|
||||||
|
13. UI 표시.
|
||||||
|
|
||||||
|
### 시나리오 B: opt-out (F2 hardening 검증)
|
||||||
|
|
||||||
|
1. 사용자가 AI 토글 OFF.
|
||||||
|
2. `AiSettingsController.setOptIn(false)` →
|
||||||
|
- `ref.read(modelDownloadControllerProvider.notifier).cancel()`.
|
||||||
|
- `await ref.read(modelLifecycleProvider).purge()`:
|
||||||
|
- 기존 코드: `await File(path).delete()` (no try/catch — F2).
|
||||||
|
- 본 설계: try/catch 로 감쌈. 실패 시 (권한, 외부 삭제) log + 0 반환.
|
||||||
|
3. `meta_kv` clear, opt_in='false'.
|
||||||
|
4. 토스트.
|
||||||
|
|
||||||
|
### 시나리오 C: HF 토큰 누락 (debug 빌드)
|
||||||
|
|
||||||
|
1. 개발자가 `--dart-define=HF_TOKEN` 없이 빌드.
|
||||||
|
2. `_hfToken == ''`.
|
||||||
|
3. 사용자 토글 ON 시도 → 다운로드 시작 (HF endpoint 가 토큰 없으면 401).
|
||||||
|
4. `ModelLifecycle.download` 가 HTTP 401 emit → 기존 `friendly_error` 로 "다운로드 실패, 다시 시도" 표시.
|
||||||
|
5. graceful 유지.
|
||||||
|
|
||||||
|
### 시나리오 D: 모델 응답이 함수 호출 없이 plain text
|
||||||
|
|
||||||
|
1. `chat.generateChatResponseAsync()` 가 `TextResponse` 만 stream.
|
||||||
|
2. `_collectFunctionCall` 이 stream done 까지 FCR 없으면 `FormatException`.
|
||||||
|
3. `suggestFrame` (#215) 의 catch 가 빈 리스트 반환 → 다이얼로그 "다시 시도".
|
||||||
|
4. dev log 에 "FCR not emitted, model returned plain text" 기록 (prompt 본문은 X).
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
|
||||||
|
| 상황 | 처리 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| HF endpoint 가 LFS 미디어 URL 로 302 redirect | 기존 `http` 패키지 follow redirect 옵션 ON 으로 처리 | flutter_gemma 가 자체 download 메서드 갖고 있어도 우리는 `ModelLifecycle.download` 유지 (resume + SHA 통일) |
|
||||||
|
| `FlutterGemma.initialize` 가 두 번째 호출에 throw | top-level `bool _hfInitialized = false` 가드 | 0.16.5 idempotent 여부 미확정 시 보호 |
|
||||||
|
| `_appendSchemaInstruction` 호출에서 schema 가 #215 형식과 다름 | ArgumentError | 본 설계에선 발생 불가 (`kFrameCandidatesSchema` 고정) |
|
||||||
|
| `_collectFunctionCall` 도중 stream error event | try/catch 으로 `FormatException` 변환 | error.toString() 폐기 (prompt 누설 방지) |
|
||||||
|
| `chat.close()` 가 throw | `unawaited` + log warn, 호출자에 전파 X | 다음 호출에 영향 없음 보장 |
|
||||||
|
| `unload()` 호출 시 `_model == null` | early return | idempotent |
|
||||||
|
| Android RAM 4GB 미만 단말 | 기존 #215 §9 device gate 동작 (모델 다운로드 자체 차단) | flutter_gemma load 이전 단계에서 거름 |
|
||||||
|
| flutter_gemma OOM (Q4_0 모델인데도) | native exception → Dart 측 `Exception` → `suggestFrame` catch → 빈 리스트 | 사용자에겐 #215 의 "응답 없음" 메시지 |
|
||||||
|
| Stream done event 가 옴 그러나 FCR 또한 옴 | break 으로 빠진 후 close — 정상 | 첫 FCR 가 진실, 이후 이벤트는 폐기 |
|
||||||
|
| ProGuard 가 MediaPipe 클래스 strip | release 빌드 시 crash | `proguard-rules.pro` 에 `-keep class com.google.mediapipe.** { *; }` 추가 |
|
||||||
|
|
||||||
|
### 안전한 기본값
|
||||||
|
|
||||||
|
- `_hfToken` 누락 → mock 경로 유지 (override 안 함).
|
||||||
|
- `_kModelUrl` / `_kModelSha256` 가 빈 문자열 또는 `<HEX_64_FROM_HF_LFS_POINTER>` 같은 sentinel 이면 다운로드 시작 안 함 → graceful.
|
||||||
|
- 모든 native exception 은 `suggestFrame` 에서 catch → 빈 리스트 (#215 계약 유지).
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
|
||||||
|
### 단위 테스트 (신규/수정)
|
||||||
|
|
||||||
|
| AC | 테스트 | 위치 | 모킹 |
|
||||||
|
|----|--------|------|------|
|
||||||
|
| AC-1 | `flutter analyze` + APK debug build CI | scripts/ci | — |
|
||||||
|
| AC-3 | `model_lifecycle_test.dart` Range header 테스트 — 기존 + 실 URL host header 검증 | test/data/ai | mock HTTP |
|
||||||
|
| AC-4 | `model_lifecycle_test.dart` — `_kModelSha256` 가 sentinel 일 때 skip 분기 | test/data/ai | tmp file |
|
||||||
|
| AC-7 (단위 부분) | `gemma_llm_service_test.dart` — `_appendSchemaInstruction` 변환 + `_collectFunctionCall` 의 4 케이스 (FCR 만 / Text+FCR / Thinking+FCR / Text 만) | test/data/ai | mock `Stream<ModelResponse>` (`flutter_gemma` 의 response 타입 fake) |
|
||||||
|
| AC-7 (E2E) | 수동: APK 실 단말 설치 → 토글 ON → 다운로드 → "술 끊고 싶어" → 후보 ≥ 1 + 모두 L2/L3 | QA 수동 | 실 Gemma |
|
||||||
|
| AC-8 (F2) | `model_lifecycle_test.dart` — `purge()` 가 `File.delete` throw 해도 정상 return | test/data/ai | mock FileSystem (`MemoryFileSystem` 가능 시) 또는 read-only tmp |
|
||||||
|
| AC-9 | `gemma_llm_service_test.dart` — `_loaded=false` 인 채 `generateStructured` 호출 시 StateError | test/data/ai | direct |
|
||||||
|
|
||||||
|
### Mock 전략
|
||||||
|
|
||||||
|
- **flutter_gemma 직접 mock 불가** (final class 가능성) → `LlmService` 추상화는 그대로 두고, `GemmaLlmService` 내부의 `_schemaToTool` / `_collectFunctionCall` 만 단위 테스트.
|
||||||
|
- `_collectFunctionCall(stream, name)` 는 `Stream<ModelResponse>` 만 받으므로 `Stream.fromIterable([...])` 로 fake event 주입 가능.
|
||||||
|
- 단위 테스트가 flutter_gemma SDK 의 ModelResponse 타입을 import 해야 함 → `flutter_gemma: ^0.16.5` 의존성을 dev_dependencies 가 아닌 dependencies 로.
|
||||||
|
|
||||||
|
### E2E (수동, QA 단계)
|
||||||
|
|
||||||
|
- 단말: joungmin Android 8GB+ 1대.
|
||||||
|
- 시나리오: 시나리오 A 전체 (다운로드 → cold inference → 후보 1개 탭 → habit 저장).
|
||||||
|
- 측정: cold latency, warm latency, 메모리 peak (Android Studio profiler 1회).
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
|
||||||
|
### 본 설계서 내 결정
|
||||||
|
|
||||||
|
| 결정 | 채택 | 대안 | 근거 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| Gemma 4 E2B (3n 또는 3 27B 아님) | ✓ | Gemma 3n E2B / Gemma 3 27B | Gemma 4 = 2026-04-02 출시, E2B 가 모바일 전용 SKU, function calling 네이티브 지원, QAT 모바일 양자화 (≈1GB) 까지 출시 (2026-06) — ADR-0003 결정 #2 유지 |
|
||||||
|
| `flutter_gemma 0.16.5` pin | ✓ | 0.16.x range / latest | 2026-06-12 기준 latest stable (40h 전 publish), Gemma 4 + FCR + `.task`/`.litertlm` 모두 지원 |
|
||||||
|
| function calling (FCR) | ✓ | 자유 텍스트 + 정규식 fallback (ADR-0004 후보) | flutter_gemma 0.16.5 가 Gemma 4 native FCR 공식 지원 — Planner 핵심 리스크 해소, ADR-0004 불필요 |
|
||||||
|
| HF 토큰 빌드 임베드 | ✓ | 사용자 본인 토큰 입력 UI | v1 은 joungmin 1인 — UX 0 비용, 보안 영향 한정 (read-only) |
|
||||||
|
| 첫 FCR 만 채택 (Thinking 등 skip) | ✓ | 모든 이벤트 누적 후 last FCR | 명확한 종료 시점 + close() 호출 가능. Gemma 4 thinking mode 는 latency 영향 커서 v1 비활성 |
|
||||||
|
| `_collectFunctionCall` 파일-private | ✓ | top-level / 별도 파일 | 캡슐화 (테스트는 `@visibleForTesting`) |
|
||||||
|
| Mock fallback 유지 | ✓ | 옵션 강제 Gemma | graceful — F2/F1 작업 없이도 release 가능 |
|
||||||
|
|
||||||
|
### 핵심 리스크
|
||||||
|
|
||||||
|
- **HF 모델 URL 변경**: Google 이 HF repo path 변경 시 `_kModelUrl` 깨짐. → `meta_kv` 에 마지막 성공 URL 캐싱, 재시도 시 두 후보 (configured + cached) 비교 검토는 v2.
|
||||||
|
- **flutter_gemma breaking change**: 0.17.x 가 FCR API 깨면 우리만 묶임. → pubspec.lock 동결 + 분기마다 release note 모니터링.
|
||||||
|
- **HF account quota**: joungmin token 의 다운로드 제한. → 단일 사용자라 영향 0. 다인 배포 시 v2 토큰 UI.
|
||||||
|
|
||||||
|
### 되돌리기 어려운 결정 → ADR 후보
|
||||||
|
|
||||||
|
- **본 설계서는 신규 ADR 발행 안 함**. ADR-0003 결정 #3 (function calling) 이 그대로 유지됨이 research 로 확정.
|
||||||
|
- E4B 지원 추가 = ADR-0004 후보 (Phase 2-C, 별 이슈).
|
||||||
|
|
||||||
|
## 12. 미해결 질문 (Open Questions)
|
||||||
|
|
||||||
|
| OQ | 질문 | 상태 | 결정 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| **OQ-A** | Gemma 4 E2B 모바일 HF 파일명 + SHA-256? | ✅ 해결 (Developer 2026-06-12) | `litert-community/gemma-4-E2B-it-litert-lm` repo 의 `gemma-4-E2B-it.litertlm` (2,588,147,712 B). SHA256 = `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c`. QAT 1GB 변종은 미공개. |
|
||||||
|
| **OQ-B** | `huggingFaceToken` 빈 문자열 시 throw? | ✅ 해결 | **즉시 throw 안 함** — 다운로드 시점까지 deferred. 빈 토큰은 public model 만 허용. 우리는 `null` 이 더 정확하지만 빈 문자열도 안전. |
|
||||||
|
| **OQ-C** | sampling 파라미터 위치? | ✅ 해결 | **chat-level** — `model.createChat(temperature: 0.4, topK: 40, topP: 0.95)`. `getActiveModel` 은 `maxTokens` / `preferredBackend` / `maxConcurrentSessions` 만. (caveat: NPU backend 는 sampling 무시.) |
|
||||||
|
| **OQ-D** | Android ProGuard rules? | ✅ 해결 | flutter_gemma example app 의 27-line 사본 적용: io.flutter.\*, play.core.\*, mediapipe.\*, protobuf.\*, kotlinx.coroutines.\*. |
|
||||||
|
| **OQ-E** | Gemma ToU 동의 UI? | ✅ 해결 | Google 표준 템플릿 없음. Settings AI 섹션 하단에 "Gemma 이용약관(https://ai.google.dev/gemma/terms)에 동의합니다" 한 줄. 다이얼로그 본문 변경 없음 (변경 최소화). |
|
||||||
|
| **OQ-F** | thinking mode off 스위치? | ✅ 해결 | `model.createChat(isThinking: false)` — **default 가 false** 라 명시 안 해도 무방하나 명시적으로 박는다. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 자가 점검 (Architect 가 작업 종료 시 검증)
|
||||||
|
|
||||||
|
- [x] §1~§12 모든 섹션 채워짐.
|
||||||
|
- [x] #215 와의 통합점 명확: `LlmService` 추상, `kFrameCandidatesSchema`, `meta_kv` 키, UI/도메인 0 변경.
|
||||||
|
- [x] 신규 ADR 발행 안 함 — flutter_gemma 0.16.5 의 FCR 지원이 ADR-0003 결정 #3 을 유지.
|
||||||
|
- [x] 5개 의사결정 (Planner 인계) 모두 반영: ① Gemma 4 E2B 단일 ② Google 호스팅 (HF `litert-community/gemma-4-E2B-it-litert-lm`) ③ flutter_gemma 0.16.5 + Gemma 4 native FCR ④ RAM 4GB 게이트 ⑤ graceful 정책 유지.
|
||||||
|
- [x] 모델 명칭 정정: Planner 가 "Gemma 3n" 으로 명시했으나 검증 결과 **Gemma 4** 가 맞음 (2026-04-02 출시, E2B/E4B 모바일 SKU + native FCR). Planner 의 "3n 정정" 자체가 잘못된 정정이었음. 본 설계서는 Gemma 4 로 정정 반영.
|
||||||
|
- [x] AC 10개 모두 §3 + §10 1:1 매핑.
|
||||||
|
- [x] 복잡 함수 → 1개 `fn-*.md` (`fn-gemma_llm_service.md`).
|
||||||
|
- [x] §12 OQ 5개 모두 Developer 단계 또는 QA 단계로 routing.
|
||||||
|
- [x] graceful degradation 명시: `_hfToken` 누락, sentinel SHA, native exception 모두 mock 경로로 폴백.
|
||||||
|
- [x] 프라이버시: prompt 본문은 log 에 X, HF 토큰 단말 영속 X.
|
||||||
|
- [x] out-of-scope 명확: #219 F1, #220 F2 광범위, #221 AC10, #222 keystore.
|
||||||
378
docs/design/218-gemma-real-integration/fn-gemma_llm_service.md
Normal file
378
docs/design/218-gemma-real-integration/fn-gemma_llm_service.md
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
# 함수 설계서: `GemmaLlmService.load` + `generateStructured` + 보조 (#218)
|
||||||
|
|
||||||
|
> **부모 설계서**: ./README.md · **상태**: Draft (v2)
|
||||||
|
> **작성**: [AI] Architect (2026-06-12) · **구현 대상**: `app/lib/data/ai/gemma_llm_service.dart` · **테스트**: `app/test/data/ai/gemma_llm_service_test.dart`
|
||||||
|
|
||||||
|
## 변경 이력 (v2, 2026-06-12 Developer 검증 후)
|
||||||
|
|
||||||
|
flutter_gemma 0.16.5 의 `InferenceChat` 구현을 직접 읽어 확인한 결과:
|
||||||
|
- Gemma 4 (ModelType.gemma4) 의 function calling 은 **SDK 가 `createChat(tools: [Tool(...)])` 의 tools 목록에서 `<|tool>declaration:...<tool|>` 토큰을 직접 렌더**한다 (`lib/core/chat.dart:94`).
|
||||||
|
- 따라서 §C `_appendSchemaInstruction` 는 Gemma 4 에선 **double-wrap** 을 유발한다. v2 에선 **§C 제거**, §B 는 `Tool` 객체를 `createChat` 에 전달하는 방식으로 변경.
|
||||||
|
- §D `_collectFunctionCall` 는 변경 없음 — 여전히 `Stream<ModelResponse>` 에서 첫 `FunctionCallResponse` 만 추출.
|
||||||
|
|
||||||
|
남은 4 함수 (§A load / §B generateStructured / §C deprecated / §D collectFunctionCall) 중 코드 대상은 3 개.
|
||||||
|
|
||||||
|
이 문서는 `GemmaLlmService` 가 노출하는 2 개 public 메서드 + 2 개 file-private 헬퍼를 한 묶음으로 설계한다. 모두 flutter_gemma 0.16.5 의 native 경계를 다루므로 한 문서에서 다루는 게 응집도 측면에서 옳다.
|
||||||
|
|
||||||
|
| # | 함수 | 가시성 |
|
||||||
|
|---|------|-------|
|
||||||
|
| §A | `GemmaLlmService.load()` | public |
|
||||||
|
| §B | `GemmaLlmService.generateStructured(prompt, schema)` | public |
|
||||||
|
| §C | `_appendSchemaInstruction(prompt, schema)` | file-private (`@visibleForTesting`) |
|
||||||
|
| §D | `_collectFunctionCall(stream, expectedName)` | file-private (`@visibleForTesting`) |
|
||||||
|
|
||||||
|
`unload()` 는 단순 (`await _model?.close(); _loaded = false;`) 이므로 별도 섹션 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §A. `GemmaLlmService.load()`
|
||||||
|
|
||||||
|
### 1. 시그니처
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
Future<void> load();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 책임 (단일 책임, 1줄)
|
||||||
|
디스크의 `modelPath` 모델 파일을 flutter_gemma native runtime 으로 메모리 적재하고 `_loaded = true` 로 표시한다.
|
||||||
|
|
||||||
|
### 3. 입력
|
||||||
|
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||||
|
|----------|------|-----------|------|
|
||||||
|
| (instance field) `modelPath` | `String` | 절대 경로, `File(path).existsSync() == true` 가정 | 생성자에서 주입. `ModelLifecycle` 가 다운로드 + SHA 검증 완료 시점에만 유효 |
|
||||||
|
| (top-level const) `_hfToken` | `String` | `String.fromEnvironment('HF_TOKEN', defaultValue: '')`. 빈 문자열도 허용 (이미 다운로드 완료된 모델은 토큰 불필요할 수 있음) | 빌드 시 `--dart-define=HF_TOKEN=hf_xxx` 으로 주입 |
|
||||||
|
|
||||||
|
### 4. 출력
|
||||||
|
- **반환**: `Future<void>`.
|
||||||
|
- **부수효과**:
|
||||||
|
- `FlutterGemma.initialize(huggingFaceToken: _hfToken)` — top-level `_initialized` 가드로 1회만.
|
||||||
|
- `FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install()` — flutter_gemma 의 active model 슬롯에 모델 등록.
|
||||||
|
- 인스턴스 필드 `_model` 에 `FlutterGemma.getActiveModel(maxTokens: 2048)` 결과 저장.
|
||||||
|
- 인스턴스 필드 `_loaded = true`.
|
||||||
|
|
||||||
|
### 5. 동작 / 알고리즘
|
||||||
|
1. `if (_loaded) return;` — idempotent.
|
||||||
|
2. `if (!await File(modelPath).exists()) throw FileSystemException('model file missing', modelPath);`
|
||||||
|
3. top-level guard: `if (!_initialized) { await FlutterGemma.initialize(huggingFaceToken: _hfToken); _initialized = true; }`
|
||||||
|
4. `await FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install();`
|
||||||
|
5. `_model = await FlutterGemma.getActiveModel(maxTokens: 2048);`
|
||||||
|
6. `_loaded = true;`
|
||||||
|
7. (no `try/catch` here — 모든 예외 caller 에 그대로 전파. `suggestFrame` 의 outer catch 가 graceful 처리)
|
||||||
|
|
||||||
|
### 6. 에러 & 실패 모드
|
||||||
|
| 조건 | 처리 | 반환/예외 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `modelPath` 의 파일 부재 | early throw | `FileSystemException` |
|
||||||
|
| `_hfToken` 빈 문자열인데 flutter_gemma 가 토큰 요구 | flutter_gemma 의 throw 그대로 | `Exception` (OQ-B 에서 정확 타입 확정) |
|
||||||
|
| MediaPipe / LiteRT native OOM | native exception → Dart 변환 | `Exception` / `PlatformException` |
|
||||||
|
| `installModel` 중간에 disk 권한 에러 | flutter_gemma 의 throw 그대로 | `FileSystemException` |
|
||||||
|
| `getActiveModel` 가 `null` (모델 등록 실패) | guard → throw | `StateError('active model missing after install')` |
|
||||||
|
|
||||||
|
### 7. 엣지케이스
|
||||||
|
- **두 번째 호출**: `_loaded == true` → 즉시 return. 같은 `GemmaLlmService` 인스턴스에서 `unload()` 후 `load()` 재호출은 정상 동작 (top-level `_initialized` 는 유지, install 만 재실행).
|
||||||
|
- **다른 인스턴스에서 이미 active model 있음**: flutter_gemma 0.16.5 의 `installModel` 이 active slot 교체 — 우리는 단일 인스턴스 가정이라 무영향.
|
||||||
|
- **modelPath 가 .litertlm 인데 ModelType.gemma4 와 불일치**: 형식 자동 감지 (확장자 기반). 실패 시 throw.
|
||||||
|
- **앱 background → foreground 사이클**: `_model` 핸들 유지. native runtime 이 OS 에 의해 강제 종료된 경우 첫 inference 호출에서 에러 → caller 가 `unload()` + `load()` retry 결정 (v1 은 retry 없음, graceful 빈 리스트).
|
||||||
|
|
||||||
|
### 8. 복잡도 / 성능
|
||||||
|
- **시간**: cold start 1–3 초 (모델 파일 read + native init + tokenizer load). 첫 호출 만, 이후 캐시.
|
||||||
|
- **공간**: peak RAM ≈ 1.5–2GB (Gemma 4 E2B Q4 .litertlm, 가중치 ~1.3GB + KV cache + activation). disk ≈ 2.41GB.
|
||||||
|
- **호출 빈도**: 사용자 1 세션 당 0–1 회 (#219 F1 의 60s idle unload 가 들어오면 다회 가능).
|
||||||
|
|
||||||
|
### 9. 의존성
|
||||||
|
- `package:flutter_gemma/flutter_gemma.dart` (^0.16.5)
|
||||||
|
- `dart:io` (`File`)
|
||||||
|
- `String.fromEnvironment('HF_TOKEN')` (build-time inject)
|
||||||
|
- `ModelLifecycle` (직접 import 안 함 — `_loaded` 보장 책임만 caller 에 위임)
|
||||||
|
|
||||||
|
### 10. 테스트 케이스
|
||||||
|
> flutter_gemma native 직접 호출은 단위 테스트에서 모킹 불가능 (final class 가능성). 본 함수는 **E2E (실 단말, AC-7)** 로만 검증. 단위 테스트는 §C / §D 에 집중.
|
||||||
|
|
||||||
|
- [E2E] `modelPath` 가 실 모델 → `_loaded == true` + 후속 `generateStructured` 1회 성공.
|
||||||
|
- [unit] `modelPath` 가 미존재 파일 → `FileSystemException` (`File.exists()` 만 검증, flutter_gemma 미진입).
|
||||||
|
- [unit] 두 번 호출 → 두 번째는 noop (counter 증가 X).
|
||||||
|
|
||||||
|
### 11. 추적성
|
||||||
|
- 인수조건: #218 AC-1 (build 성공) + AC-6 (cold start 3s 이내) + AC-9 (OOM graceful).
|
||||||
|
- 관련 ADR: ADR-0003 (on-device LLM Gemma, 결정 #2 — E2B 단일).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §B. `GemmaLlmService.generateStructured(prompt, schema)`
|
||||||
|
|
||||||
|
### 1. 시그니처
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> generateStructured(
|
||||||
|
String prompt,
|
||||||
|
Map<String, dynamic> schema,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 책임 (단일 책임, 1줄)
|
||||||
|
loaded 상태의 Gemma 4 모델에 prompt + JSON Schema 를 전달하여 단일 function call 응답 (`args: Map`) 을 받아 반환한다.
|
||||||
|
|
||||||
|
### 3. 입력
|
||||||
|
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||||
|
|----------|------|-----------|------|
|
||||||
|
| `prompt` | `String` | non-empty. caller 가 `.length ≤ 4096` 보장 (#215 buildFewShotPrompt). | 시스템 prompt + few-shot + 사용자 raw text |
|
||||||
|
| `schema` | `Map<String, dynamic>` | `schema['name']: String`, `schema['parameters']: Map` 필수. 본 설계에선 항상 `kFrameCandidatesSchema` 고정. | function calling JSON Schema (#215 §6) |
|
||||||
|
|
||||||
|
### 4. 출력
|
||||||
|
- **반환**: `Future<Map<String, dynamic>>` — `FunctionCallResponse.args` 그대로. `kFrameCandidatesSchema` 기준이면 `{ "candidates": [...] }` 구조.
|
||||||
|
- **부수효과**:
|
||||||
|
- flutter_gemma chat session 1개 생성 후 `chat.close()` 으로 정리.
|
||||||
|
- 모델 latent state 변경 (다음 호출은 fresh chat).
|
||||||
|
- log: prompt length, latency, FCR 수신 여부 (prompt 본문 X — 프라이버시).
|
||||||
|
|
||||||
|
### 5. 동작 / 알고리즘 (v2)
|
||||||
|
```
|
||||||
|
1. if (!_loaded) throw StateError('LlmService not loaded');
|
||||||
|
2. final fnName = schema['name'] as String;
|
||||||
|
3. final fnDesc = (schema['description'] as String?) ?? '';
|
||||||
|
4. final fnParams = schema['parameters'] as Map<String, dynamic>;
|
||||||
|
5. final tool = Tool(name: fnName, description: fnDesc, parameters: fnParams);
|
||||||
|
6. final chat = await _model!.createChat(
|
||||||
|
modelType: ModelType.gemma4,
|
||||||
|
supportsFunctionCalls: true,
|
||||||
|
toolChoice: ToolChoice.required, // 강제 FCR
|
||||||
|
tools: [tool],
|
||||||
|
);
|
||||||
|
7. try {
|
||||||
|
8. await chat.addQueryChunk(Message.text(text: prompt, isUser: true));
|
||||||
|
9. final stream = chat.generateChatResponseAsync();
|
||||||
|
10. final args = await _collectFunctionCall(stream, fnName);
|
||||||
|
11. return args;
|
||||||
|
12. } finally {
|
||||||
|
13. await chat.close(); // 항상 정리
|
||||||
|
14. }
|
||||||
|
```
|
||||||
|
|
||||||
|
caller (#215 `suggestFrame`) 가 `.timeout(Duration(seconds: 10))` 적용 → timeout 시 본 함수의 `await` 가 throw 됨 → finally 의 `chat.close()` 가 실행되어 native session leak 방지.
|
||||||
|
|
||||||
|
### 6. 에러 & 실패 모드
|
||||||
|
| 조건 | 처리 | 반환/예외 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `_loaded == false` | early throw | `StateError` |
|
||||||
|
| `schema['name']` 또는 `schema['parameters']` 누락 | `_appendSchemaInstruction` 가 throw | `ArgumentError` |
|
||||||
|
| stream 이 `FunctionCallResponse` emit 안 함 (Text 만, 또는 empty) | `_collectFunctionCall` 가 throw | `FormatException('no function call emitted')` |
|
||||||
|
| 다른 name 의 FCR | `_collectFunctionCall` 가 throw | `FormatException('unexpected function: ${actualName}')` |
|
||||||
|
| stream 자체 error event | catch → throw (본문은 log 안 함, name 만) | `FormatException('stream error')` |
|
||||||
|
| caller timeout | finally 에서 close, exception 전파 | (caller 의 `TimeoutException`) |
|
||||||
|
| native runtime crash | flutter_gemma 가 PlatformException | 그대로 전파 |
|
||||||
|
|
||||||
|
### 7. 엣지케이스
|
||||||
|
- **첫 token 이 Thinking → Text → FCR 순서**: §D 가 첫 FCR 만 채택, 나머지 skip.
|
||||||
|
- **FCR 두 번 emit**: 첫 번째 채택 후 break — stream 미소진 채 `chat.close()` 호출. flutter_gemma 가 graceful 처리 가정 (OQ).
|
||||||
|
- **`args` 가 `null`**: §D 에서 검사, throw `FormatException('null args')`.
|
||||||
|
- **`args['candidates']` 가 Map 으로 옴 (List 아님)**: 본 함수 책임 밖. caller 의 `parseFrameCandidates` (#215) 가 `FormatException` 으로 처리.
|
||||||
|
- **prompt UTF-8 길이 vs token 길이 불일치**: caller 책임. 본 함수는 prompt 길이 검증 X.
|
||||||
|
|
||||||
|
### 8. 복잡도 / 성능
|
||||||
|
- **시간**: warm 0.5–2초 / cold (load 직후) 추가 1–3초. function calling 1턴이라 stream 길이 짧음 (~200 token).
|
||||||
|
- **공간**: chat 인스턴스 ~ 수십 MB (KV cache). close 시 회수.
|
||||||
|
- **호출 빈도**: habit 생성 화면 진입 시 사용자 trigger. throttle 5회/세션 (#215).
|
||||||
|
|
||||||
|
### 9. 의존성
|
||||||
|
- `flutter_gemma`: `FlutterGemma.getActiveModel` 결과의 `createChat` / `Message.text` / `ModelResponse`.
|
||||||
|
- `_appendSchemaInstruction` (§C)
|
||||||
|
- `_collectFunctionCall` (§D)
|
||||||
|
|
||||||
|
### 10. 테스트 케이스
|
||||||
|
- [unit] `_loaded = false` → `StateError`. (직접 검증)
|
||||||
|
- [E2E] AC-7 — 실 단말에서 prompt + `kFrameCandidatesSchema` → `args['candidates']` 3개 반환.
|
||||||
|
- [unit] caller timeout 시 finally close 호출 확인 — 간접 (`_collectFunctionCall` 가 await never-completing future 일 때 외부 timeout → exception 후 chat.close mock 카운터).
|
||||||
|
|
||||||
|
### 11. 추적성
|
||||||
|
- 인수조건: #218 AC-6 (latency), AC-7 (E2E candidates), AC-9 (graceful).
|
||||||
|
- 관련 ADR: ADR-0003 결정 #3 (function calling).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §C. (DEPRECATED — v2) `_appendSchemaInstruction(prompt, schema)`
|
||||||
|
|
||||||
|
> **v2 결정**: Gemma 4 SDK 가 `Tool` 객체에서 직접 declaration 토큰을 렌더하므로, prompt 측에서 schema 안내문을 덧붙이면 double-wrap 이 된다. **본 함수는 구현하지 않는다.**
|
||||||
|
>
|
||||||
|
> 아래 §C 본문은 v1 (gemmaIt fallback) 시나리오용 참고 자료로 보존하나, v2 코드 대상에서 제외한다. 단위 테스트도 작성하지 않는다.
|
||||||
|
|
||||||
|
원본 본문 (참고용):
|
||||||
|
|
||||||
|
### 1. 시그니처
|
||||||
|
```dart
|
||||||
|
@visibleForTesting
|
||||||
|
String appendSchemaInstruction(String prompt, Map<String, dynamic> schema);
|
||||||
|
```
|
||||||
|
> 파일 내부에선 `_appendSchemaInstruction` 으로 호출, 테스트는 public `appendSchemaInstruction` 으로 re-export.
|
||||||
|
|
||||||
|
### 2. 책임 (단일 책임, 1줄)
|
||||||
|
prompt 본문 끝에 Gemma 4 chat template 이 인식할 function call 안내 (name + JSON Schema) 를 append 한다.
|
||||||
|
|
||||||
|
### 3. 입력
|
||||||
|
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||||
|
|----------|------|-----------|------|
|
||||||
|
| `prompt` | `String` | non-empty | 시스템 + few-shot + 사용자 입력 |
|
||||||
|
| `schema` | `Map<String, dynamic>` | `schema['name']: String`, `schema['parameters']: Map` 필수 | function calling schema |
|
||||||
|
|
||||||
|
### 4. 출력
|
||||||
|
- **반환**: `String` — `prompt + '\n\n' + 안내문` 형태.
|
||||||
|
- **부수효과**: **순수 함수**.
|
||||||
|
|
||||||
|
### 5. 동작 / 알고리즘
|
||||||
|
```
|
||||||
|
1. final name = schema['name'];
|
||||||
|
2. if (name is! String || name.isEmpty) throw ArgumentError('schema.name missing');
|
||||||
|
3. final params = schema['parameters'];
|
||||||
|
4. if (params is! Map) throw ArgumentError('schema.parameters missing');
|
||||||
|
5. final description = schema['description'] as String? ?? '';
|
||||||
|
6. final paramsJson = const JsonEncoder().convert(params);
|
||||||
|
7. final block = [
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'## Function call instruction',
|
||||||
|
'You MUST respond by calling the function `$name`.',
|
||||||
|
if (description.isNotEmpty) description,
|
||||||
|
'Arguments must conform to this JSON Schema:',
|
||||||
|
'```json',
|
||||||
|
paramsJson,
|
||||||
|
'```',
|
||||||
|
].join('\n');
|
||||||
|
8. return prompt + block;
|
||||||
|
```
|
||||||
|
|
||||||
|
순수 함수라 deterministic. 같은 입력에 대해 항상 같은 출력.
|
||||||
|
|
||||||
|
### 6. 에러 & 실패 모드
|
||||||
|
| 조건 | 처리 | 반환/예외 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `schema['name']` 누락/빈 문자열 | throw | `ArgumentError('schema.name missing')` |
|
||||||
|
| `schema['parameters']` 가 Map 아님 | throw | `ArgumentError('schema.parameters missing')` |
|
||||||
|
| `prompt` 가 빈 문자열 | 허용 (append 만) | OK |
|
||||||
|
|
||||||
|
### 7. 엣지케이스
|
||||||
|
- `params` 가 빈 Map → `{}` JSON 으로 직렬화. caller 가 의도한 경우면 OK (본 설계엔 발생 안 함).
|
||||||
|
- `description` 누락 → 해당 라인 생략.
|
||||||
|
- prompt 끝에 이미 `\n\n` 있음 → 결과 `\n\n\n\n`. Gemma 4 tokenizer 가 무시.
|
||||||
|
|
||||||
|
### 8. 복잡도 / 성능
|
||||||
|
- O(N) — `JsonEncoder` 가 schema 깊이에 비례. `kFrameCandidatesSchema` 는 작아서 < 1ms.
|
||||||
|
|
||||||
|
### 9. 의존성
|
||||||
|
- `dart:convert` (`JsonEncoder`).
|
||||||
|
- `package:flutter/foundation.dart` (`@visibleForTesting`).
|
||||||
|
|
||||||
|
### 10. 테스트 케이스
|
||||||
|
- [unit] `kFrameCandidatesSchema` 입력 → 반환 string 에 `'emit_frame_candidates'` 와 `'\"L2\"' / '\"L3\"'` 포함.
|
||||||
|
- [unit] `schema['name']` 없음 → `ArgumentError`.
|
||||||
|
- [unit] `schema['parameters']` 가 `List` → `ArgumentError`.
|
||||||
|
- [unit] 같은 입력 2회 호출 → 동일 string (순수성 검증).
|
||||||
|
- [unit] `prompt` 끝 trim 없이 그대로 append 되는지 — exact string compare.
|
||||||
|
|
||||||
|
### 11. 추적성
|
||||||
|
- 인수조건: #218 AC-7 (모델이 FCR 로 응답하려면 안내문이 필요).
|
||||||
|
- 관련 ADR: ADR-0003 결정 #3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §D. `_collectFunctionCall(stream, expectedName)`
|
||||||
|
|
||||||
|
### 1. 시그니처
|
||||||
|
```dart
|
||||||
|
@visibleForTesting
|
||||||
|
Future<Map<String, dynamic>> collectFunctionCall(
|
||||||
|
Stream<ModelResponse> stream,
|
||||||
|
String expectedName,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 책임 (단일 책임, 1줄)
|
||||||
|
`Stream<ModelResponse>` 에서 **첫 `FunctionCallResponse(name == expectedName)`** 의 `args` 만 추출한다. `TextResponse`/`ThinkingResponse` 는 skip, 잘못된 name 은 throw.
|
||||||
|
|
||||||
|
### 3. 입력
|
||||||
|
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||||
|
|----------|------|-----------|------|
|
||||||
|
| `stream` | `Stream<ModelResponse>` | flutter_gemma 의 union 타입. `TextResponse \| FunctionCallResponse \| ThinkingResponse` 가정 | `chat.generateChatResponseAsync()` 결과 |
|
||||||
|
| `expectedName` | `String` | non-empty | 매칭할 function name (e.g. `'emit_frame_candidates'`) |
|
||||||
|
|
||||||
|
### 4. 출력
|
||||||
|
- **반환**: `Future<Map<String, dynamic>>` — 첫 매칭 FCR 의 `args`.
|
||||||
|
- **부수효과**: 없음 — stream 의 첫 매칭 이벤트 후 `await for` 빠져나옴 (cancelSubscription 자동).
|
||||||
|
|
||||||
|
### 5. 동작 / 알고리즘
|
||||||
|
```
|
||||||
|
1. Map<String, dynamic>? result;
|
||||||
|
2. String? wrongName;
|
||||||
|
3. try {
|
||||||
|
4. await for (final event in stream) {
|
||||||
|
5. if (event is FunctionCallResponse) {
|
||||||
|
6. if (event.name == expectedName) {
|
||||||
|
7. result = Map<String, dynamic>.from(event.args ?? const {});
|
||||||
|
8. break;
|
||||||
|
9. } else {
|
||||||
|
10. wrongName = event.name;
|
||||||
|
11. break; // 잘못된 함수 — 빠른 실패
|
||||||
|
12. }
|
||||||
|
13. }
|
||||||
|
14. // TextResponse / ThinkingResponse 는 무시 (continue)
|
||||||
|
15. }
|
||||||
|
16. } catch (e) {
|
||||||
|
17. throw FormatException('stream error'); // e.toString() 폐기 (prompt 누설 방지)
|
||||||
|
18. }
|
||||||
|
19. if (wrongName != null) {
|
||||||
|
20. throw FormatException('unexpected function: $wrongName');
|
||||||
|
21. }
|
||||||
|
22. if (result == null) {
|
||||||
|
23. throw FormatException('no function call emitted');
|
||||||
|
24. }
|
||||||
|
25. return result;
|
||||||
|
```
|
||||||
|
|
||||||
|
`event.args` 가 `null` 이면 빈 Map 으로 대체 → caller 의 `parseFrameCandidates` 가 빈 `candidates` 로 처리하여 빈 리스트 반환.
|
||||||
|
|
||||||
|
### 6. 에러 & 실패 모드
|
||||||
|
| 조건 | 처리 | 반환/예외 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| stream done 까지 FCR 없음 | check after loop | `FormatException('no function call emitted')` |
|
||||||
|
| 다른 name 의 FCR | break + check | `FormatException('unexpected function: ...')` |
|
||||||
|
| stream error event (native crash 등) | catch | `FormatException('stream error')` (원본 e 폐기 — 본문 누설 X) |
|
||||||
|
| `event.args == null` | 빈 Map 으로 대체 후 return | (no throw) |
|
||||||
|
|
||||||
|
### 7. 엣지케이스
|
||||||
|
- **첫 이벤트가 곧바로 FCR**: 정상. Text/Thinking 없이 바로 break.
|
||||||
|
- **Text + Text + FCR + FCR (두 번째 FCR 이 정답 name)**: 첫 FCR 의 name 검증으로 break — `wrongName` 으로 throw. v1 정책: 첫 FCR 만 신뢰. (Gemma 4 가 다중 FCR 보내는 경우 거의 없음. 발생 시 prompt 개선 신호.)
|
||||||
|
- **Thinking → FCR 순서**: Thinking skip 후 FCR 채택. OK.
|
||||||
|
- **stream 이 무한 (timeout 없음)**: caller 의 `.timeout(10s)` 에 의존. 본 함수는 자체 timeout X.
|
||||||
|
- **event 가 `null`** (Dart stream 에 null event): `await for` 에서 false-match → skip. (실제로는 발생 안 함, 방어 안 함.)
|
||||||
|
|
||||||
|
### 8. 복잡도 / 성능
|
||||||
|
- O(N) — N = stream 이벤트 수. function calling 응답은 보통 ≤ 10 events. ~수십 ms.
|
||||||
|
|
||||||
|
### 9. 의존성
|
||||||
|
- `package:flutter_gemma/flutter_gemma.dart` — `ModelResponse` / `FunctionCallResponse` / `TextResponse` / `ThinkingResponse` 타입.
|
||||||
|
|
||||||
|
### 10. 테스트 케이스
|
||||||
|
> 핵심 단위 테스트 슬롯. flutter_gemma response 클래스를 `Stream.fromIterable([...])` 로 fake 주입 가능.
|
||||||
|
|
||||||
|
- [unit] `[FunctionCallResponse('emit_frame_candidates', {'candidates': [...3개...]})]` → `args` 반환.
|
||||||
|
- [unit] `[TextResponse('hello'), FunctionCallResponse('emit_frame_candidates', {...})]` → Text skip 후 args 반환.
|
||||||
|
- [unit] `[ThinkingResponse('...'), TextResponse('...'), FunctionCallResponse('emit_frame_candidates', {})]` → 빈 args Map 반환 (no throw).
|
||||||
|
- [unit] `[FunctionCallResponse('wrong_name', {})]` → `FormatException('unexpected function: wrong_name')`.
|
||||||
|
- [unit] `[TextResponse('only text')]` (FCR 없이 done) → `FormatException('no function call emitted')`.
|
||||||
|
- [unit] `Stream.error(...)` event → `FormatException('stream error')` (원본 메시지 미포함 검증).
|
||||||
|
- [unit] `[FunctionCallResponse('emit_frame_candidates', null)]` → 빈 Map 반환 (`{}`), no throw.
|
||||||
|
- [unit] `[]` 빈 stream → `FormatException('no function call emitted')`.
|
||||||
|
|
||||||
|
### 11. 추적성
|
||||||
|
- 인수조건: #218 AC-7 (FCR 수집 성공), AC-9 (graceful — `FormatException` 이 caller 의 빈 리스트 반환으로 전환).
|
||||||
|
- 관련 ADR: ADR-0003 결정 #3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 자가 점검
|
||||||
|
|
||||||
|
- [x] §A~§D 4 함수 모두 시그니처 + 책임 + 에러 + 테스트 케이스 채움.
|
||||||
|
- [x] §D 테스트 케이스 8개 — flutter_gemma 의 ModelResponse 변종 (Text/Thinking/FCR/error/empty) 모두 커버.
|
||||||
|
- [x] §C 순수성 강조 — `_appendSchemaInstruction` 은 외부 I/O 0, deterministic.
|
||||||
|
- [x] 프라이버시: §B 와 §D 모두 catch 시 `e.toString()` 폐기 (prompt 본문 누설 방지).
|
||||||
|
- [x] timeout 책임 분리: 본 모듈은 timeout 미구현, caller 의 `.timeout(10s)` 에 의존. finally close 로 native session leak 방지.
|
||||||
|
- [x] `@visibleForTesting` 으로 file-private 함수도 단위 테스트 가능.
|
||||||
|
- [x] AC-7 의 E2E 부분은 §A `load` + §B `generateStructured` 에서만 검증, §C/§D 는 단위 테스트로 100% 커버.
|
||||||
96
docs/guides/ai-help-onboarding.md
Normal file
96
docs/guides/ai-help-onboarding.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# AI 도움 켜기·끄기 (사용자 가이드)
|
||||||
|
|
||||||
|
> 적용 버전: **v0.2.0 이상** · Redmine #215 · 관련 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../reference/215-ai-frame-suggest.md)
|
||||||
|
|
||||||
|
life-helper 는 사용자가 입력한 자유 문장(예: "술 끊고 싶어")을 Huberman 프로토콜 기반 L2/L3 프레임 문장으로 변환해주는 **단말 내 AI 보조**를 제공합니다. 모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다.
|
||||||
|
|
||||||
|
## 누구를 위한 가이드인가
|
||||||
|
|
||||||
|
- 새 습관을 추가할 때 "어떻게 표현하면 좋을지" 막막한 사용자.
|
||||||
|
- AI 기능을 켜기 전에 데이터/저장공간/배터리 영향을 미리 확인하고 싶은 사용자.
|
||||||
|
|
||||||
|
## 핵심 원칙
|
||||||
|
|
||||||
|
1. **기본 OFF.** AI 기능은 사용자가 명시적으로 켜야 동작합니다.
|
||||||
|
2. **단말 처리.** 입력 텍스트는 단말 밖으로 나가지 않습니다.
|
||||||
|
3. **수동 입력 100% 유지.** AI 가 꺼져 있거나 모델 다운로드가 안 되어 있어도, "프레임 문구" 입력란에 직접 작성하는 경로는 항상 살아있습니다.
|
||||||
|
|
||||||
|
## AI 도움 켜기
|
||||||
|
|
||||||
|
1. 하단 탭에서 **설정** 진입.
|
||||||
|
2. "AI 도움 켜기" 토글 탭.
|
||||||
|
3. 동의 다이얼로그가 뜹니다:
|
||||||
|
- **파일 크기 ≈ 2.4GB** (Gemma 4 E2B Q4_0 모델 — 단말에 한 번만 다운로드)
|
||||||
|
- **WiFi 연결 권장** (셀룰러 대역폭 절약)
|
||||||
|
- 모든 처리는 단말 — 입력 텍스트 외부 송출 없음
|
||||||
|
4. **"동의하고 다운로드"** 탭 → 백그라운드 다운로드 시작.
|
||||||
|
|
||||||
|
### 다운로드 진행 화면
|
||||||
|
|
||||||
|
설정 화면 "AI 도움" 섹션 아래에 진행 상태가 표시됩니다:
|
||||||
|
|
||||||
|
| 상태 | 표시 | 가능한 조작 |
|
||||||
|
|---|---|---|
|
||||||
|
| 다운로드 중 | 진행률 % + 받은 용량 | **일시정지** 버튼 |
|
||||||
|
| 일시정지됨 | 마지막 진행률 | **재개** 버튼 (이어받기) |
|
||||||
|
| 실패 | 한국어 안내 메시지 (네트워크/서버/손상별) | **다시 시도** 버튼 |
|
||||||
|
| 준비 완료 | "준비 완료" 라벨 + 받은 용량 | (조작 없음 — 사용 가능) |
|
||||||
|
|
||||||
|
다운로드 도중 앱을 강제 종료해도 다음 실행 시 같은 자리에서 이어받습니다 (HTTP Range 기반).
|
||||||
|
|
||||||
|
### 다운로드 실패 시 한국어 안내
|
||||||
|
|
||||||
|
| 화면 메시지 | 의미 | 권장 조치 |
|
||||||
|
|---|---|---|
|
||||||
|
| "네트워크 연결을 확인하고 다시 시도해주세요." | 일시적 끊김 | WiFi 확인 후 [다시 시도] |
|
||||||
|
| "서버 응답이 올바르지 않습니다. 잠시 후 다시 시도해주세요." | 서버 측 문제 | 시간 두고 [다시 시도] |
|
||||||
|
| "다운로드가 중단되었어요. 다시 시도하면 이어받습니다." | 스트림 중단 | [다시 시도] — 받은 데이터는 보존 |
|
||||||
|
| "파일이 손상되었어요. 다시 시도하면 처음부터 받습니다." | 무결성 검증 실패 | [다시 시도] — 자동으로 처음부터 |
|
||||||
|
|
||||||
|
## AI 사용하기
|
||||||
|
|
||||||
|
1. **새 습관** 화면 진입.
|
||||||
|
2. "제목" 에 자유 문장 입력 (예: "술 끊고 싶어").
|
||||||
|
3. "프레임 문구" 입력란 아래 **✨ AI 제안** 버튼 탭.
|
||||||
|
- AI 도움이 꺼져 있으면 버튼이 보이지 않습니다.
|
||||||
|
- AI 도움은 켜졌지만 모델 다운로드가 아직 완료되지 않았다면 버튼은 비활성 상태로 보이고 "AI 도움을 먼저 켜주세요" 툴팁이 표시됩니다.
|
||||||
|
4. 다이얼로그에 후보가 **최대 3개** 표시됩니다 (L2 조건부 긍정 2개 + L3 정체성 1개 권장).
|
||||||
|
5. 마음에 드는 후보 카드 탭 → "프레임 문구" 입력란이 자동으로 채워지고 프레임 레벨이 자동 선택됩니다.
|
||||||
|
6. 저장.
|
||||||
|
|
||||||
|
### 후보가 없거나 마음에 안 들 때
|
||||||
|
|
||||||
|
- "더 구체적으로 입력해주시면 더 좋은 제안을 드릴 수 있어요" 메시지가 보이면 제목을 더 명확히 작성한 뒤 [다시 시도].
|
||||||
|
- 제안을 받지 못해도 **프레임 문구를 직접 입력하셔도 괜찮습니다**.
|
||||||
|
|
||||||
|
## AI 도움 끄기
|
||||||
|
|
||||||
|
1. **설정** → "AI 도움 켜기" 토글 OFF.
|
||||||
|
2. 확인 다이얼로그:
|
||||||
|
- 모델 파일이 단말에서 **즉시 삭제** 됩니다.
|
||||||
|
- 약 2.4GB 의 저장공간이 확보됩니다.
|
||||||
|
- 다시 켜면 다시 다운로드해야 합니다.
|
||||||
|
3. **"끄고 삭제"** 탭 → "공간 확보됨 2469 MB" 토스트.
|
||||||
|
|
||||||
|
진행 중인 다운로드가 있어도 깔끔히 중단되고, `.tmp` 임시 파일까지 함께 삭제됩니다.
|
||||||
|
|
||||||
|
## 자주 묻는 질문
|
||||||
|
|
||||||
|
**Q. 입력 텍스트가 외부로 나가나요?**
|
||||||
|
A. 아니요. 단말 내 추론만 사용합니다. 다운로드는 모델 파일을 받을 때 한 번만 발생합니다.
|
||||||
|
|
||||||
|
**Q. AI 가 만들어준 문장이 마음에 안 들면?**
|
||||||
|
A. 직접 입력란을 고쳐 쓰면 됩니다. AI 제안은 채우기 도우미일 뿐, 저장 시점 검증(L0/L1 금지 등)은 변하지 않습니다.
|
||||||
|
|
||||||
|
**Q. 모델 파일이 너무 큽니다.**
|
||||||
|
A. 언제든 끌 수 있고, 끄면 즉시 삭제됩니다. 다시 켜면 다시 받아야 한다는 점만 유의하세요.
|
||||||
|
|
||||||
|
**Q. v0.2.0 에서 다운로드가 항상 실패합니다.**
|
||||||
|
A. v0.2.0 은 모델 URL 이 미확정 (OQ-1) 인 상태로 출시되어, 실제 다운로드는 의도된 graceful 실패 경로로 안내됩니다. **v0.3.0 부터 실 Gemma 4 E2B 모델 (HuggingFace) 다운로드가 활성화되었습니다.** 그동안 수동 입력 경로는 정상 동작합니다.
|
||||||
|
|
||||||
|
## 관련 문서
|
||||||
|
|
||||||
|
- 설계서: [docs/design/215-gemma-frame-suggest/](../design/215-gemma-frame-suggest/)
|
||||||
|
- 결정 기록: [docs/adr/0003-on-device-llm-gemma.md](../adr/0003-on-device-llm-gemma.md)
|
||||||
|
- API 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../reference/215-ai-frame-suggest.md)
|
||||||
|
- 변경 이력: [CHANGELOG.md](../../CHANGELOG.md)
|
||||||
193
docs/reference/215-ai-frame-suggest.md
Normal file
193
docs/reference/215-ai-frame-suggest.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Reference: AI 프레임 제안 (#215 + #218, v0.3.0-dev)
|
||||||
|
|
||||||
|
> **상태**: 구현 후 동기화 · **추적성** — 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.3.0-dev 시점의 **실제 코드 사양**이다. v0.2.0 의 placeholder 상태는 #218 에서 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합으로 대체됨. 설계 의도/대안은 설계서·ADR 을 참조.
|
||||||
|
|
||||||
|
## 1. 모듈 지도
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
data/ai/
|
||||||
|
llm_service.dart — LlmService 추상 + MockLlmService
|
||||||
|
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)
|
||||||
|
suggest_frame.dart — suggestFrame() 메인 함수 + L2:2+L3:1 분포
|
||||||
|
few_shot_builder.dart — buildFewShotPrompt()
|
||||||
|
parse_response.dart — parseFrameCandidates()
|
||||||
|
state/
|
||||||
|
ai_providers.dart — Riverpod providers + ModelDownloadController
|
||||||
|
ui/
|
||||||
|
screens/settings_screen.dart — AI 도움 토글 + 다운로드 진행률
|
||||||
|
widgets/frame_suggestion_dialog.dart — 후보 카드 선택
|
||||||
|
screens/habit_create_screen.dart — _AiSuggestButton (3분기)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 도메인 모델
|
||||||
|
|
||||||
|
### `FrameCandidate` (`lib/domain/ai/frame_candidate.dart`)
|
||||||
|
|
||||||
|
| 필드 | 타입 | 의미 |
|
||||||
|
|---|---|---|
|
||||||
|
| `level` | `FrameLevel` | `l0` / `l1` / `l2` / `l3` (출력에는 L2/L3 만 살아남음) |
|
||||||
|
| `framedText` | `String` | 모델이 생성한 한국어 문장 (≤120자) |
|
||||||
|
| `confidence` | `double` | 0.0~1.0 (모델이 반환한 score, 없으면 0.5) — UI 표시 X |
|
||||||
|
| `sourcePatternId` | `String?` | few-shot 매칭에 쓰인 `FramePattern.id` |
|
||||||
|
|
||||||
|
### Function-calling 스키마 (`kFrameCandidatesSchema`)
|
||||||
|
|
||||||
|
`suggest_frame.dart` 상단의 `const Map<String, dynamic>`. `emit_frame_candidates` 함수의 parameters. `minItems:1 / maxItems:3`, 각 `item.required = ['level','framed_text']`.
|
||||||
|
|
||||||
|
## 3. 핵심 함수
|
||||||
|
|
||||||
|
### `suggestFrame(input, {llm, framePatterns, timeout=10s}) → List<FrameCandidate>`
|
||||||
|
|
||||||
|
순수에 가까움 (`llm` + `framePatterns` 만 의존). **절대 throw 하지 않음**. 모든 실패 → `const []`.
|
||||||
|
|
||||||
|
흐름:
|
||||||
|
1. `input.rawText.trim()` 길이 검사 (1~200자). 벗어나면 빈 리스트.
|
||||||
|
2. `buildFewShotPrompt(input, framePatterns)` 로 prompt 조립.
|
||||||
|
3. `llm.generateStructured(prompt, schema).timeout(10s)` 호출. 어떤 예외든 catch → 빈 리스트.
|
||||||
|
4. `parseFrameCandidates(json)` 으로 디코드. `FormatException` catch → 빈 리스트.
|
||||||
|
5. 각 후보에 `validateFrameLevel` 적용. `reject` 인 후보만 드랍.
|
||||||
|
6. `_shapeDistribution(validated, l2Quota:2, l3Quota:1)` — L2 먼저 최대 2개 + L3 최대 1개. **부족 시 패딩 X** (graceful — 적은 카드).
|
||||||
|
|
||||||
|
### `buildFewShotPrompt(input, framePatterns, {maxFewShot=5}) → String`
|
||||||
|
|
||||||
|
순수. `_tokenize` (whitespace + 한국어 punctuation 분리) → `_scorePattern` (avoidanceKeyword 3점 + domain 1점) → 상위 N개. 매칭 0 이면 시드 앞에서 N개. patterns 비어있으면 시스템 prompt 만 emit.
|
||||||
|
|
||||||
|
마지막에 명시 지시: `"L2 2개 + L3 1개, 총 3개 후보로 변환. 순서는 L2 → L2 → L3. emit_frame_candidates 함수로 호출."`
|
||||||
|
|
||||||
|
### `parseFrameCandidates(json) → List<FrameCandidate>`
|
||||||
|
|
||||||
|
- 최상위 `candidates` 없거나 `List` 아니면 `throw FormatException`.
|
||||||
|
- 개별 item 결손 (level/framed_text 미존재, 빈 문자열, >120자) → 조용히 skip.
|
||||||
|
- `level` 은 대소문자 무시 매칭.
|
||||||
|
- `confidence` 결손 시 0.5 기본값, 범위 밖이면 `clamp(0, 1)`.
|
||||||
|
|
||||||
|
## 4. 데이터 계층
|
||||||
|
|
||||||
|
### `LlmService` (abstract)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
abstract class LlmService {
|
||||||
|
bool get isLoaded;
|
||||||
|
Future<void> load();
|
||||||
|
Future<void> unload(); // idempotent
|
||||||
|
Future<Map<String, dynamic>> generateStructured(String prompt, Map schema);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
계약:
|
||||||
|
- `load` 후 `isLoaded == true`.
|
||||||
|
- 미로드 상태에서 `generateStructured` 호출 → `StateError`.
|
||||||
|
- 스키마/응답 깨짐 → `FormatException`.
|
||||||
|
- timeout 은 **호출자 책임** (`suggestFrame` 가 10s 적용).
|
||||||
|
|
||||||
|
구현 2개:
|
||||||
|
- `MockLlmService` — `enqueueResponse(Map)` / `enqueueError(Object)`. `callCount`, `lastPrompt`, `lastSchema`, `responseDelay` 노출. **v1 런타임 기본 주입**.
|
||||||
|
- `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`)
|
||||||
|
|
||||||
|
생성자 의존성: `MetaDao meta`, `ModelConfig config`, `StorageAdapter? storage`, `http.Client? httpClient`.
|
||||||
|
|
||||||
|
| 메서드 | 시그니처 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `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` 제외) 삭제. F2 hardening (#218): `File.delete()` / `temp.delete()` / `meta.remove()` 각각 try/catch 로 감쌈 → 단일 OS 플레이크가 opt-out 을 막지 않음. freed-bytes 는 성공한 삭제만 합산. |
|
||||||
|
|
||||||
|
`StorageAdapter` 는 `supportDir()` + `rangeGet(Uri, int)` 2개 메서드만 — 테스트가 `_FakeStorage` 로 주입.
|
||||||
|
|
||||||
|
### meta_kv 키 5개 (`AiMetaKeys`)
|
||||||
|
|
||||||
|
| 키 | 값 | 의미 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ai_opt_in` | `'true'` / `'false'` | 사용자 옵트인 |
|
||||||
|
| `ai_model_path` | 절대경로 | 다운로드 완료 시 |
|
||||||
|
| `ai_model_sha256` | hex string | 검증 통과 시 |
|
||||||
|
| `ai_download_state` | `'idle'` / `'downloading'` / `'paused'` / `'completed'` / `'failed'` | 진행 상태 |
|
||||||
|
| `ai_download_bytes` | int as string | 재시작 시 resume 좌표 |
|
||||||
|
|
||||||
|
→ Drift schema 변경 0. `meta_kv` 테이블은 #204 에서 이미 존재.
|
||||||
|
|
||||||
|
## 5. 상태 계층 (Riverpod, `lib/state/ai_providers.dart`)
|
||||||
|
|
||||||
|
| Provider | 타입 | 책임 |
|
||||||
|
|---|---|---|
|
||||||
|
| `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 |
|
||||||
|
| `modelAvailabilityProvider` | `FutureProvider<ModelAvailability>` | `lifecycle.checkAvailability()` |
|
||||||
|
| `framePatternsProvider` | `FutureProvider<List<FramePatternModel>>` | Drift → 도메인 |
|
||||||
|
| `llmServiceProvider` | `Provider<LlmService>` | **반드시 override** — `main.dart` 가 `MockLlmService` 주입 |
|
||||||
|
| `frameSuggestionsProvider` | `FutureProvider.autoDispose.family<List<FrameCandidate>, SuggestFrameInput>` | `llm.load` (실패 시 빈 리스트) → `suggestFrame` |
|
||||||
|
|
||||||
|
### `AiSettingsController.setOptIn(value)`
|
||||||
|
|
||||||
|
- `value=true`: `meta_kv['ai_opt_in']='true'` → invalidate(settings, availability) → `ModelDownloadController.start()` 호출 (AC2 — 다운로드 스트림 시작).
|
||||||
|
- `value=false`: `ModelDownloadController.cancel()` → `ModelLifecycle.purge()` → `meta_kv['ai_opt_in']='false'` → invalidate. 반환: 해제된 byte 수.
|
||||||
|
|
||||||
|
### `ModelDownloadController`
|
||||||
|
|
||||||
|
- `start()`: 기존 subscription cancel 후 `lifecycle.download().listen(...)`. 완료 시 `modelAvailabilityProvider` invalidate.
|
||||||
|
- `pause()`: subscription cancel + state 를 `paused` 로. `.tmp` 파일 + meta_kv 보존 → 다음 `start()` 가 Range 로 resume.
|
||||||
|
- `resume()` = `start()` alias.
|
||||||
|
- `cancel()`: subscription cancel + state = `null` (idle).
|
||||||
|
|
||||||
|
## 6. UI 계층
|
||||||
|
|
||||||
|
### `SettingsScreen` (`lib/ui/screens/settings_screen.dart`)
|
||||||
|
|
||||||
|
- `SwitchListTile` — 토글 시 옵트인은 동의 다이얼로그 (저장공간/WiFi/단말 처리 3개 bullet) → `setOptIn(true)`. 옵트아웃은 확인 다이얼로그 → `setOptIn(false)` → "공간 확보됨 X.X MB" 토스트.
|
||||||
|
- `_DownloadProgressTile` — 옵트인 ON + 진행 중일 때만 표시. 상태별 컬러 라벨 + 둥근 `LinearProgressIndicator(minHeight:6)` + `FilledButton.tonalIcon` 재개/재시도. `_friendlyError()` 가 내부 코드를 한국어로 매핑:
|
||||||
|
- `network:*` → "네트워크 연결을 확인하고 다시 시도해주세요."
|
||||||
|
- `http *` → "서버 응답이 올바르지 않습니다."
|
||||||
|
- `stream:*` → "다운로드가 중단되었어요. 다시 시도하면 이어받습니다."
|
||||||
|
- `sha mismatch` → "파일이 손상되었어요. 다시 시도하면 처음부터 받습니다."
|
||||||
|
|
||||||
|
### `_AiSuggestButton` (3분기, AC6)
|
||||||
|
|
||||||
|
| optIn | availability | 렌더 |
|
||||||
|
|---|---|---|
|
||||||
|
| false | * | `SizedBox.shrink()` (숨김) |
|
||||||
|
| true | `!= ready` | `TextButton` (disabled) + `Tooltip("AI 도움을 먼저 켜주세요")` |
|
||||||
|
| true | `ready` | `TextButton` (enabled, tap → `FrameSuggestionDialog.show`) |
|
||||||
|
|
||||||
|
### `FrameSuggestionDialog`
|
||||||
|
|
||||||
|
`AlertDialog` 안에 `frameSuggestionsProvider(input).when(loading/error/data)`. data 가 empty 면 "더 구체적으로 입력해주시면 더 좋은 제안을 드릴 수 있어요" + "다시 시도" 버튼. data 가 있으면 `_CandidateCard` 리스트 — L3 는 `scheme.primary` 배지, L2 는 `scheme.secondary` 배지. 탭 시 `Navigator.pop(c)` 로 `FrameCandidate` 반환.
|
||||||
|
|
||||||
|
## 7. 테스트 매핑
|
||||||
|
|
||||||
|
| AC | 테스트 파일 | 케이스 수 |
|
||||||
|
|---|---|---|
|
||||||
|
| AC1 | `flutter analyze` + `flutter build apk --debug/release` | CI |
|
||||||
|
| AC2 | `test/state/model_download_controller_test.dart` | 3 |
|
||||||
|
| AC3, AC8 | `test/data/ai/model_lifecycle_test.dart` | 7 |
|
||||||
|
| AC4 | `test/domain/ai/suggest_frame_test.dart` (분포 3) | 3 |
|
||||||
|
| AC5 | `test/domain/ai/suggest_frame_test.dart` (FrameLevel 사용) | 1 |
|
||||||
|
| 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 | (별도 이슈 #221 — corpus 품질 평가) | — |
|
||||||
|
| #218 AC8 | `test/data/ai/gemma_llm_service_test.dart` (`collectFunctionCall` 8 + ParallelFCR 2) | 10 |
|
||||||
|
|
||||||
|
신규 합계 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. 알려진 제약
|
||||||
|
|
||||||
|
- **#218 AC-6 (디바이스 게이트)**: 저사양 단말 (RAM < 4GB) 에서는 SettingsScreen 의 AI 토글 disabled + "이 단말에서는 AI 도움을 사용할 수 없어요 (RAM 4GB 이상 필요)" 안내. RAM 조회 = MethodChannel `life_helper/device_caps` → `MainActivity.kt` 에서 `ActivityManager.MemoryInfo.totalMem`. `device_info_plus` 도 deps 에 있지만 RAM 임계 (4GB) 측정엔 미사용 (해당 패키지는 `isLowRamDevice` ≈ 1GB 만 제공). iOS 는 #218 범위 밖.
|
||||||
|
- **#218 AC-7 (실 단말 E2E)**: cold-start ≤ 8s / opt-in→ready→suggestFrame 시나리오는 실 Android 8GB+ 단말 필요. Host CI 는 검증 불가 → 별도 디바이스 랩 단계.
|
||||||
|
- **F1**: 설계서 §8.8 / §9 의 "60초 idle 시 `unload()`" 미구현. #219 별도 이슈 — 단발 호출 후 즉시 unload 가 안전한 기본값.
|
||||||
|
- **#221 AC10 corpus 품질 평가**: 30+ 한국어 라이브 입력 corpus 로 L2 ≥ 70% / L3 ≥ 50% 측정. 실 모델 통합 후 별도 이슈.
|
||||||
|
|
||||||
|
## 9. 다음 단계 / 확장 포인트
|
||||||
|
|
||||||
|
- **#215 follow-up 4 이슈** (#218 다음): **#219** 60s idle auto-unload (F1), **#220** purge hardening (F2), **#221** AC10 한국어 corpus 품질 평가 (≥70%), **#222** production keystore / Play Store 준비.
|
||||||
|
- 시나리오 #2~#6 (앵커 추출 / dose variants / if-then / lapse 구조화 / 주간 요약): 모두 `LlmService.generateStructured` 에 새 schema 추가하는 형태로 확장. 도메인 함수는 `lib/domain/ai/` 에 신규 파일.
|
||||||
|
- 멀티 모델 슬롯 (E2B + E4B): ADR-0004 후보.
|
||||||
Reference in New Issue
Block a user