[Developer] #215 AI frame-suggest vertical slice (mock LlmService)

설계서대로 구현. flutter_gemma 실제 통합은 OQ-1 (모델 URL+SHA) 확정 후.
v1은 LlmService 추상 + ModelLifecycle (다운로드/SHA/purge) + Riverpod
providers + 다이얼로그 + Settings 화면까지. main.dart 가 MockLlmService 를
override 해 모든 경로가 graceful (suggest 결과는 빈 리스트).

추가:
- lib/data/ai/{llm_service,gemma_llm_service,model_lifecycle}.dart
- lib/domain/ai/{frame_candidate,few_shot_builder,parse_response,suggest_frame}.dart
- lib/state/ai_providers.dart (aiSettings + modelAvailability + frameSuggestions)
- lib/ui/screens/settings_screen.dart (opt-in 토글 + 모델 상태 표시)
- lib/ui/widgets/frame_suggestion_dialog.dart (후보 3개 카드 + 다시 시도)
- HabitCreateScreen: "AI 제안" 버튼 (opt-in + ready 일 때만 노출)
- MetaDao.remove(key) 추가 (purge 용)

테스트 31개 신규 추가 (총 62개 통과):
- test/domain/ai/{suggest_frame, few_shot_builder, parse_response}_test.dart
- test/data/ai/model_lifecycle_test.dart (download/SHA/purge/availability)

flutter analyze 0 issue, flutter build apk --debug 통과.

Refs #215
This commit is contained in:
2026-06-12 12:08:25 +09:00
parent d31b17f3e8
commit 6ab4c0da7d
20 changed files with 1735 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/ai/llm_service.dart';
import '../data/ai/model_lifecycle.dart';
import '../data/db/app_database.dart' as drift;
import '../domain/ai/frame_candidate.dart';
import '../domain/ai/suggest_frame.dart';
import '../domain/models/frame_pattern.dart';
import 'providers.dart';
/// Default config for the on-device Gemma model (#215).
/// OQ-1: URL + SHA-256 pinned in Developer phase. Until then, downloads are
/// disabled (AI toggle is gated behind these constants being real).
const _kModelUrlPlaceholder =
'https://example.invalid/gemma4-e2b-q4.bin'; // OQ-1
const _kModelShaPlaceholder = 'PENDING_OQ_1';
final modelLifecycleProvider = Provider<ModelLifecycle>((ref) {
return ModelLifecycle(
meta: ref.watch(metaDaoProvider),
config: ModelConfig(
url: Uri.parse(_kModelUrlPlaceholder),
expectedSha256: _kModelShaPlaceholder,
),
);
});
/// Read-only opt-in state. Default OFF; persisted in `meta_kv`.
final aiSettingsProvider = FutureProvider<bool>((ref) async {
final meta = ref.watch(metaDaoProvider);
final v = await meta.find(AiMetaKeys.optIn);
return v == 'true';
});
/// Toggles opt-in. On opt-out, purges model file via [ModelLifecycle.purge].
class AiSettingsController {
AiSettingsController(this.ref);
final Ref ref;
Future<int> setOptIn(bool value) async {
final meta = ref.read(metaDaoProvider);
if (value) {
await meta.put(AiMetaKeys.optIn, 'true');
ref.invalidate(aiSettingsProvider);
ref.invalidate(modelAvailabilityProvider);
return 0;
}
final freed = await ref.read(modelLifecycleProvider).purge();
await meta.put(AiMetaKeys.optIn, 'false');
ref.invalidate(aiSettingsProvider);
ref.invalidate(modelAvailabilityProvider);
return freed;
}
}
final aiSettingsControllerProvider = Provider<AiSettingsController>((ref) {
return AiSettingsController(ref);
});
final modelAvailabilityProvider =
FutureProvider<ModelAvailability>((ref) async {
final lc = ref.watch(modelLifecycleProvider);
return lc.checkAvailability();
});
/// Loads FramePatterns from DB and converts to domain models.
final framePatternsProvider = FutureProvider<List<FramePatternModel>>(
(ref) async {
final db = ref.watch(appDatabaseProvider);
final rows = await db.select(db.framePatterns).get();
return rows.map(_toDomain).toList(growable: false);
},
);
FramePatternModel _toDomain(drift.FramePattern r) => FramePatternModel(
id: r.id,
domain: r.domain,
avoidanceKeyword: r.avoidanceKeyword,
l0Example: r.l0Example,
l1SimpleReplace: r.l1SimpleReplace,
l2Suggestion: r.l2Suggestion,
l3Identity: r.l3Identity,
);
/// Singleton LLM service for the app. v1 starts unloaded; first
/// [suggestFrame] triggers `.load()` via the dialog. Override in tests with
/// `MockLlmService`.
final llmServiceProvider = Provider<LlmService>((ref) {
throw UnimplementedError(
'llmServiceProvider must be overridden (Mock in tests, '
'GemmaLlmService after OQ-1 in production).',
);
});
/// `family` param wraps a SuggestFrameInput. Loads model lazily before
/// calling suggestFrame.
final frameSuggestionsProvider = FutureProvider.autoDispose
.family<List<FrameCandidate>, SuggestFrameInput>((ref, input) async {
final llm = ref.watch(llmServiceProvider);
final patterns = await ref.watch(framePatternsProvider.future);
if (!llm.isLoaded) {
try {
await llm.load();
} catch (_) {
return const [];
}
}
return suggestFrame(
input,
llm: llm,
framePatterns: patterns,
);
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"

View File

@@ -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

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

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

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

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