[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,45 @@
import 'llm_service.dart';
/// Stub for the real `flutter_gemma` integration.
///
/// Wired up only after OQ-1 (exact model URL + SHA + flutter_gemma API
/// surface) is confirmed in Developer phase. Today this throws
/// `UnimplementedError` from every method — the rest of the system
/// (suggestFrame, ModelLifecycle, Riverpod providers) is built against the
/// `LlmService` abstract above and runs end-to-end with `MockLlmService`.
///
/// When the package is added, replace the bodies with calls into
/// FlutterGemma.init / generateWithFunctionCalling per the package docs.
/// Existing tests + UI hooks remain unchanged.
class GemmaLlmService implements LlmService {
final String modelPath;
GemmaLlmService({required this.modelPath});
bool _loaded = false;
@override
bool get isLoaded => _loaded;
@override
Future<void> load() async {
throw UnimplementedError(
'GemmaLlmService.load: pending OQ-1 (model URL + flutter_gemma).',
);
}
@override
Future<void> unload() async {
_loaded = false;
}
@override
Future<Map<String, dynamic>> generateStructured(
String prompt,
Map<String, dynamic> schema,
) async {
throw UnimplementedError(
'GemmaLlmService.generateStructured: pending OQ-1.',
);
}
}

View File

@@ -0,0 +1,84 @@
/// Abstract LLM backend.
///
/// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests).
/// Contract:
/// - After [load], [isLoaded] is true.
/// - [generateStructured] throws [StateError] when not loaded.
/// - [generateStructured] returns a parsed JSON map matching the schema.
/// On schema/parse failure throws [FormatException].
/// - [unload] is idempotent.
abstract class LlmService {
bool get isLoaded;
Future<void> load();
Future<void> unload();
/// Calls the model with a function-calling [schema] and returns the parsed
/// JSON arguments map. Caller is responsible for applying `.timeout(...)`.
Future<Map<String, dynamic>> generateStructured(
String prompt,
Map<String, dynamic> schema,
);
}
/// Programmable stub for tests. Use [enqueueResponse] / [enqueueError].
class MockLlmService implements LlmService {
final List<_Response> _queue = [];
bool _loaded = false;
int callCount = 0;
String? lastPrompt;
Map<String, dynamic>? lastSchema;
Duration responseDelay = Duration.zero;
@override
bool get isLoaded => _loaded;
@override
Future<void> load() async {
_loaded = true;
}
@override
Future<void> unload() async {
_loaded = false;
}
void enqueueResponse(Map<String, dynamic> response) {
_queue.add(_Response.value(response));
}
void enqueueError(Object error) {
_queue.add(_Response.error(error));
}
@override
Future<Map<String, dynamic>> generateStructured(
String prompt,
Map<String, dynamic> schema,
) async {
callCount += 1;
lastPrompt = prompt;
lastSchema = schema;
if (!_loaded) {
throw StateError('LlmService not loaded');
}
if (responseDelay > Duration.zero) {
await Future<void>.delayed(responseDelay);
}
if (_queue.isEmpty) {
throw StateError('MockLlmService: no queued response');
}
final r = _queue.removeAt(0);
if (r.error != null) throw r.error!;
return r.value!;
}
}
class _Response {
final Map<String, dynamic>? value;
final Object? error;
const _Response._(this.value, this.error);
factory _Response.value(Map<String, dynamic> v) => _Response._(v, null);
factory _Response.error(Object e) => _Response._(null, e);
}

View File

@@ -0,0 +1,262 @@
import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../db/daos/meta_dao.dart';
/// meta_kv keys (#215 README §6).
class AiMetaKeys {
static const optIn = 'ai_opt_in';
static const modelPath = 'ai_model_path';
static const modelSha = 'ai_model_sha256';
static const downloadState = 'ai_download_state';
static const downloadBytes = 'ai_download_bytes';
static const all = <String>[
optIn,
modelPath,
modelSha,
downloadState,
downloadBytes,
];
}
enum ModelAvailability { ready, missing, corrupt, downloading }
enum DownloadState { idle, downloading, paused, completed, failed }
class DownloadProgress {
final int bytesReceived;
final int totalBytes; // -1 if unknown
final DownloadState state;
final String? errorMessage;
const DownloadProgress({
required this.bytesReceived,
required this.totalBytes,
required this.state,
this.errorMessage,
});
}
/// File-system / HTTP abstraction so tests can inject a fake.
abstract class StorageAdapter {
Future<Directory> supportDir();
Future<http.StreamedResponse> rangeGet(Uri url, int from);
}
class _ProdStorage implements StorageAdapter {
final http.Client client;
_ProdStorage(this.client);
@override
Future<Directory> supportDir() => getApplicationSupportDirectory();
@override
Future<http.StreamedResponse> rangeGet(Uri url, int from) async {
final req = http.Request('GET', url);
if (from > 0) req.headers['Range'] = 'bytes=$from-';
return client.send(req);
}
}
/// Default config for the Gemma 4 E2B Q4_0 model. OQ-1: real URL + SHA
/// to be pinned in Developer phase after `flutter_gemma` docs review.
class ModelConfig {
final Uri url;
final String expectedSha256;
final String filename;
const ModelConfig({
required this.url,
required this.expectedSha256,
this.filename = 'gemma4_e2b_q4.bin',
});
}
/// Owns the model file: download (resumable) → SHA-256 verify → availability
/// query → purge. All paths graceful: on failure surfaces as `corrupt` /
/// `failed` rather than throwing through the call chain.
class ModelLifecycle {
final MetaDao meta;
final ModelConfig config;
final StorageAdapter _storage;
ModelLifecycle({
required this.meta,
required this.config,
StorageAdapter? storage,
http.Client? httpClient,
}) : _storage = storage ?? _ProdStorage(httpClient ?? http.Client());
Future<String> _modelPath() async {
final dir = await _storage.supportDir();
return p.join(dir.path, config.filename);
}
Future<ModelAvailability> checkAvailability() async {
try {
final optIn = await meta.find(AiMetaKeys.optIn);
if (optIn != 'true') return ModelAvailability.missing;
final state = await meta.find(AiMetaKeys.downloadState);
if (state == 'downloading' || state == 'paused') {
return ModelAvailability.downloading;
}
final pathStr = await meta.find(AiMetaKeys.modelPath);
if (pathStr == null) return ModelAvailability.missing;
final file = File(pathStr);
if (!file.existsSync()) return ModelAvailability.missing;
final expected = await meta.find(AiMetaKeys.modelSha);
if (expected == null) return ModelAvailability.corrupt;
final actual = await _hashFile(file);
if (actual != expected) return ModelAvailability.corrupt;
return ModelAvailability.ready;
} catch (_) {
return ModelAvailability.corrupt;
}
}
Future<String> _hashFile(File file) async {
final digest = await sha256.bind(file.openRead()).first;
return digest.toString();
}
/// Streams resumable download progress. Mutates `meta_kv` as it goes.
/// Errors are emitted as `DownloadProgress(state: failed)` rather than
/// thrown — UI listens to the stream.
Stream<DownloadProgress> download() async* {
final path = await _modelPath();
final tempPath = '$path.tmp';
final tempFile = File(tempPath);
int existing =
tempFile.existsSync() ? await tempFile.length() : 0;
await meta.put(AiMetaKeys.downloadState, 'downloading');
await meta.put(AiMetaKeys.downloadBytes, existing.toString());
yield DownloadProgress(
bytesReceived: existing,
totalBytes: -1,
state: DownloadState.downloading,
);
http.StreamedResponse response;
try {
response = await _storage.rangeGet(config.url, existing);
} catch (e) {
await meta.put(AiMetaKeys.downloadState, 'paused');
yield DownloadProgress(
bytesReceived: existing,
totalBytes: -1,
state: DownloadState.failed,
errorMessage: 'network: $e',
);
return;
}
final status = response.statusCode;
if (status != 200 && status != 206) {
// 416 etc. — restart from 0.
if (tempFile.existsSync()) await tempFile.delete();
existing = 0;
await meta.put(AiMetaKeys.downloadBytes, '0');
yield DownloadProgress(
bytesReceived: 0,
totalBytes: -1,
state: DownloadState.failed,
errorMessage: 'http $status',
);
return;
}
final contentLength = response.contentLength ?? 0;
final total = status == 206 ? existing + contentLength : contentLength;
final sink = tempFile.openWrite(mode: FileMode.append);
int received = existing;
try {
await for (final chunk in response.stream) {
sink.add(chunk);
received += chunk.length;
await meta.put(AiMetaKeys.downloadBytes, received.toString());
yield DownloadProgress(
bytesReceived: received,
totalBytes: total,
state: DownloadState.downloading,
);
}
await sink.flush();
await sink.close();
} catch (e) {
await sink.close();
await meta.put(AiMetaKeys.downloadState, 'paused');
yield DownloadProgress(
bytesReceived: received,
totalBytes: total,
state: DownloadState.failed,
errorMessage: 'stream: $e',
);
return;
}
// Verify SHA-256.
final finalFile = File(path);
await tempFile.rename(path);
final sha = await _hashFile(finalFile);
if (sha != config.expectedSha256) {
await finalFile.delete();
await meta.put(AiMetaKeys.downloadState, 'failed');
yield DownloadProgress(
bytesReceived: received,
totalBytes: total,
state: DownloadState.failed,
errorMessage: 'sha mismatch',
);
return;
}
await meta.put(AiMetaKeys.modelPath, path);
await meta.put(AiMetaKeys.modelSha, sha);
await meta.put(AiMetaKeys.downloadState, 'completed');
yield DownloadProgress(
bytesReceived: received,
totalBytes: total,
state: DownloadState.completed,
);
}
/// opt-out: delete model file + clear all ai_* meta keys (except opt_in
/// which the caller toggles). Returns freed bytes (0 if nothing existed).
/// Idempotent.
Future<int> purge() async {
int freed = 0;
final pathStr = await meta.find(AiMetaKeys.modelPath);
if (pathStr != null) {
final f = File(pathStr);
if (f.existsSync()) {
freed += await f.length();
await f.delete();
}
}
final tempPath = '${await _modelPath()}.tmp';
final temp = File(tempPath);
if (temp.existsSync()) {
freed += await temp.length();
await temp.delete();
}
for (final k in [
AiMetaKeys.modelPath,
AiMetaKeys.modelSha,
AiMetaKeys.downloadState,
AiMetaKeys.downloadBytes,
]) {
await meta.remove(k);
}
return freed;
}
}

View File

@@ -20,4 +20,8 @@ class MetaDao extends DatabaseAccessor<AppDatabase> with _$MetaDaoMixin {
MetaKvCompanion.insert(key: key, value: value),
);
}
Future<void> remove(String key) async {
await (delete(metaKv)..where((t) => t.key.equals(key))).go();
}
}

