[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:
2026-06-12 12:08:25 +09:00
parent d31b17f3e8
commit 6ab4c0da7d
20 changed files with 1735 additions and 5 deletions

View 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,
);
});