[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:
45
app/lib/data/ai/gemma_llm_service.dart
Normal file
45
app/lib/data/ai/gemma_llm_service.dart
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
84
app/lib/data/ai/llm_service.dart
Normal file
84
app/lib/data/ai/llm_service.dart
Normal 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);
|
||||
}
|
||||
262
app/lib/data/ai/model_lifecycle.dart
Normal file
262
app/lib/data/ai/model_lifecycle.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
96
app/lib/domain/ai/few_shot_builder.dart
Normal file
96
app/lib/domain/ai/few_shot_builder.dart
Normal 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;
|
||||
}
|
||||
42
app/lib/domain/ai/frame_candidate.dart
Normal file
42
app/lib/domain/ai/frame_candidate.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
60
app/lib/domain/ai/parse_response.dart
Normal file
60
app/lib/domain/ai/parse_response.dart
Normal 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;
|
||||
}
|
||||
83
app/lib/domain/ai/suggest_frame.dart
Normal file
83
app/lib/domain/ai/suggest_frame.dart
Normal 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;
|
||||
}
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
|
||||
161
app/lib/ui/screens/settings_screen.dart
Normal file
161
app/lib/ui/screens/settings_screen.dart
Normal 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('끄고 삭제'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
app/lib/ui/widgets/frame_suggestion_dialog.dart
Normal file
102
app/lib/ui/widgets/frame_suggestion_dialog.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user