View File

@@ -0,0 +1,96 @@
import '../models/frame_pattern.dart';
import '../models/habit.dart';
import 'frame_candidate.dart';
/// Builds the prompt string fed to the LLM. Pure — no I/O, no globals.
///
/// Strategy: pick top-N FramePattern by token-overlap with rawText. If no
/// match, fall back to first [maxFewShot] patterns. If patterns is empty,
/// emit a system+user prompt with no few-shot section (graceful).
String buildFewShotPrompt(
SuggestFrameInput input,
List<FramePatternModel> framePatterns, {
int maxFewShot = 5,
}) {
final tokens = _tokenize(input.rawText);
final scored = <MapEntry<FramePatternModel, int>>[];
for (final p in framePatterns) {
final score = _scorePattern(tokens, p);
if (score > 0) scored.add(MapEntry(p, score));
}
scored.sort((a, b) => b.value.compareTo(a.value));
List<FramePatternModel> selected;
if (scored.isNotEmpty) {
selected = scored.take(maxFewShot).map((e) => e.key).toList();
} else if (framePatterns.isNotEmpty) {
selected = framePatterns.take(maxFewShot).toList();
} else {
selected = const [];
}
final buf = StringBuffer();
buf.writeln(_systemPrompt());
if (selected.isNotEmpty) {
buf.writeln();
buf.writeln('# 변환 예시');
var i = 1;
for (final p in selected) {
buf.writeln('## 예시 $i');
buf.writeln('L0: ${p.l0Example}');
buf.writeln('L2: ${p.l2Suggestion}');
if (p.l3Identity != null) {
buf.writeln('L3: ${p.l3Identity}');
}
buf.writeln();
i += 1;
}
}
buf.writeln('# 사용자 입력');
buf.writeln('habit_type: ${input.habitType.dbValue}');
buf.writeln('raw_text: "${input.rawText}"');
if (input.anchorHint != null && input.anchorHint!.isNotEmpty) {
buf.writeln('anchor_hint: "${input.anchorHint}"');
} else {
buf.writeln('anchor_hint: 없음');
}
buf.writeln();
buf.writeln(
'위 raw_text 를 L2(조건부 긍정) 또는 L3(정체성) 후보 3개로 변환하세요. '
'emit_frame_candidates 함수로 호출하세요.',
);
return buf.toString();
}
String _systemPrompt() => '''
당신은 Huberman 프로토콜을 따르는 한국어 코치입니다. 사용자가 입력한
raw_text 를 L2 (조건부 긍정 — "X 할 때 Y 한다") 또는 L3 (정체성 — "나는 ~ 인 사람이다")
프레임의 한국어 문장으로 변환합니다.
규칙:
- L0/L1 (회피·부정·금지 표현) 금지. "안", "끊다", "그만두다", "참는다" 사용 금지.
- 각 후보는 120자 이내.
- 의도가 명확하지 않으면 confidence 를 낮춥니다.
- 반드시 함수 emit_frame_candidates 를 호출해 JSON 으로 응답합니다.''';
List<String> _tokenize(String text) {
final normalized = text.trim();
if (normalized.isEmpty) return const [];
// Split on whitespace + Korean punctuation.
final parts = normalized.split(RegExp(r'[\s,.!?;:()\[\]"' "'" r'`/\\]+'));
return parts.where((t) => t.isNotEmpty).toList(growable: false);
}
int _scorePattern(List<String> tokens, FramePatternModel p) {
if (tokens.isEmpty) return 0;
var score = 0;
for (final t in tokens) {
if (t.contains(p.avoidanceKeyword) || p.avoidanceKeyword.contains(t)) {
score += 3;
}
if (p.domain != null && t.contains(p.domain!)) {
score += 1;
}
}
return score;
}

