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

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

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

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