[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:
96
app/lib/domain/ai/few_shot_builder.dart
Normal file
96
app/lib/domain/ai/few_shot_builder.dart
Normal 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;
|
||||
}
|
||||
42
app/lib/domain/ai/frame_candidate.dart
Normal file
42
app/lib/domain/ai/frame_candidate.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
60
app/lib/domain/ai/parse_response.dart
Normal file
60
app/lib/domain/ai/parse_response.dart
Normal 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;
|
||||
}
|
||||
83
app/lib/domain/ai/suggest_frame.dart
Normal file
83
app/lib/domain/ai/suggest_frame.dart
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user