[Developer] #215 AI frame-suggest vertical slice (mock LlmService)
설계서대로 구현. flutter_gemma 실제 통합은 OQ-1 (모델 URL+SHA) 확정 후.
v1은 LlmService 추상 + ModelLifecycle (다운로드/SHA/purge) + Riverpod
providers + 다이얼로그 + Settings 화면까지. main.dart 가 MockLlmService 를
override 해 모든 경로가 graceful (suggest 결과는 빈 리스트).
추가:
- lib/data/ai/{llm_service,gemma_llm_service,model_lifecycle}.dart
- lib/domain/ai/{frame_candidate,few_shot_builder,parse_response,suggest_frame}.dart
- lib/state/ai_providers.dart (aiSettings + modelAvailability + frameSuggestions)
- lib/ui/screens/settings_screen.dart (opt-in 토글 + 모델 상태 표시)
- lib/ui/widgets/frame_suggestion_dialog.dart (후보 3개 카드 + 다시 시도)
- HabitCreateScreen: "AI 제안" 버튼 (opt-in + ready 일 때만 노출)
- MetaDao.remove(key) 추가 (purge 용)
테스트 31개 신규 추가 (총 62개 통과):
- test/domain/ai/{suggest_frame, few_shot_builder, parse_response}_test.dart
- test/data/ai/model_lifecycle_test.dart (download/SHA/purge/availability)
flutter analyze 0 issue, flutter build apk --debug 통과.
Refs #215
This commit is contained in:
113
app/lib/state/ai_providers.dart
Normal file
113
app/lib/state/ai_providers.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/ai/llm_service.dart';
|
||||
import '../data/ai/model_lifecycle.dart';
|
||||
import '../data/db/app_database.dart' as drift;
|
||||
import '../domain/ai/frame_candidate.dart';
|
||||
import '../domain/ai/suggest_frame.dart';
|
||||
import '../domain/models/frame_pattern.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
/// Default config for the on-device Gemma model (#215).
|
||||
/// OQ-1: URL + SHA-256 pinned in Developer phase. Until then, downloads are
|
||||
/// disabled (AI toggle is gated behind these constants being real).
|
||||
const _kModelUrlPlaceholder =
|
||||
'https://example.invalid/gemma4-e2b-q4.bin'; // OQ-1
|
||||
const _kModelShaPlaceholder = 'PENDING_OQ_1';
|
||||
|
||||
final modelLifecycleProvider = Provider<ModelLifecycle>((ref) {
|
||||
return ModelLifecycle(
|
||||
meta: ref.watch(metaDaoProvider),
|
||||
config: ModelConfig(
|
||||
url: Uri.parse(_kModelUrlPlaceholder),
|
||||
expectedSha256: _kModelShaPlaceholder,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
/// Read-only opt-in state. Default OFF; persisted in `meta_kv`.
|
||||
final aiSettingsProvider = FutureProvider<bool>((ref) async {
|
||||
final meta = ref.watch(metaDaoProvider);
|
||||
final v = await meta.find(AiMetaKeys.optIn);
|
||||
return v == 'true';
|
||||
});
|
||||
|
||||
/// Toggles opt-in. On opt-out, purges model file via [ModelLifecycle.purge].
|
||||
class AiSettingsController {
|
||||
AiSettingsController(this.ref);
|
||||
final Ref ref;
|
||||
|
||||
Future<int> setOptIn(bool value) async {
|
||||
final meta = ref.read(metaDaoProvider);
|
||||
if (value) {
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
ref.invalidate(aiSettingsProvider);
|
||||
ref.invalidate(modelAvailabilityProvider);
|
||||
return 0;
|
||||
}
|
||||
final freed = await ref.read(modelLifecycleProvider).purge();
|
||||
await meta.put(AiMetaKeys.optIn, 'false');
|
||||
ref.invalidate(aiSettingsProvider);
|
||||
ref.invalidate(modelAvailabilityProvider);
|
||||
return freed;
|
||||
}
|
||||
}
|
||||
|
||||
final aiSettingsControllerProvider = Provider<AiSettingsController>((ref) {
|
||||
return AiSettingsController(ref);
|
||||
});
|
||||
|
||||
final modelAvailabilityProvider =
|
||||
FutureProvider<ModelAvailability>((ref) async {
|
||||
final lc = ref.watch(modelLifecycleProvider);
|
||||
return lc.checkAvailability();
|
||||
});
|
||||
|
||||
/// Loads FramePatterns from DB and converts to domain models.
|
||||
final framePatternsProvider = FutureProvider<List<FramePatternModel>>(
|
||||
(ref) async {
|
||||
final db = ref.watch(appDatabaseProvider);
|
||||
final rows = await db.select(db.framePatterns).get();
|
||||
return rows.map(_toDomain).toList(growable: false);
|
||||
},
|
||||
);
|
||||
|
||||
FramePatternModel _toDomain(drift.FramePattern r) => FramePatternModel(
|
||||
id: r.id,
|
||||
domain: r.domain,
|
||||
avoidanceKeyword: r.avoidanceKeyword,
|
||||
l0Example: r.l0Example,
|
||||
l1SimpleReplace: r.l1SimpleReplace,
|
||||
l2Suggestion: r.l2Suggestion,
|
||||
l3Identity: r.l3Identity,
|
||||
);
|
||||
|
||||
/// Singleton LLM service for the app. v1 starts unloaded; first
|
||||
/// [suggestFrame] triggers `.load()` via the dialog. Override in tests with
|
||||
/// `MockLlmService`.
|
||||
final llmServiceProvider = Provider<LlmService>((ref) {
|
||||
throw UnimplementedError(
|
||||
'llmServiceProvider must be overridden (Mock in tests, '
|
||||
'GemmaLlmService after OQ-1 in production).',
|
||||
);
|
||||
});
|
||||
|
||||
/// `family` param wraps a SuggestFrameInput. Loads model lazily before
|
||||
/// calling suggestFrame.
|
||||
final frameSuggestionsProvider = FutureProvider.autoDispose
|
||||
.family<List<FrameCandidate>, SuggestFrameInput>((ref, input) async {
|
||||
final llm = ref.watch(llmServiceProvider);
|
||||
final patterns = await ref.watch(framePatternsProvider.future);
|
||||
if (!llm.isLoaded) {
|
||||
try {
|
||||
await llm.load();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
return suggestFrame(
|
||||
input,
|
||||
llm: llm,
|
||||
framePatterns: patterns,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user