View File

@@ -0,0 +1,42 @@
import '../models/habit.dart';
/// Output of LLM frame suggestion. Always L2/L3 — L0/L1 candidates are
/// discarded by [parseFrameCandidates] + `validateFrameLevel`.
class FrameCandidate {
final FrameLevel level;
final String framedText;
final double confidence;
final String? sourcePatternId;
const FrameCandidate({
required this.level,
required this.framedText,
this.confidence = 0.5,
this.sourcePatternId,
});
@override
bool operator ==(Object other) =>
other is FrameCandidate &&
other.level == level &&
other.framedText == framedText &&
other.confidence == confidence &&
other.sourcePatternId == sourcePatternId;
@override
int get hashCode =>
Object.hash(level, framedText, confidence, sourcePatternId);
}
/// Input bundle for [suggestFrame].
class SuggestFrameInput {
final String rawText;
final HabitType habitType;
final String? anchorHint;
const SuggestFrameInput({
required this.rawText,
required this.habitType,
this.anchorHint,
});
}

View File

@@ -0,0 +1,60 @@
import '../models/habit.dart';
import 'frame_candidate.dart';
/// Parses function-calling JSON into FrameCandidate list. Throws
/// [FormatException] on missing/invalid top-level shape; silently skips
/// individual malformed items.
List<FrameCandidate> parseFrameCandidates(Map<String, dynamic> json) {
final raw = json['candidates'];
if (raw == null) {
throw const FormatException('candidates missing');
}
if (raw is! List) {
throw const FormatException('candidates not array');
}
final result = <FrameCandidate>[];
for (final item in raw) {
if (item is! Map) continue;
final map = item.map((k, v) => MapEntry(k.toString(), v));
final levelStr = map['level'];
if (levelStr is! String) continue;
final level = _parseLevel(levelStr);
if (level == null) continue;
final framedText = map['framed_text'];
if (framedText is! String) continue;
final trimmed = framedText.trim();
if (trimmed.isEmpty || trimmed.length > 120) continue;
double confidence = 0.5;
final c = map['confidence'];
if (c is num) {
confidence = c.toDouble().clamp(0.0, 1.0);
}
final src = map['source_pattern_id'];
result.add(FrameCandidate(
level: level,
framedText: trimmed,
confidence: confidence,
sourcePatternId: src is String ? src : null,
));
}
return result;
}
FrameLevel? _parseLevel(String s) {
switch (s.toUpperCase()) {
case 'L0':
return FrameLevel.l0;
case 'L1':
return FrameLevel.l1;
case 'L2':
return FrameLevel.l2;
case 'L3':
return FrameLevel.l3;
}
return null;
}

