From 6ab4c0da7d40a154407fa9ec5f3d6999fac4d428 Mon Sep 17 00:00:00 2001 From: joungmin Date: Fri, 12 Jun 2026 12:08:25 +0900 Subject: [PATCH] [Developer] #215 AI frame-suggest vertical slice (mock LlmService) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계서대로 구현. 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 --- app/lib/data/ai/gemma_llm_service.dart | 45 +++ app/lib/data/ai/llm_service.dart | 84 ++++++ app/lib/data/ai/model_lifecycle.dart | 262 ++++++++++++++++++ app/lib/data/db/daos/meta_dao.dart | 4 + app/lib/domain/ai/few_shot_builder.dart | 96 +++++++ app/lib/domain/ai/frame_candidate.dart | 42 +++ app/lib/domain/ai/parse_response.dart | 60 ++++ app/lib/domain/ai/suggest_frame.dart | 83 ++++++ app/lib/main.dart | 10 +- app/lib/state/ai_providers.dart | 113 ++++++++ app/lib/ui/screens/habit_create_screen.dart | 73 ++++- app/lib/ui/screens/habit_list_screen.dart | 16 +- app/lib/ui/screens/settings_screen.dart | 161 +++++++++++ .../ui/widgets/frame_suggestion_dialog.dart | 102 +++++++ app/pubspec.lock | 4 +- app/pubspec.yaml | 6 + app/test/data/ai/model_lifecycle_test.dart | 193 +++++++++++++ app/test/domain/ai/few_shot_builder_test.dart | 131 +++++++++ app/test/domain/ai/parse_response_test.dart | 90 ++++++ app/test/domain/ai/suggest_frame_test.dart | 165 +++++++++++ 20 files changed, 1735 insertions(+), 5 deletions(-) create mode 100644 app/lib/data/ai/gemma_llm_service.dart create mode 100644 app/lib/data/ai/llm_service.dart create mode 100644 app/lib/data/ai/model_lifecycle.dart create mode 100644 app/lib/domain/ai/few_shot_builder.dart create mode 100644 app/lib/domain/ai/frame_candidate.dart create mode 100644 app/lib/domain/ai/parse_response.dart create mode 100644 app/lib/domain/ai/suggest_frame.dart create mode 100644 app/lib/state/ai_providers.dart create mode 100644 app/lib/ui/screens/settings_screen.dart create mode 100644 app/lib/ui/widgets/frame_suggestion_dialog.dart create mode 100644 app/test/data/ai/model_lifecycle_test.dart create mode 100644 app/test/domain/ai/few_shot_builder_test.dart create mode 100644 app/test/domain/ai/parse_response_test.dart create mode 100644 app/test/domain/ai/suggest_frame_test.dart diff --git a/app/lib/data/ai/gemma_llm_service.dart b/app/lib/data/ai/gemma_llm_service.dart new file mode 100644 index 0000000..9e29163 --- /dev/null +++ b/app/lib/data/ai/gemma_llm_service.dart @@ -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 load() async { + throw UnimplementedError( + 'GemmaLlmService.load: pending OQ-1 (model URL + flutter_gemma).', + ); + } + + @override + Future unload() async { + _loaded = false; + } + + @override + Future> generateStructured( + String prompt, + Map schema, + ) async { + throw UnimplementedError( + 'GemmaLlmService.generateStructured: pending OQ-1.', + ); + } +} diff --git a/app/lib/data/ai/llm_service.dart b/app/lib/data/ai/llm_service.dart new file mode 100644 index 0000000..9e03947 --- /dev/null +++ b/app/lib/data/ai/llm_service.dart @@ -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 load(); + + Future unload(); + + /// Calls the model with a function-calling [schema] and returns the parsed + /// JSON arguments map. Caller is responsible for applying `.timeout(...)`. + Future> generateStructured( + String prompt, + Map 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? lastSchema; + Duration responseDelay = Duration.zero; + + @override + bool get isLoaded => _loaded; + + @override + Future load() async { + _loaded = true; + } + + @override + Future unload() async { + _loaded = false; + } + + void enqueueResponse(Map response) { + _queue.add(_Response.value(response)); + } + + void enqueueError(Object error) { + _queue.add(_Response.error(error)); + } + + @override + Future> generateStructured( + String prompt, + Map schema, + ) async { + callCount += 1; + lastPrompt = prompt; + lastSchema = schema; + if (!_loaded) { + throw StateError('LlmService not loaded'); + } + if (responseDelay > Duration.zero) { + await Future.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? value; + final Object? error; + const _Response._(this.value, this.error); + factory _Response.value(Map v) => _Response._(v, null); + factory _Response.error(Object e) => _Response._(null, e); +} diff --git a/app/lib/data/ai/model_lifecycle.dart b/app/lib/data/ai/model_lifecycle.dart new file mode 100644 index 0000000..b85ddcb --- /dev/null +++ b/app/lib/data/ai/model_lifecycle.dart @@ -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 = [ + 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 supportDir(); + Future rangeGet(Uri url, int from); +} + +class _ProdStorage implements StorageAdapter { + final http.Client client; + _ProdStorage(this.client); + @override + Future supportDir() => getApplicationSupportDirectory(); + @override + Future 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 _modelPath() async { + final dir = await _storage.supportDir(); + return p.join(dir.path, config.filename); + } + + Future 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 _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 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 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; + } +} diff --git a/app/lib/data/db/daos/meta_dao.dart b/app/lib/data/db/daos/meta_dao.dart index 7ab4496..00bbcdf 100644 --- a/app/lib/data/db/daos/meta_dao.dart +++ b/app/lib/data/db/daos/meta_dao.dart @@ -20,4 +20,8 @@ class MetaDao extends DatabaseAccessor with _$MetaDaoMixin { MetaKvCompanion.insert(key: key, value: value), ); } + + Future remove(String key) async { + await (delete(metaKv)..where((t) => t.key.equals(key))).go(); + } } diff --git a/app/lib/domain/ai/few_shot_builder.dart b/app/lib/domain/ai/few_shot_builder.dart new file mode 100644 index 0000000..42adae1 --- /dev/null +++ b/app/lib/domain/ai/few_shot_builder.dart @@ -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 framePatterns, { + int maxFewShot = 5, +}) { + final tokens = _tokenize(input.rawText); + final scored = >[]; + 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 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 _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 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; +} diff --git a/app/lib/domain/ai/frame_candidate.dart b/app/lib/domain/ai/frame_candidate.dart new file mode 100644 index 0000000..fe69f3d --- /dev/null +++ b/app/lib/domain/ai/frame_candidate.dart @@ -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, + }); +} diff --git a/app/lib/domain/ai/parse_response.dart b/app/lib/domain/ai/parse_response.dart new file mode 100644 index 0000000..7856b64 --- /dev/null +++ b/app/lib/domain/ai/parse_response.dart @@ -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 parseFrameCandidates(Map 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 = []; + 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; +} diff --git a/app/lib/domain/ai/suggest_frame.dart b/app/lib/domain/ai/suggest_frame.dart new file mode 100644 index 0000000..4b9e141 --- /dev/null +++ b/app/lib/domain/ai/suggest_frame.dart @@ -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 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> suggestFrame( + SuggestFrameInput input, { + required LlmService llm, + required List framePatterns, + Duration timeout = _defaultTimeout, +}) async { + final raw = input.rawText.trim(); + if (raw.isEmpty || raw.length > _maxRawTextLength) { + return const []; + } + + final prompt = buildFewShotPrompt(input, framePatterns); + + Map json; + try { + json = await llm + .generateStructured(prompt, kFrameCandidatesSchema) + .timeout(timeout); + } catch (_) { + // Timeout / StateError / FormatException / anything else: graceful. + return const []; + } + + List candidates; + try { + candidates = parseFrameCandidates(json); + } on FormatException { + return const []; + } + + // Drop L0/L1 + avoidance-violating via validateFrameLevel. + final validated = []; + 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; +} diff --git a/app/lib/main.dart b/app/lib/main.dart index 41ebd84..29be514 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -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 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(), )); } diff --git a/app/lib/state/ai_providers.dart b/app/lib/state/ai_providers.dart new file mode 100644 index 0000000..0e1a2e1 --- /dev/null +++ b/app/lib/state/ai_providers.dart @@ -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((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((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 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((ref) { + return AiSettingsController(ref); +}); + +final modelAvailabilityProvider = + FutureProvider((ref) async { + final lc = ref.watch(modelLifecycleProvider); + return lc.checkAvailability(); +}); + +/// Loads FramePatterns from DB and converts to domain models. +final framePatternsProvider = FutureProvider>( + (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((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, 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, + ); +}); diff --git a/app/lib/ui/screens/habit_create_screen.dart b/app/lib/ui/screens/habit_create_screen.dart index c04e88d..db1bbb8 100644 --- a/app/lib/ui/screens/habit_create_screen.dart +++ b/app/lib/ui/screens/habit_create_screen.dart @@ -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 { 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 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, + ); + } + }, + ), + ); + } +} diff --git a/app/lib/ui/screens/habit_list_screen.dart b/app/lib/ui/screens/habit_list_screen.dart index 1c4af3f..ce2514b 100644 --- a/app/lib/ui/screens/habit_list_screen.dart +++ b/app/lib/ui/screens/habit_list_screen.dart @@ -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')), diff --git a/app/lib/ui/screens/settings_screen.dart b/app/lib/ui/screens/settings_screen.dart new file mode 100644 index 0000000..f98d36e --- /dev/null +++ b/app/lib/ui/screens/settings_screen.dart @@ -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 _confirmOptIn(BuildContext context) { + return showDialog( + 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 _confirmOptOut(BuildContext context) { + return showDialog( + 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('끄고 삭제'), + ), + ], + ), + ); + } +} diff --git a/app/lib/ui/widgets/frame_suggestion_dialog.dart b/app/lib/ui/widgets/frame_suggestion_dialog.dart new file mode 100644 index 0000000..1c2a3ee --- /dev/null +++ b/app/lib/ui/widgets/frame_suggestion_dialog.dart @@ -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 show( + BuildContext context, { + required SuggestFrameInput input, + }) { + return showDialog( + 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, + ), + ); + } +} diff --git a/app/pubspec.lock b/app/pubspec.lock index 85b27be..33222b9 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -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" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 2110c88..86a644b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/app/test/data/ai/model_lifecycle_test.dart b/app/test/data/ai/model_lifecycle_test.dart new file mode 100644 index 0000000..84015f8 --- /dev/null +++ b/app/test/data/ai/model_lifecycle_test.dart @@ -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 Function(Uri url, int from)? handler; + + @override + Future supportDir() async => dir; + + @override + Future rangeGet(Uri url, int from) { + final h = handler; + if (h == null) { + throw StateError('no handler'); + } + return h(url, from); + } +} + +http.StreamedResponse _streamed( + List 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); + }); +} diff --git a/app/test/domain/ai/few_shot_builder_test.dart b/app/test/domain/ai/few_shot_builder_test.dart new file mode 100644 index 0000000..f376805 --- /dev/null +++ b/app/test/domain/ai/few_shot_builder_test.dart @@ -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 = [ + 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); + }); +} diff --git a/app/test/domain/ai/parse_response_test.dart b/app/test/domain/ai/parse_response_test.dart new file mode 100644 index 0000000..f2c6552 --- /dev/null +++ b/app/test/domain/ai/parse_response_test.dart @@ -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()), + ); + }); + + test('candidates not list → FormatException', () { + expect( + () => parseFrameCandidates({'candidates': 'oops'}), + throwsA(isA()), + ); + }); + + 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'); + }); +} diff --git a/app/test/domain/ai/suggest_frame_test.dart b/app/test/domain/ai/suggest_frame_test.dart new file mode 100644 index 0000000..845b505 --- /dev/null +++ b/app/test/domain/ai/suggest_frame_test.dart @@ -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 = [ + 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)); + }); +}