Files
life-helper/app/lib/domain/ai/few_shot_builder.dart
joungmin 6ab4c0da7d [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
2026-06-12 12:08:25 +09:00

97 lines
3.2 KiB
Dart

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