View File

@@ -0,0 +1,83 @@
import '../../data/ai/llm_service.dart';
import '../frame/validate_frame_level.dart';
import '../models/frame_pattern.dart';
import 'few_shot_builder.dart';
import 'frame_candidate.dart';
import 'parse_response.dart';
/// JSON schema (function-calling parameters) for the model output.
const Map<String, dynamic> kFrameCandidatesSchema = {
'type': 'object',
'properties': {
'candidates': {
'type': 'array',
'minItems': 1,
'maxItems': 3,
'items': {
'type': 'object',
'properties': {
'level': {'type': 'string', 'enum': ['L2', 'L3']},
'framed_text': {'type': 'string', 'maxLength': 120},
'confidence': {'type': 'number', 'minimum': 0, 'maximum': 1},
'source_pattern_id': {'type': 'string'},
},
'required': ['level', 'framed_text'],
},
},
},
'required': ['candidates'],
};
const Duration _defaultTimeout = Duration(seconds: 10);
const int _maxRawTextLength = 200;
/// Main domain function (#215 §A). Pure-ish: depends only on injected
/// [llm] and [framePatterns]. Never throws — failure returns an empty list
/// so callers (UI) can decide messaging (graceful degradation, AC-9).
Future<List<FrameCandidate>> suggestFrame(
SuggestFrameInput input, {
required LlmService llm,
required List<FramePatternModel> framePatterns,
Duration timeout = _defaultTimeout,
}) async {
final raw = input.rawText.trim();
if (raw.isEmpty || raw.length > _maxRawTextLength) {
return const [];
}
final prompt = buildFewShotPrompt(input, framePatterns);
Map<String, dynamic> json;
try {
json = await llm
.generateStructured(prompt, kFrameCandidatesSchema)
.timeout(timeout);
} catch (_) {
// Timeout / StateError / FormatException / anything else: graceful.
return const [];
}
List<FrameCandidate> candidates;
try {
candidates = parseFrameCandidates(json);
} on FormatException {
return const [];
}
// Drop L0/L1 + avoidance-violating via validateFrameLevel.
final validated = <FrameCandidate>[];
for (final c in candidates) {
final result = validateFrameLevel(
FrameInput(
level: c.level,
framedText: c.framedText,
originalText: input.rawText,
),
knownPatterns: framePatterns,
);
if (result.status == FrameValidationStatus.reject) continue;
validated.add(c);
if (validated.length >= 3) break;
}
return validated;
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'data/ai/llm_service.dart';
import 'state/ai_providers.dart';
import 'state/providers.dart';
import 'ui/screens/habit_list_screen.dart';
@@ -8,7 +10,13 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final db = await openProductionDatabase();
runApp(ProviderScope(
overrides: [appDatabaseProvider.overrideWithValue(db)],
overrides: [
appDatabaseProvider.overrideWithValue(db),
// OQ-1 pending: production-ready GemmaLlmService is wired here once
// model URL + SHA are pinned and flutter_gemma is added. Until then,
// MockLlmService keeps the app graceful (suggestFrame returns []).
llmServiceProvider.overrideWithValue(MockLlmService()),
],
child: const LifeHelperApp(),
));
}

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

