[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,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);
});
}