[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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,7 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
@@ -324,7 +324,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
|
||||
@@ -27,6 +27,12 @@ dependencies:
|
||||
# IDs
|
||||
ulid: ^2.0.0
|
||||
|
||||
# AI / on-device LLM (#215)
|
||||
# flutter_gemma 는 OQ-1 (정확한 모델 URL + SHA) 확정 후 추가.
|
||||
# v1은 LlmService 추상 + ModelLifecycle (파일/SHA/메타 관리) + Mock 까지 구현.
|
||||
crypto: ^3.0.0
|
||||
http: ^1.2.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
193
app/test/data/ai/model_lifecycle_test.dart
Normal file
193
app/test/data/ai/model_lifecycle_test.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:life_helper/data/ai/model_lifecycle.dart';
|
||||
import 'package:life_helper/data/db/app_database.dart';
|
||||
import 'package:life_helper/data/db/daos/meta_dao.dart';
|
||||
|
||||
class _FakeStorage implements StorageAdapter {
|
||||
_FakeStorage(this.dir);
|
||||
final Directory dir;
|
||||
Future<http.StreamedResponse> Function(Uri url, int from)? handler;
|
||||
|
||||
@override
|
||||
Future<Directory> supportDir() async => dir;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> rangeGet(Uri url, int from) {
|
||||
final h = handler;
|
||||
if (h == null) {
|
||||
throw StateError('no handler');
|
||||
}
|
||||
return h(url, from);
|
||||
}
|
||||
}
|
||||
|
||||
http.StreamedResponse _streamed(
|
||||
List<int> bytes, {
|
||||
int status = 200,
|
||||
}) {
|
||||
return http.StreamedResponse(
|
||||
Stream.value(bytes),
|
||||
status,
|
||||
contentLength: bytes.length,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late MetaDao meta;
|
||||
late Directory tmp;
|
||||
late _FakeStorage storage;
|
||||
const url = 'https://example/model.bin';
|
||||
|
||||
setUp(() async {
|
||||
db = AppDatabase(NativeDatabase.memory());
|
||||
meta = MetaDao(db);
|
||||
tmp = await Directory.systemTemp.createTemp('lifecycle_test_');
|
||||
storage = _FakeStorage(tmp);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
if (tmp.existsSync()) await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('checkAvailability missing when opt_in false', () async {
|
||||
final lc = ModelLifecycle(
|
||||
meta: meta,
|
||||
config: ModelConfig(url: Uri.parse(url), expectedSha256: 'x'),
|
||||
storage: storage,
|
||||
);
|
||||
expect(await lc.checkAvailability(), ModelAvailability.missing);
|
||||
});
|
||||
|
||||
test('download writes file, sets meta keys, completes with ready', () async {
|
||||
final payload = utf8.encode('hello world fake model');
|
||||
final expected = sha256.convert(payload).toString();
|
||||
final lc = ModelLifecycle(
|
||||
meta: meta,
|
||||
config: ModelConfig(
|
||||
url: Uri.parse(url),
|
||||
expectedSha256: expected,
|
||||
filename: 'gemma_test.bin',
|
||||
),
|
||||
storage: storage,
|
||||
);
|
||||
storage.handler = (_, from) async => _streamed(payload);
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
|
||||
final progresses = await lc.download().toList();
|
||||
expect(progresses.last.state, DownloadState.completed);
|
||||
expect(progresses.last.bytesReceived, payload.length);
|
||||
|
||||
expect(await meta.find(AiMetaKeys.downloadState), 'completed');
|
||||
final p = await meta.find(AiMetaKeys.modelPath);
|
||||
expect(p, isNotNull);
|
||||
expect(File(p!).existsSync(), true);
|
||||
expect(await meta.find(AiMetaKeys.modelSha), expected);
|
||||
|
||||
expect(await lc.checkAvailability(), ModelAvailability.ready);
|
||||
});
|
||||
|
||||
test('SHA mismatch deletes file and emits failed', () async {
|
||||
final payload = utf8.encode('payload');
|
||||
final lc = ModelLifecycle(
|
||||
meta: meta,
|
||||
config: ModelConfig(
|
||||
url: Uri.parse(url),
|
||||
expectedSha256: 'deadbeef',
|
||||
filename: 'gemma_bad.bin',
|
||||
),
|
||||
storage: storage,
|
||||
);
|
||||
storage.handler = (_, from) async => _streamed(payload);
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
|
||||
final progresses = await lc.download().toList();
|
||||
expect(progresses.last.state, DownloadState.failed);
|
||||
expect(progresses.last.errorMessage, contains('sha'));
|
||||
|
||||
final pathStr = '${tmp.path}/gemma_bad.bin';
|
||||
expect(File(pathStr).existsSync(), false);
|
||||
});
|
||||
|
||||
test('network error → paused, file preserved for resume', () async {
|
||||
final lc = ModelLifecycle(
|
||||
meta: meta,
|
||||
config: ModelConfig(
|
||||
url: Uri.parse(url),
|
||||
expectedSha256: 'x',
|
||||
filename: 'gemma_net.bin',
|
||||
),
|
||||
storage: storage,
|
||||
);
|
||||
storage.handler = (_, from) async => throw const SocketException('down');
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
|
||||
final progresses = await lc.download().toList();
|
||||
expect(progresses.last.state, DownloadState.failed);
|
||||
expect(await meta.find(AiMetaKeys.downloadState), 'paused');
|
||||
});
|
||||
|
||||
test('purge deletes file + clears meta keys (idempotent)', () async {
|
||||
final payload = utf8.encode('xx');
|
||||
final expected = sha256.convert(payload).toString();
|
||||
final lc = ModelLifecycle(
|
||||
meta: meta,
|
||||
config: ModelConfig(
|
||||
url: Uri.parse(url),
|
||||
expectedSha256: expected,
|
||||
filename: 'gemma_purge.bin',
|
||||
),
|
||||
storage: storage,
|
||||
);
|
||||
storage.handler = (_, from) async => _streamed(payload);
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
await lc.download().toList();
|
||||
|
||||
final freed = await lc.purge();
|
||||
expect(freed, payload.length);
|
||||
expect(await meta.find(AiMetaKeys.modelPath), isNull);
|
||||
expect(await meta.find(AiMetaKeys.modelSha), isNull);
|
||||
expect(await meta.find(AiMetaKeys.downloadState), isNull);
|
||||
|
||||
// Idempotent — second purge returns 0 without throwing.
|
||||
expect(await lc.purge(), 0);
|
||||
});
|
||||
|
||||
test('checkAvailability detects download in progress', () async {
|
||||
final lc = ModelLifecycle(
|
||||
meta: meta,
|
||||
config: ModelConfig(url: Uri.parse(url), expectedSha256: 'x'),
|
||||
storage: storage,
|
||||
);
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
await meta.put(AiMetaKeys.downloadState, 'paused');
|
||||
expect(await lc.checkAvailability(), ModelAvailability.downloading);
|
||||
});
|
||||
|
||||
test('checkAvailability returns corrupt when file SHA mismatches', () async {
|
||||
const file = 'gemma_corrupt.bin';
|
||||
final lc = ModelLifecycle(
|
||||
meta: meta,
|
||||
config: ModelConfig(
|
||||
url: Uri.parse(url),
|
||||
expectedSha256: 'wrong',
|
||||
filename: file,
|
||||
),
|
||||
storage: storage,
|
||||
);
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
final path = '${tmp.path}/$file';
|
||||
File(path).writeAsStringSync('payload');
|
||||
await meta.put(AiMetaKeys.modelPath, path);
|
||||
await meta.put(AiMetaKeys.modelSha, 'expected_but_actual_will_differ');
|
||||
expect(await lc.checkAvailability(), ModelAvailability.corrupt);
|
||||
});
|
||||
}
|
||||
131
app/test/domain/ai/few_shot_builder_test.dart
Normal file
131
app/test/domain/ai/few_shot_builder_test.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/domain/ai/few_shot_builder.dart';
|
||||
import 'package:life_helper/domain/ai/frame_candidate.dart';
|
||||
import 'package:life_helper/domain/models/frame_pattern.dart';
|
||||
import 'package:life_helper/domain/models/habit.dart';
|
||||
|
||||
final _patterns = <FramePatternModel>[
|
||||
const FramePatternModel(
|
||||
id: 'fp_alcohol',
|
||||
domain: 'drink',
|
||||
avoidanceKeyword: '술 끊기',
|
||||
l0Example: '술 끊기',
|
||||
l2Suggestion: '저녁엔 무알콜 음료',
|
||||
l3Identity: '나는 맑은 정신의 사람이다',
|
||||
),
|
||||
const FramePatternModel(
|
||||
id: 'fp_smoke',
|
||||
domain: 'smoke',
|
||||
avoidanceKeyword: '담배 끊기',
|
||||
l0Example: '담배 끊기',
|
||||
l2Suggestion: '간식 대체',
|
||||
l3Identity: '나는 깨끗한 폐를 가진 사람이다',
|
||||
),
|
||||
const FramePatternModel(
|
||||
id: 'fp_general',
|
||||
domain: 'general',
|
||||
avoidanceKeyword: '안 하기',
|
||||
l0Example: '안 하기',
|
||||
l2Suggestion: '대체 행동 정의',
|
||||
),
|
||||
];
|
||||
|
||||
void main() {
|
||||
test('matched keyword surfaces relevant pattern first', () {
|
||||
final p = buildFewShotPrompt(
|
||||
const SuggestFrameInput(
|
||||
rawText: '술 끊고 싶어',
|
||||
habitType: HabitType.breakHabit,
|
||||
),
|
||||
_patterns,
|
||||
);
|
||||
expect(p, contains('## 예시 1'));
|
||||
// The alcohol pattern (highest score) should appear before smoke/general.
|
||||
final idxAlc = p.indexOf('저녁엔 무알콜 음료');
|
||||
final idxSmk = p.indexOf('간식 대체');
|
||||
expect(idxAlc, greaterThan(-1));
|
||||
expect(idxSmk == -1 || idxAlc < idxSmk, true);
|
||||
});
|
||||
|
||||
test('fallback uses first patterns when no keyword matches', () {
|
||||
final p = buildFewShotPrompt(
|
||||
const SuggestFrameInput(
|
||||
rawText: 'xyz unknown words',
|
||||
habitType: HabitType.build,
|
||||
),
|
||||
_patterns,
|
||||
);
|
||||
expect(p, contains('## 예시 1'));
|
||||
// First pattern in list is alcohol.
|
||||
expect(p, contains('저녁엔 무알콜 음료'));
|
||||
});
|
||||
|
||||
test('empty patterns → prompt has no few-shot section', () {
|
||||
final p = buildFewShotPrompt(
|
||||
const SuggestFrameInput(
|
||||
rawText: '술 끊고 싶어',
|
||||
habitType: HabitType.breakHabit,
|
||||
),
|
||||
const [],
|
||||
);
|
||||
expect(p.contains('변환 예시'), false);
|
||||
expect(p, contains('사용자 입력'));
|
||||
expect(p, contains('raw_text:'));
|
||||
});
|
||||
|
||||
test('anchor hint appears when provided', () {
|
||||
final p = buildFewShotPrompt(
|
||||
const SuggestFrameInput(
|
||||
rawText: '책 읽고 싶어',
|
||||
habitType: HabitType.build,
|
||||
anchorHint: '아침 양치 후',
|
||||
),
|
||||
_patterns,
|
||||
);
|
||||
expect(p, contains('anchor_hint: "아침 양치 후"'));
|
||||
});
|
||||
|
||||
test('habit_type rendered using dbValue', () {
|
||||
final pBreak = buildFewShotPrompt(
|
||||
const SuggestFrameInput(
|
||||
rawText: 'a',
|
||||
habitType: HabitType.breakHabit,
|
||||
),
|
||||
const [],
|
||||
);
|
||||
expect(pBreak, contains('habit_type: break'));
|
||||
final pBuild = buildFewShotPrompt(
|
||||
const SuggestFrameInput(rawText: 'a', habitType: HabitType.build),
|
||||
const [],
|
||||
);
|
||||
expect(pBuild, contains('habit_type: build'));
|
||||
});
|
||||
|
||||
test('deterministic — same input → same prompt', () {
|
||||
final a = buildFewShotPrompt(
|
||||
const SuggestFrameInput(
|
||||
rawText: '술 끊기',
|
||||
habitType: HabitType.breakHabit,
|
||||
),
|
||||
_patterns,
|
||||
);
|
||||
final b = buildFewShotPrompt(
|
||||
const SuggestFrameInput(
|
||||
rawText: '술 끊기',
|
||||
habitType: HabitType.breakHabit,
|
||||
),
|
||||
_patterns,
|
||||
);
|
||||
expect(a, b);
|
||||
});
|
||||
|
||||
test('maxFewShot caps selected examples', () {
|
||||
final p = buildFewShotPrompt(
|
||||
const SuggestFrameInput(rawText: 'x', habitType: HabitType.build),
|
||||
_patterns,
|
||||
maxFewShot: 1,
|
||||
);
|
||||
expect(p, contains('## 예시 1'));
|
||||
expect(p.contains('## 예시 2'), false);
|
||||
});
|
||||
}
|
||||
90
app/test/domain/ai/parse_response_test.dart
Normal file
90
app/test/domain/ai/parse_response_test.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/domain/ai/parse_response.dart';
|
||||
import 'package:life_helper/domain/models/habit.dart';
|
||||
|
||||
void main() {
|
||||
test('parses 3 valid candidates', () {
|
||||
final r = parseFrameCandidates({
|
||||
'candidates': [
|
||||
{'level': 'L2', 'framed_text': '저녁엔 무알콜 마시기', 'confidence': 0.9},
|
||||
{'level': 'L3', 'framed_text': '나는 맑은 정신의 사람이다'},
|
||||
{'level': 'L2', 'framed_text': '주중엔 운동 우선'},
|
||||
],
|
||||
});
|
||||
expect(r, hasLength(3));
|
||||
expect(r[0].level, FrameLevel.l2);
|
||||
expect(r[0].confidence, 0.9);
|
||||
expect(r[1].level, FrameLevel.l3);
|
||||
expect(r[1].confidence, 0.5); // default
|
||||
});
|
||||
|
||||
test('candidates missing → FormatException', () {
|
||||
expect(
|
||||
() => parseFrameCandidates({'foo': 'bar'}),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('candidates not list → FormatException', () {
|
||||
expect(
|
||||
() => parseFrameCandidates({'candidates': 'oops'}),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('empty candidates → empty list (no throw)', () {
|
||||
final r = parseFrameCandidates({'candidates': []});
|
||||
expect(r, isEmpty);
|
||||
});
|
||||
|
||||
test('skips unknown level + length-violating items', () {
|
||||
final tooLong = 'a' * 121;
|
||||
final r = parseFrameCandidates({
|
||||
'candidates': [
|
||||
{'level': 'L99', 'framed_text': '?'}, // skipped
|
||||
{'level': 'L2', 'framed_text': tooLong}, // skipped
|
||||
{'level': 'L3', 'framed_text': ' '}, // skipped (empty after trim)
|
||||
{'level': 'L2', 'framed_text': '유효한 후보'},
|
||||
],
|
||||
});
|
||||
expect(r, hasLength(1));
|
||||
expect(r.first.framedText, '유효한 후보');
|
||||
});
|
||||
|
||||
test('confidence clamps and falls back to 0.5', () {
|
||||
final r = parseFrameCandidates({
|
||||
'candidates': [
|
||||
{'level': 'L2', 'framed_text': 'a', 'confidence': -0.4},
|
||||
{'level': 'L2', 'framed_text': 'b', 'confidence': 2.5},
|
||||
{'level': 'L2', 'framed_text': 'c', 'confidence': 'not-a-number'},
|
||||
],
|
||||
});
|
||||
expect(r[0].confidence, 0.0);
|
||||
expect(r[1].confidence, 1.0);
|
||||
expect(r[2].confidence, 0.5);
|
||||
});
|
||||
|
||||
test('keeps L0/L1 candidates (filtering is suggestFrame responsibility)', () {
|
||||
final r = parseFrameCandidates({
|
||||
'candidates': [
|
||||
{'level': 'L0', 'framed_text': '술 끊기'},
|
||||
{'level': 'L2', 'framed_text': '무알콜'},
|
||||
],
|
||||
});
|
||||
expect(r, hasLength(2));
|
||||
expect(r[0].level, FrameLevel.l0);
|
||||
});
|
||||
|
||||
test('source_pattern_id preserved when present', () {
|
||||
final r = parseFrameCandidates({
|
||||
'candidates': [
|
||||
{
|
||||
'level': 'L2',
|
||||
'framed_text': 'foo',
|
||||
'source_pattern_id': 'fp_alcohol'
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(r.single.sourcePatternId, 'fp_alcohol');
|
||||
});
|
||||
}
|
||||
165
app/test/domain/ai/suggest_frame_test.dart
Normal file
165
app/test/domain/ai/suggest_frame_test.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/data/ai/llm_service.dart';
|
||||
import 'package:life_helper/domain/ai/frame_candidate.dart';
|
||||
import 'package:life_helper/domain/ai/suggest_frame.dart';
|
||||
import 'package:life_helper/domain/models/frame_pattern.dart';
|
||||
import 'package:life_helper/domain/models/habit.dart';
|
||||
|
||||
final _patterns = <FramePatternModel>[
|
||||
const FramePatternModel(
|
||||
id: 'fp_alcohol',
|
||||
domain: 'drink',
|
||||
avoidanceKeyword: '술 끊기',
|
||||
l0Example: '술 끊기',
|
||||
l2Suggestion: '무알콜',
|
||||
l3Identity: '맑은 정신',
|
||||
),
|
||||
];
|
||||
|
||||
const _input = SuggestFrameInput(
|
||||
rawText: '술 끊고 싶어',
|
||||
habitType: HabitType.breakHabit,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('happy path: returns up to 3 validated candidates', () async {
|
||||
final llm = MockLlmService();
|
||||
await llm.load();
|
||||
llm.enqueueResponse({
|
||||
'candidates': [
|
||||
{'level': 'L2', 'framed_text': '저녁엔 무알콜 음료', 'confidence': 0.9},
|
||||
{'level': 'L3', 'framed_text': '나는 맑은 정신의 사람이다'},
|
||||
{'level': 'L2', 'framed_text': '주말엔 운동 우선'},
|
||||
],
|
||||
});
|
||||
final result = await suggestFrame(
|
||||
_input,
|
||||
llm: llm,
|
||||
framePatterns: _patterns,
|
||||
);
|
||||
expect(result, hasLength(3));
|
||||
expect(result[0].level, FrameLevel.l2);
|
||||
});
|
||||
|
||||
test('L0/L1 candidates discarded by validateFrameLevel', () async {
|
||||
final llm = MockLlmService();
|
||||
await llm.load();
|
||||
llm.enqueueResponse({
|
||||
'candidates': [
|
||||
{'level': 'L0', 'framed_text': '술 안 마시기'},
|
||||
{'level': 'L1', 'framed_text': '음주 중단'},
|
||||
{'level': 'L2', 'framed_text': '무알콜 마시기'},
|
||||
],
|
||||
});
|
||||
final result = await suggestFrame(
|
||||
_input,
|
||||
llm: llm,
|
||||
framePatterns: _patterns,
|
||||
);
|
||||
expect(result, hasLength(1));
|
||||
expect(result.first.level, FrameLevel.l2);
|
||||
});
|
||||
|
||||
test('timeout → empty list (graceful)', () async {
|
||||
final llm = MockLlmService();
|
||||
await llm.load();
|
||||
llm.responseDelay = const Duration(milliseconds: 200);
|
||||
llm.enqueueResponse({
|
||||
'candidates': [
|
||||
{'level': 'L2', 'framed_text': '무알콜'},
|
||||
],
|
||||
});
|
||||
final result = await suggestFrame(
|
||||
_input,
|
||||
llm: llm,
|
||||
framePatterns: _patterns,
|
||||
timeout: const Duration(milliseconds: 50),
|
||||
);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('StateError (not loaded) → empty list (graceful)', () async {
|
||||
final llm = MockLlmService(); // not loaded
|
||||
final result = await suggestFrame(
|
||||
_input,
|
||||
llm: llm,
|
||||
framePatterns: _patterns,
|
||||
);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('malformed JSON → empty list (graceful)', () async {
|
||||
final llm = MockLlmService();
|
||||
await llm.load();
|
||||
llm.enqueueResponse({'foo': 'bar'}); // no candidates key
|
||||
final result = await suggestFrame(
|
||||
_input,
|
||||
llm: llm,
|
||||
framePatterns: _patterns,
|
||||
);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('empty rawText → llm not called', () async {
|
||||
final llm = MockLlmService();
|
||||
await llm.load();
|
||||
final result = await suggestFrame(
|
||||
const SuggestFrameInput(
|
||||
rawText: ' ',
|
||||
habitType: HabitType.breakHabit,
|
||||
),
|
||||
llm: llm,
|
||||
framePatterns: _patterns,
|
||||
);
|
||||
expect(result, isEmpty);
|
||||
expect(llm.callCount, 0);
|
||||
});
|
||||
|
||||
test('rawText > 200 chars → empty list, llm not called', () async {
|
||||
final llm = MockLlmService();
|
||||
await llm.load();
|
||||
final result = await suggestFrame(
|
||||
SuggestFrameInput(
|
||||
rawText: 'a' * 201,
|
||||
habitType: HabitType.breakHabit,
|
||||
),
|
||||
llm: llm,
|
||||
framePatterns: _patterns,
|
||||
);
|
||||
expect(result, isEmpty);
|
||||
expect(llm.callCount, 0);
|
||||
});
|
||||
|
||||
test('graceful: arbitrary throw is caught', () async {
|
||||
final llm = MockLlmService();
|
||||
await llm.load();
|
||||
llm.enqueueError(Exception('boom'));
|
||||
final result = await suggestFrame(
|
||||
_input,
|
||||
llm: llm,
|
||||
framePatterns: _patterns,
|
||||
);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('result truncated at 3 even if more valid candidates returned',
|
||||
() async {
|
||||
final llm = MockLlmService();
|
||||
await llm.load();
|
||||
llm.enqueueResponse({
|
||||
'candidates': [
|
||||
{'level': 'L2', 'framed_text': 'a'},
|
||||
{'level': 'L2', 'framed_text': 'b'},
|
||||
{'level': 'L2', 'framed_text': 'c'},
|
||||
{'level': 'L2', 'framed_text': 'd'},
|
||||
{'level': 'L2', 'framed_text': 'e'},
|
||||
],
|
||||
});
|
||||
final result = await suggestFrame(
|
||||
_input,
|
||||
llm: llm,
|
||||
framePatterns: _patterns,
|
||||
);
|
||||
expect(result, hasLength(3));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user