View File

@@ -3,10 +3,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants.dart';
import '../../core/time.dart';
import '../../data/ai/model_lifecycle.dart';
import '../../data/db/daos/habit_dao.dart';
import '../../domain/ai/frame_candidate.dart';
import '../../domain/models/habit.dart';
import '../../domain/rules/active_habit_quota.dart';
import '../../state/ai_providers.dart';
import '../../state/providers.dart';
import '../widgets/frame_suggestion_dialog.dart';
class HabitCreateScreen extends ConsumerStatefulWidget {
const HabitCreateScreen({super.key});
@@ -118,7 +122,14 @@ class _HabitCreateScreenState extends ConsumerState<HabitCreateScreen> {
validator: (v) =>
(v == null || v.trim().isEmpty) ? '프레임 문구를 입력하세요' : null,
),
const SizedBox(height: 24),
const SizedBox(height: 8),
_AiSuggestButton(
titleCtrl: _titleCtrl,
framedCtrl: _framedCtrl,
habitType: _type,
onSelectLevel: (level) => setState(() => _level = level),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? '저장 중...' : '저장'),
@@ -135,3 +146,63 @@ String _ymd(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
/// "AI 제안" button — only visible when opt-in is ON and model is ready.
/// Graceful: hidden otherwise (AC-5, AC-9).
class _AiSuggestButton extends ConsumerWidget {
final TextEditingController titleCtrl;
final TextEditingController framedCtrl;
final HabitType habitType;
final ValueChanged<FrameLevel> onSelectLevel;
const _AiSuggestButton({
required this.titleCtrl,
required this.framedCtrl,
required this.habitType,
required this.onSelectLevel,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(aiSettingsProvider);
final availability = ref.watch(modelAvailabilityProvider);
final visible = settings.maybeWhen(
data: (v) => v,
orElse: () => false,
) &&
availability.maybeWhen(
data: (a) => a == ModelAvailability.ready,
orElse: () => false,
);
if (!visible) return const SizedBox.shrink();
return Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(Icons.auto_awesome),
label: const Text('AI 제안'),
onPressed: () async {
final raw = titleCtrl.text.trim();
if (raw.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('먼저 제목을 입력해주세요')),
);
return;
}
final picked = await FrameSuggestionDialog.show(
context,
input: SuggestFrameInput(
rawText: raw,
habitType: habitType,
),
);
if (picked != null) {
framedCtrl.text = picked;
// Heuristic: identity-style ("나는") → L3, else L2.
onSelectLevel(
picked.startsWith('나는') ? FrameLevel.l3 : FrameLevel.l2,
);
}
},
),
);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../state/providers.dart';
import 'check_in_screen.dart';
import 'habit_create_screen.dart';
import 'settings_screen.dart';
import 'streak_screen.dart';
class HabitListScreen extends ConsumerWidget {
@@ -15,7 +16,20 @@ class HabitListScreen extends ConsumerWidget {
final habitsAsync = ref.watch(activeHabitsProvider);
return Scaffold(
appBar: AppBar(title: const Text('습관')),
appBar: AppBar(
title: const Text('습관'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
tooltip: '설정',
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const SettingsScreen(),
));
},
),
],
),
body: boot.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text('초기화 실패: $e')),

