설계서대로 구현. 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
132 lines
3.8 KiB
Dart
132 lines
3.8 KiB
Dart
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);
|
|
});
|
|
}
|