View File

@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/ai/model_lifecycle.dart';
import '../../state/ai_providers.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('설정')),
body: ListView(
children: const [
_SectionHeader('AI 도움'),
_AiSection(),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader(this.title);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall,
),
);
}
}
class _AiSection extends ConsumerWidget {
const _AiSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(aiSettingsProvider);
final availability = ref.watch(modelAvailabilityProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SwitchListTile(
title: const Text('AI 도움 켜기'),
subtitle: const Text(
'Gemma 4 E2B 모델 ≈ 1.5GB. 모든 처리는 단말에서 일어납니다.',
),
value: settings.maybeWhen(
data: (v) => v,
orElse: () => false,
),
onChanged: (v) async {
if (v) {
final ok = await _confirmOptIn(context);
if (ok != true) return;
} else {
final ok = await _confirmOptOut(context);
if (ok != true) return;
}
final freed =
await ref.read(aiSettingsControllerProvider).setOptIn(v);
if (!context.mounted) return;
if (!v && freed > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('공간 확보됨 ${_fmtMB(freed)}')),
);
}
},
),
availability.when(
loading: () => const ListTile(title: Text('상태 확인 중...')),
error: (e, _) => ListTile(title: Text('상태 오류: $e')),
data: (a) => ListTile(
title: const Text('모델 상태'),
subtitle: Text(_describe(a)),
),
),
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Text(
'OQ-1 미해결: 정확한 모델 URL + SHA 가 픽스되기 전까지 '
'다운로드는 동작하지 않습니다. (Architect/Developer 인계 사항)',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
],
);
}
String _describe(ModelAvailability a) {
switch (a) {
case ModelAvailability.ready:
return '사용 가능';
case ModelAvailability.missing:
return '미설치 — 토글을 켜면 다운로드를 시작합니다';
case ModelAvailability.corrupt:
return '손상됨 — 토글을 끄고 다시 켜주세요';
case ModelAvailability.downloading:
return '다운로드 중 / 일시정지됨';
}
}
String _fmtMB(int bytes) {
final mb = bytes / (1024 * 1024);
return '${mb.toStringAsFixed(1)} MB';
}
Future<bool?> _confirmOptIn(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('AI 도움 켜기'),
content: const Text(
'Gemma 4 E2B 모델 ≈ 1.5GB 를 다운로드합니다.\n'
'- WiFi 권장\n'
'- 모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다.\n'
'- 끄면 즉시 삭제됩니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('동의 후 다운로드'),
),
],
),
);
}
Future<bool?> _confirmOptOut(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('AI 도움 끄기'),
content: const Text(
'모델 파일 ≈ 1.5GB 가 즉시 삭제됩니다. '
'다시 켜면 다시 다운로드해야 합니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('끄고 삭제'),
),
],
),
);
}
}

View File

@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/ai/frame_candidate.dart';
import '../../domain/models/habit.dart';
import '../../state/ai_providers.dart';
/// Shows L2/L3 suggestion cards from suggestFrame. Returns the selected
/// framed_text via `Navigator.pop(context, candidate.framedText)`.
class FrameSuggestionDialog extends ConsumerWidget {
final SuggestFrameInput input;
const FrameSuggestionDialog({super.key, required this.input});
static Future<String?> show(
BuildContext context, {
required SuggestFrameInput input,
}) {
return showDialog<String>(
context: context,
builder: (_) => FrameSuggestionDialog(input: input),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(frameSuggestionsProvider(input));
return AlertDialog(
title: const Text('AI 제안'),
content: SizedBox(
width: 320,
child: async.when(
loading: () => const SizedBox(
height: 120,
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(8),
child: Text('AI 제안을 받지 못했습니다. 직접 입력해주세요.\n($e)'),
),
data: (candidates) {
if (candidates.isEmpty) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.all(8),
child: Text(
'더 구체적으로 입력해주시면 더 좋은 제안을 드릴 수 있어요.',
),
),
TextButton(
onPressed: () {
ref.invalidate(frameSuggestionsProvider(input));
},
child: const Text('다시 시도'),
),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final c in candidates)
_CandidateCard(
candidate: c,
onTap: () => Navigator.of(context).pop(c.framedText),
),
],
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
],
);
}
}
class _CandidateCard extends StatelessWidget {
final FrameCandidate candidate;
final VoidCallback onTap;
const _CandidateCard({required this.candidate, required this.onTap});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
title: Text(candidate.framedText),
subtitle: Text(
'${candidate.level == FrameLevel.l3 ? "L3 · 정체성" : "L2 · 조건부 긍정"} '
'· 신뢰도 ${(candidate.confidence * 100).toInt()}%',
),
onTap: onTap,
),
);
}
}