Compare commits
4 Commits
v0.4.2
...
8b11d77c97
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b11d77c97 | |||
| b8e74d7dd9 | |||
| c18dca1def | |||
| e81f3e44a4 |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -8,6 +8,19 @@
|
||||
### Fixed (Redmine #342)
|
||||
- **ChatScreen 하단 잘림** — Android edge-to-edge 모드에서 시스템 nav bar (3-button / gesture handle) 가 입력창을 덮던 문제. `Scaffold.body` 를 `SafeArea(top: false, …)` 로 감쌈. AppBar 가 이미 top inset 처리하므로 top 만 false.
|
||||
|
||||
### UX round 1 — raw enum 노출 정리 (Redmine #342 추가)
|
||||
- **습관 카드 부제** — `build · L3 · …` (raw enum) → `만들기 · …`. FrameLevel 노출 제거 (시스템 규약이라 사용자 가치 낮음).
|
||||
- **스트릭 화면 현재 티어** — `T0` / `T1` raw → `🌱 새싹` / `🥉 3회 도전` / `🥈 7일 형성` / `🥇 30일 정착` / `🏆 6주 완주` 이모지+한국어 라벨.
|
||||
- **스트릭 강등 경고** — `Never miss twice 발동 — 티어 강등` (영문 잠언) → `이틀 연속 빠졌어요. 한 단계 강등됐습니다.`.
|
||||
- **스트릭 hero 위계** — 현재 스트릭을 `displayLarge` 큰 숫자 + 티어 라벨로 시각 강조 (사용자의 핵심 동기 지표).
|
||||
- **습관 추가 드롭다운** — `만들기 (build)` → `만들기` (영어 식별자 병기 제거).
|
||||
- 신규 `app/lib/ui/labels.dart` — domain enum 의 한국어 라벨 매핑 단일 지점. domain layer 에 `koreanLabel` 두지 않음 (관심사 분리).
|
||||
|
||||
### UX round 2 — 빈 상태 + 날짜 + 라벨 명확화 (Redmine #342 추가)
|
||||
- **ChatScreen 빈 상태 안내** — 첫 진입 시 빈 메시지 리스트 대신 아이콘 + 한 줄 설명 + 예시 prompt 4개 (`아침 햇빛 받기 습관 추가해줘`, `오늘 운동 했어`, `내 스트릭 보여줘`, `수면 프로토콜 알려줘`). tap → 입력창 자동 채움 (자동 send X, 사용자 수정 여지).
|
||||
- **CheckIn 날짜 한국식** — `2026-06-15` raw → `6월 15일 (월)`. DB 저장은 `_ymd` 유지.
|
||||
- **HabitCreate 표현 방식** — `프레임 레벨` (의미 모호) → `표현 방식` + helperText `행동 위주 vs 정체성 위주`. 아이템 라벨 `L2 · 조건부 긍정` / `L3 · 정체성` → `조건부 행동 (예: 아침에 햇빛 받기)` / `정체성 (예: 나는 일찍 자는 사람)` 식 예시 포함.
|
||||
|
||||
### Dev
|
||||
- **LLM 실패 빨간 배너에 full message + stack trace** — 단말 진단을 위해 release 빌드에서도 노출. `LLM 응답 실패: <Type>\n<message>\n--- STACK ---\n<stack>` 형식. SelectableText + monospace + 최대 화면 1/3 높이 + scroll. 사용자 친화 메시지로 좁히는 작업은 #342 종료 후 follow-up.
|
||||
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- Debug-only: emulator testing pulls the Gemma model from a host-local
|
||||
HTTP server (GEMMA_MODEL_URL=http://10.0.2.2:...). Release builds
|
||||
keep the platform default (cleartext blocked). -->
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
|
||||
@@ -13,6 +13,20 @@ import 'llm_service.dart';
|
||||
/// local file path generally does not require the token.
|
||||
const String _hfToken = String.fromEnvironment('HF_TOKEN', defaultValue: '');
|
||||
|
||||
/// Backend override for dev builds. The Android emulator advertises a
|
||||
/// software Vulkan adapter (SwiftShader) that SIGABRTs LiteRT-LM's GPU
|
||||
/// init natively — the Dart-level gpu→cpu fallback can't catch a native
|
||||
/// abort, so emulator runs must force CPU: `--dart-define=LLM_BACKEND=cpu`.
|
||||
/// Unset (default) keeps the SDK's gpu→cpu fallback for real devices.
|
||||
const String _backendOverride =
|
||||
String.fromEnvironment('LLM_BACKEND', defaultValue: '');
|
||||
|
||||
PreferredBackend? get _preferredBackend => switch (_backendOverride) {
|
||||
'cpu' => PreferredBackend.cpu,
|
||||
'gpu' => PreferredBackend.gpu,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// One-shot guard so [FlutterGemma.initialize] runs at most once per
|
||||
/// isolate. Re-init is unsupported by the underlying plugin.
|
||||
bool _initialized = false;
|
||||
@@ -70,7 +84,15 @@ class GemmaLlmService implements LlmService {
|
||||
modelType: ModelType.gemma4,
|
||||
fileType: ModelFileType.litertlm,
|
||||
).fromFile(modelPath).install();
|
||||
final model = await FlutterGemma.getActiveModel(maxTokens: 2048);
|
||||
// #342 root cause was the model *filename* (.bin — LiteRT-LM rejects it;
|
||||
// must be .litertlm), NOT the KV cache size. maxTokens stays 2048: the
|
||||
// Gemma 4 E2B compiled graph requires a cache ≥ its prefill signature —
|
||||
// 1024 fails tensor allocation (DYNAMIC_UPDATE_SLICE prepare, verified
|
||||
// on-emulator 2026-07-09).
|
||||
final model = await FlutterGemma.getActiveModel(
|
||||
maxTokens: 2048,
|
||||
preferredBackend: _preferredBackend,
|
||||
);
|
||||
_model = model;
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
@@ -66,11 +66,16 @@ class _ProdStorage implements StorageAdapter {
|
||||
class ModelConfig {
|
||||
final Uri url;
|
||||
final String expectedSha256;
|
||||
|
||||
/// MUST keep the `.litertlm` extension: LiteRT-LM sniffs the container
|
||||
/// format from the file extension, and a `.bin` name makes native
|
||||
/// engine_create fail with "Model may be invalid" on every backend
|
||||
/// (#342 root cause, verified on-emulator 2026-07-09).
|
||||
final String filename;
|
||||
const ModelConfig({
|
||||
required this.url,
|
||||
required this.expectedSha256,
|
||||
this.filename = 'gemma4_e2b_q4.bin',
|
||||
this.filename = 'gemma-4-E2B-it.litertlm',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,6 +99,26 @@ class ModelLifecycle {
|
||||
return p.join(dir.path, config.filename);
|
||||
}
|
||||
|
||||
/// #657 AC-2/3/4: pre-.litertlm installs saved the model as
|
||||
/// `gemma4_e2b_q4.bin`, which LiteRT-LM's engine_create rejects. Renaming
|
||||
/// the existing file (same bytes — SHA meta stays valid) spares those
|
||||
/// users a 2.4GB re-download. Idempotent; a failed rename leaves state
|
||||
/// untouched so the next availability check retries.
|
||||
Future<void> _migrateLegacyPath() async {
|
||||
final metaPath = await meta.find(AiMetaKeys.modelPath);
|
||||
if (metaPath == null) return;
|
||||
final canonical = await _modelPath();
|
||||
if (metaPath == canonical) return;
|
||||
final legacy = File(metaPath);
|
||||
if (!legacy.existsSync()) return;
|
||||
try {
|
||||
await legacy.rename(canonical);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
await meta.put(AiMetaKeys.modelPath, canonical);
|
||||
}
|
||||
|
||||
/// Lightweight ready estimate for warm-up gating (#311).
|
||||
///
|
||||
/// Skips the SHA-256 re-hash that [checkAvailability] performs — for a
|
||||
@@ -117,6 +142,8 @@ class ModelLifecycle {
|
||||
return ModelAvailability.downloading;
|
||||
}
|
||||
|
||||
await _migrateLegacyPath();
|
||||
|
||||
final pathStr = await meta.find(AiMetaKeys.modelPath);
|
||||
if (pathStr == null) return ModelAvailability.missing;
|
||||
|
||||
@@ -142,6 +169,8 @@ class ModelLifecycle {
|
||||
return ModelAvailability.downloading;
|
||||
}
|
||||
|
||||
await _migrateLegacyPath();
|
||||
|
||||
final pathStr = await meta.find(AiMetaKeys.modelPath);
|
||||
if (pathStr == null) return ModelAvailability.missing;
|
||||
|
||||
|
||||
@@ -16,10 +16,15 @@ import 'providers.dart';
|
||||
/// File ≈ 2.41GB; SHA-256 pinned for integrity check.
|
||||
///
|
||||
/// Tests / placeholder builds may override `modelLifecycleProvider` with
|
||||
/// fixture URLs. Production builds optionally inject a private mirror via
|
||||
/// `--dart-define=GEMMA_MODEL_URL=...` (see main.dart).
|
||||
const _kModelUrl =
|
||||
'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm';
|
||||
/// fixture URLs. Dev/mirror builds may inject an alternate download origin
|
||||
/// via `--dart-define=GEMMA_MODEL_URL=...` (e.g. a host-local server when
|
||||
/// testing on the Android emulator). SHA-256 stays pinned regardless of
|
||||
/// origin, so a tampered mirror still fails verification.
|
||||
const _kModelUrl = String.fromEnvironment(
|
||||
'GEMMA_MODEL_URL',
|
||||
defaultValue:
|
||||
'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm',
|
||||
);
|
||||
const _kModelSha256 =
|
||||
'181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c';
|
||||
|
||||
|
||||
44
app/lib/ui/labels.dart
Normal file
44
app/lib/ui/labels.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import '../domain/models/habit.dart';
|
||||
import '../domain/streak/compute_streak.dart';
|
||||
|
||||
/// UI 한국어 라벨 매핑. domain enum 의 `dbValue` 는 DB 직렬화용이므로
|
||||
/// 사용자에게 그대로 노출하면 'build', 'L3', 'T0' 같은 raw 식별자가
|
||||
/// 그대로 보인다. 본 헬퍼는 그걸 한국어 표현으로 바꾼다.
|
||||
|
||||
String habitTypeLabel(HabitType t) {
|
||||
switch (t) {
|
||||
case HabitType.build:
|
||||
return '만들기';
|
||||
case HabitType.breakHabit:
|
||||
return '없애기';
|
||||
}
|
||||
}
|
||||
|
||||
/// Drift row (raw db String) 에서 직접 매핑. 'build' / 'break' 외의 값은
|
||||
/// 그대로 노출해 invariant 위반을 가시화.
|
||||
String habitTypeLabelFromDb(String dbValue) {
|
||||
switch (dbValue) {
|
||||
case 'build':
|
||||
return '만들기';
|
||||
case 'break':
|
||||
return '없애기';
|
||||
default:
|
||||
return dbValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// 5-Tier Reward Ladder (T0 새싹 → T4 6주 완주). milestone 누적 보상.
|
||||
String rewardTierLabel(RewardTier t) {
|
||||
switch (t) {
|
||||
case RewardTier.t0:
|
||||
return '🌱 새싹';
|
||||
case RewardTier.t1:
|
||||
return '🥉 3회 도전';
|
||||
case RewardTier.t2:
|
||||
return '🥈 7일 형성';
|
||||
case RewardTier.t3:
|
||||
return '🥇 30일 정착';
|
||||
case RewardTier.t4:
|
||||
return '🏆 6주 완주';
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
child: state.messages.isEmpty && state.streamingText == null
|
||||
? _EmptyChatHint(onPickPrompt: (p) {
|
||||
_textCtrl.text = p;
|
||||
_textCtrl.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: p.length),
|
||||
);
|
||||
})
|
||||
: ListView.builder(
|
||||
controller: _scrollCtrl,
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: state.messages.length +
|
||||
@@ -248,6 +255,73 @@ class _WarmupErrorBanner extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 첫 진입 시 빈 대화창 안내. 사용자가 무엇을 물어볼 수 있는지
|
||||
/// 예시 prompt 4개 + tool 카테고리 짧은 안내. tap 하면 입력창에 채워지고
|
||||
/// 사용자가 보내기만 누르면 됨 (자동 send X — 사용자가 문구 수정 여지).
|
||||
class _EmptyChatHint extends StatelessWidget {
|
||||
final ValueChanged<String> onPickPrompt;
|
||||
const _EmptyChatHint({required this.onPickPrompt});
|
||||
|
||||
static const _examples = [
|
||||
'아침 햇빛 받기 습관 추가해줘',
|
||||
'오늘 운동 했어',
|
||||
'내 스트릭 보여줘',
|
||||
'수면 프로토콜 알려줘',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.smart_toy_outlined,
|
||||
size: 48,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'AI 코치',
|
||||
style: theme.textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'습관 추가·기록 · 카탈로그 검색 · 스트릭 조회를 도와드려요.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'예시',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._examples.map((p) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: OutlinedButton(
|
||||
onPressed: () => onPickPrompt(p),
|
||||
style: OutlinedButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 12),
|
||||
),
|
||||
child: Text(p),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-friendly Korean labels for the 6 tools registered in
|
||||
/// `ToolRegistry.defaults()`. Falls back to the raw tool name for any
|
||||
/// future tool that hasn't been mapped yet — better to show the raw id
|
||||
|
||||
@@ -50,7 +50,7 @@ class _CheckInScreenState extends ConsumerState<CheckInScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('오늘 (${_ymd(nowKst())})',
|
||||
Text('오늘 · ${_koreanDate(nowKst())}',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 32),
|
||||
@@ -80,3 +80,9 @@ String _ymd(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-'
|
||||
'${d.month.toString().padLeft(2, '0')}-'
|
||||
'${d.day.toString().padLeft(2, '0')}';
|
||||
|
||||
/// 사용자 노출용 한국식 날짜 — '6월 15일 (월)'. DB 저장은 _ymd 가 담당.
|
||||
String _koreanDate(DateTime d) {
|
||||
const weekdays = ['월', '화', '수', '목', '금', '토', '일'];
|
||||
return '${d.month}월 ${d.day}일 (${weekdays[d.weekday - 1]})';
|
||||
}
|
||||
|
||||
@@ -94,9 +94,8 @@ class _HabitCreateScreenState extends ConsumerState<HabitCreateScreen> {
|
||||
DropdownButtonFormField<HabitType>(
|
||||
initialValue: _type,
|
||||
items: const [
|
||||
DropdownMenuItem(value: HabitType.build, child: Text('만들기 (build)')),
|
||||
DropdownMenuItem(
|
||||
value: HabitType.breakHabit, child: Text('없애기 (break)')),
|
||||
DropdownMenuItem(value: HabitType.build, child: Text('만들기')),
|
||||
DropdownMenuItem(value: HabitType.breakHabit, child: Text('없애기')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _type = v ?? HabitType.build),
|
||||
decoration: const InputDecoration(labelText: '타입'),
|
||||
@@ -105,11 +104,16 @@ class _HabitCreateScreenState extends ConsumerState<HabitCreateScreen> {
|
||||
DropdownButtonFormField<FrameLevel>(
|
||||
initialValue: _level,
|
||||
items: const [
|
||||
DropdownMenuItem(value: FrameLevel.l2, child: Text('L2 · 조건부 긍정')),
|
||||
DropdownMenuItem(value: FrameLevel.l3, child: Text('L3 · 정체성')),
|
||||
DropdownMenuItem(
|
||||
value: FrameLevel.l2, child: Text('조건부 행동 (예: 아침에 햇빛 받기)')),
|
||||
DropdownMenuItem(
|
||||
value: FrameLevel.l3, child: Text('정체성 (예: 나는 일찍 자는 사람)')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _level = v ?? FrameLevel.l2),
|
||||
decoration: const InputDecoration(labelText: '프레임 레벨'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '표현 방식',
|
||||
helperText: '문구를 어떻게 적을지 — 행동 위주 vs 정체성 위주',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../state/ai_providers.dart';
|
||||
import '../../state/providers.dart';
|
||||
import '../labels.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'check_in_screen.dart';
|
||||
import 'habit_create_screen.dart';
|
||||
@@ -85,8 +86,10 @@ class HabitListScreen extends ConsumerWidget {
|
||||
final h = habits[i];
|
||||
return ListTile(
|
||||
title: Text(h.title),
|
||||
// FrameLevel (L2/L3) 은 시스템 규약이라 사용자에게 노출
|
||||
// 가치 낮음 — type chip + framedText 만 표시.
|
||||
subtitle: Text(
|
||||
'${h.type} · ${h.frameLevel} · ${h.frameFramedText}',
|
||||
'${habitTypeLabelFromDb(h.type)} · ${h.frameFramedText}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../core/time.dart';
|
||||
import '../../domain/models/tracker_entry.dart';
|
||||
import '../../domain/streak/compute_streak.dart';
|
||||
import '../../state/providers.dart';
|
||||
import '../labels.dart';
|
||||
|
||||
class StreakScreen extends ConsumerWidget {
|
||||
final String habitId;
|
||||
@@ -51,25 +52,43 @@ class StreakScreen extends ConsumerWidget {
|
||||
asOf: nowKst(),
|
||||
habitStartedAt: habit.startedAt as String,
|
||||
);
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(habit.title as String,
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 24),
|
||||
_Row('현재 스트릭', '${state.currentStreak}일'),
|
||||
// Hero — 핵심 동기 지표. 큰 숫자 + 티어 emoji 라벨로 위계 강조.
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'${state.currentStreak}',
|
||||
style: theme.textTheme.displayLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Text('일 연속 (현재 스트릭)',
|
||||
style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(rewardTierLabel(state.currentTier),
|
||||
style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 40),
|
||||
_Row('최장 스트릭', '${state.longestStreak}일'),
|
||||
_Row('최근 30일 / 완료', '${state.doneCountInWindow30}회'),
|
||||
_Row('Phase 42일 / 완료', '${state.doneCountInPhase42}회'),
|
||||
const Divider(height: 32),
|
||||
_Row('현재 티어', state.currentTier.dbValue),
|
||||
if (state.neverMissTwiceBroken)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'⚠ Never miss twice 발동 — 티어 강등',
|
||||
'⚠ 이틀 연속 빠졌어요. 한 단계 강등됐습니다.',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -250,4 +250,80 @@ void main() {
|
||||
await meta.put(AiMetaKeys.modelSha, 'expected_but_actual_will_differ');
|
||||
expect(await lc.checkAvailability(), ModelAvailability.corrupt);
|
||||
});
|
||||
|
||||
group('#657 legacy .bin filename migration', () {
|
||||
// Pre-.litertlm installs: file on disk + meta path both use the old
|
||||
// `gemma4_e2b_q4.bin` name that LiteRT-LM rejects.
|
||||
const legacyName = 'gemma4_e2b_q4.bin';
|
||||
const canonicalName = 'model.litertlm';
|
||||
|
||||
ModelLifecycle makeLc(String expectedSha) => ModelLifecycle(
|
||||
meta: meta,
|
||||
config: ModelConfig(
|
||||
url: Uri.parse(url),
|
||||
expectedSha256: expectedSha,
|
||||
filename: canonicalName,
|
||||
),
|
||||
storage: storage,
|
||||
);
|
||||
|
||||
Future<String> seedLegacy(List<int> payload) async {
|
||||
final legacyPath = '${tmp.path}/$legacyName';
|
||||
File(legacyPath).writeAsBytesSync(payload);
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
await meta.put(AiMetaKeys.modelPath, legacyPath);
|
||||
await meta.put(
|
||||
AiMetaKeys.modelSha, sha256.convert(payload).toString());
|
||||
await meta.put(AiMetaKeys.downloadState, 'completed');
|
||||
return legacyPath;
|
||||
}
|
||||
|
||||
test('quickCheck renames legacy file + updates meta path (AC-2)',
|
||||
() async {
|
||||
final payload = utf8.encode('legacy model bytes');
|
||||
final legacyPath = await seedLegacy(payload);
|
||||
final lc = makeLc(sha256.convert(payload).toString());
|
||||
|
||||
expect(await lc.quickCheck(), ModelAvailability.ready);
|
||||
expect(File(legacyPath).existsSync(), isFalse);
|
||||
final canonicalPath = '${tmp.path}/$canonicalName';
|
||||
expect(File(canonicalPath).existsSync(), isTrue);
|
||||
expect(await meta.find(AiMetaKeys.modelPath), canonicalPath);
|
||||
});
|
||||
|
||||
test('checkAvailability migrates and SHA meta stays valid (AC-2)',
|
||||
() async {
|
||||
final payload = utf8.encode('legacy model bytes');
|
||||
await seedLegacy(payload);
|
||||
final lc = makeLc(sha256.convert(payload).toString());
|
||||
|
||||
expect(await lc.checkAvailability(), ModelAvailability.ready);
|
||||
expect(File('${tmp.path}/$canonicalName').existsSync(), isTrue);
|
||||
});
|
||||
|
||||
test('migration is idempotent — second check is a no-op (AC-3)',
|
||||
() async {
|
||||
final payload = utf8.encode('legacy model bytes');
|
||||
await seedLegacy(payload);
|
||||
final lc = makeLc(sha256.convert(payload).toString());
|
||||
|
||||
expect(await lc.quickCheck(), ModelAvailability.ready);
|
||||
expect(await lc.quickCheck(), ModelAvailability.ready);
|
||||
expect(
|
||||
await meta.find(AiMetaKeys.modelPath),
|
||||
'${tmp.path}/$canonicalName',
|
||||
);
|
||||
});
|
||||
|
||||
test('meta path file gone → skip migration, stays missing (AC-4)',
|
||||
() async {
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
await meta.put(AiMetaKeys.modelPath, '${tmp.path}/$legacyName');
|
||||
await meta.put(AiMetaKeys.modelSha, 'irrelevant');
|
||||
|
||||
final lc = makeLc('irrelevant');
|
||||
expect(await lc.quickCheck(), ModelAvailability.missing);
|
||||
expect(File('${tmp.path}/$canonicalName').existsSync(), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
116
docs/design/342-v042-hotfix/README.md
Normal file
116
docs/design/342-v042-hotfix/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 설계서: v0.4.2 hotfix — ChatScreen SafeArea + LLM 진단 + UX round 1 (#342)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #342 · 관련 ADR: 없음
|
||||
> · 구현 파일: `app/lib/ui/screens/chat_screen.dart`, `app/lib/state/chat_providers.dart`, `app/lib/ui/labels.dart`, `app/lib/ui/screens/habit_list_screen.dart`, `app/lib/ui/screens/streak_screen.dart`, `app/lib/ui/screens/habit_create_screen.dart`
|
||||
> · 테스트: 기존 167 회귀 (신규 추가 없음 — string label / SafeArea wrap 라 단위 가치 낮음)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
v0.4.1 실 단말 (Android, 사용자 본인) 첫 테스트에서 발견된 사용성·진단 격차 묶음. 모두 dev 단계 신속 hotfix.
|
||||
|
||||
1. **A — ChatScreen 입력창 가림**: edge-to-edge 모드의 시스템 nav bar 가 send 버튼/입력창을 덮어 사용 불가.
|
||||
2. **B — LLM 실패 원인 불명**: warm-up 은 Ready 까지 가지만 send 실패 시 빨간 배너에 `LLM 응답 실패: <Type>` 만 떠 원인 진단 불가.
|
||||
3. **C — UX round 1 (raw enum 노출)**: 습관 카드/스트릭/추가 화면이 Drift row 의 `'build'` / `RewardTier.dbValue('T0')` / `'Never miss twice'` 같은 식별자를 그대로 사용자에 노출.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
- **포함**:
|
||||
- A. `ChatScreen` Scaffold.body → `SafeArea(top: false, …)`.
|
||||
- B. `userTurn` catch 가 `e.toString() + stack` 전체를 error state 에 저장. ChatScreen 빨간 배너를 `SingleChildScrollView + SelectableText` (monospace, 12pt, 최대 1/3 높이) 로 교체.
|
||||
- C. `app/lib/ui/labels.dart` 신규 — `habitTypeLabel(HabitType)`, `habitTypeLabelFromDb(String)`, `rewardTierLabel(RewardTier)`. P0 3건 + P1 2건.
|
||||
- D. UX round 2 — chat 빈 상태 안내 (예시 prompt 4 tap-to-fill), check_in 한국식 날짜 (`6월 15일 (월)`), habit_create "프레임 레벨" → "표현 방식" + 예시 부연.
|
||||
- **제외 (out of scope)**:
|
||||
- LLM `BackendInitException: model may be invalid` 실 원인 (GPU KV cache RESOURCE_EXHAUSTED 후보) — 단말 빌드/설치 비용 때문에 별도 사이클로 분리. follow-up 이슈 발행 예정.
|
||||
- release 빌드에서 stack 숨김 (사용자 친화 메시지로 좁히기) — #342 종료 후 follow-up.
|
||||
|
||||
## 3. 인수조건 (Acceptance Criteria)
|
||||
- [x] **AC-A1** Pixel/Galaxy gesture nav 단말에서 입력창 + send 버튼이 nav bar 와 겹치지 않음.
|
||||
- [x] **AC-B1** send 실패 시 빨간 배너에 `LLM 응답 실패: <Type>\n<message>\n--- STACK ---\n<stack>` 표시. SelectableText 라 복사 가능.
|
||||
- [x] **AC-B2** 빨간 배너 높이는 화면의 1/3 을 넘지 않고, 내부 스크롤로 전체 노출.
|
||||
- [x] **AC-C1** 습관 카드 부제가 `build · L3 · …` → `만들기 · …` (frameLevel 식별자 제거).
|
||||
- [x] **AC-C2** 스트릭 화면 현재 티어가 `T0` / `T1` (raw) → `🌱 새싹` / `🥉 3회 도전` ….
|
||||
- [x] **AC-C3** 스트릭 화면 강등 경고가 `Never miss twice 발동 — 티어 강등` → `이틀 연속 빠졌어요. 한 단계 강등됐습니다.` (영문 잠언 제거).
|
||||
- [x] **AC-C4** 습관 추가 드롭다운이 `만들기 (build)` → `만들기` (식별자 병기 제거).
|
||||
- [x] **AC-C5** 스트릭 화면의 현재 스트릭이 `displayLarge` hero + 티어 라벨로 시각 위계 강조.
|
||||
- [x] **AC-D1** ChatScreen 첫 진입 시 빈 메시지 리스트 대신 안내 (아이콘 + 한 줄 설명 + 예시 prompt 4개). tap → 입력창 자동 채움 (자동 send X — 사용자 수정 여지).
|
||||
- [x] **AC-D2** CheckIn 화면 날짜 `2026-06-15` raw → `6월 15일 (월)` 한국식. DB 저장은 `_ymd` 유지.
|
||||
- [x] **AC-D3** HabitCreate 의 `프레임 레벨` → `표현 방식` (+ helperText `행동 위주 vs 정체성 위주`). 아이템 라벨 `L2 · 조건부 긍정` → `조건부 행동 (예: 아침에 햇빛 받기)` 식 예시 포함.
|
||||
- [x] **AC-D** 167 기존 테스트 회귀 없음, `flutter analyze` clean.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
- **의존성**: flutter_gemma 0.16.5 (B 변경 안 함), Riverpod 2.x, Drift row 의 raw String enum.
|
||||
- **제약**:
|
||||
- dev 단계 hotfix — release 노출 가능한 stack 도 허용 (사용자 본인 단말 진단 우선).
|
||||
- C 의 라벨 매핑은 UI 레이어 단일 지점 (`ui/labels.dart`) — domain enum 에 `koreanLabel` 두지 않음 (관심사 분리).
|
||||
- **가정**:
|
||||
- `h.type` 은 Drift row 의 String — `HabitTypeX.dbValue` 와 동일한 wire 값 (`'build'` / `'break'`).
|
||||
- `RewardTier` 의 사용자 명칭은 메모리상 5-Tier 정의 — 🌱 새싹 / 🥉 3회 / 🥈 7일 / 🥇 30일 / 🏆 6주 완주.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
순수 string 매핑 + Widget tree 재구성. 신규 모듈 없음.
|
||||
|
||||
```
|
||||
ChatScreen
|
||||
├─ Scaffold.body — SafeArea(top: false) ← AC-A1
|
||||
│ └─ Column
|
||||
│ ├─ _WarmupErrorBanner (변경 없음)
|
||||
│ ├─ Container(error) ← AC-B1/B2
|
||||
│ │ constraints: maxHeight: screen/3
|
||||
│ │ child: SingleChildScrollView(SelectableText, monospace 12pt)
|
||||
│ └─ ListView (변경 없음)
|
||||
|
||||
ChatSessionController.userTurn ← AC-B1
|
||||
└─ catch (e, st) → state.error = "LLM 응답 실패: ${e.runtimeType}\n$e\n\n--- STACK ---\n$st"
|
||||
|
||||
ui/labels.dart ← AC-C1~C4
|
||||
├─ habitTypeLabel(HabitType) → '만들기' / '없애기'
|
||||
├─ habitTypeLabelFromDb(String) → ↑ (Drift raw 분기, 기본 fallback = dbValue)
|
||||
└─ rewardTierLabel(RewardTier) → '🌱 새싹' / '🥉 3회 도전' / … / '🏆 6주 완주'
|
||||
|
||||
habit_list_screen / streak_screen / habit_create_screen
|
||||
└─ raw enum 노출 지점 모두 labels.dart 의 함수로 교체
|
||||
```
|
||||
|
||||
## 6. 데이터 모델
|
||||
신규 모델 없음. 매핑 도메인은 기존 enum (`HabitType`, `FrameLevel`, `RewardTier`) 의 표현 레이어만 분리.
|
||||
|
||||
| Enum | Raw (DB/wire) | UI 라벨 |
|
||||
|---|---|---|
|
||||
| `HabitType.build` | `'build'` | `만들기` |
|
||||
| `HabitType.breakHabit` | `'break'` | `없애기` |
|
||||
| `RewardTier.t0` | `'T0'` | `🌱 새싹` |
|
||||
| `RewardTier.t1` | `'T1'` | `🥉 3회 도전` |
|
||||
| `RewardTier.t2` | `'T2'` | `🥈 7일 형성` |
|
||||
| `RewardTier.t3` | `'T3'` | `🥇 30일 정착` |
|
||||
| `RewardTier.t4` | `'T4'` | `🏆 6주 완주` |
|
||||
|
||||
`FrameLevel` 은 본 hotfix 에서 UI 노출을 **제거** — 사용자에 의미 모호 (L2/L3 차이가 즉시 보이지 않음). 라벨 매핑 미작성.
|
||||
|
||||
## 7. 함수 명세
|
||||
|
||||
| 함수 | 책임 | 시그니처 | 복잡? |
|
||||
|------|------|----------|-------|
|
||||
| `habitTypeLabel` | enum → 한국어 라벨 | `String habitTypeLabel(HabitType)` | 단순 (switch) |
|
||||
| `habitTypeLabelFromDb` | Drift raw String → 한국어 (fallback = raw) | `String habitTypeLabelFromDb(String)` | 단순 (switch + default) |
|
||||
| `rewardTierLabel` | enum → 이모지+한국어 | `String rewardTierLabel(RewardTier)` | 단순 (switch) |
|
||||
|
||||
모두 단순 string switch 라 `fn-*.md` 분리 불필요.
|
||||
|
||||
## 8. 흐름 / 알고리즘
|
||||
- A: `Scaffold.body` 가 `SafeArea` 로 감싸지면서 system bottom inset 만큼 padding 자동 적용. `top: false` 인 이유는 AppBar 가 이미 top inset 처리 (이중 padding 방지).
|
||||
- B: `Future.try-catch (e, st)` 에서 stack trace 까지 함께 string concat → state → 빨간 컨테이너의 `SelectableText` 로 노출. 사용자가 텍스트 선택 → 복사해 외부에 공유 가능.
|
||||
- C: 라벨 매핑은 분기/상태/I/O 없음. switch one-liner.
|
||||
|
||||
## 9. 테스트 전략
|
||||
- 신규 unit 추가 없음 — 라벨 매핑은 상수 매핑이라 unit 가치 낮음.
|
||||
- SafeArea + 빨간 배너는 widget 레이어 변경이지만 LLM 단말 시도 자체가 차단 상태 (#312 corpus collection blocker) — manual 검증으로 대체.
|
||||
- 167 기존 테스트 회귀 없음으로 단위/통합/도메인 보호.
|
||||
|
||||
## 10. 후속 (v0.4.3 또는 별개 이슈)
|
||||
- `BackendInitException: model may be invalid` 진단/수정 — `maxTokens=2048` 의 GPU buffer 후보. 단말 빌드 비용 때문에 분리.
|
||||
- release 빌드에서 stack 숨김 (사용자 친화 메시지로).
|
||||
|
||||
## 11. 추적성
|
||||
- **Redmine**: #342 (07-Release, dev hotfix bundle).
|
||||
- **선행**: #311 (v0.4.1 warm-up — 빨간 배너 자체는 v0.4.1 에서 도입, 본 hotfix 가 진단성 강화).
|
||||
- **관련**: #312 (corpus collection — LLM 동작 의존, B 진단 완료까지 블로커).
|
||||
76
docs/design/657-litertlm-filename/README.md
Normal file
76
docs/design/657-litertlm-filename/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 설계서: LLM 로드 실패 근본 원인 fix — 모델 파일명 .litertlm + 레거시 마이그레이션 (#657)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-07-14
|
||||
> **추적성** — Redmine: #657 (← #342 §2 out-of-scope follow-up) · 관련 ADR: 없음
|
||||
> · 구현 파일: `app/lib/data/ai/model_lifecycle.dart`, `app/lib/data/ai/gemma_llm_service.dart`, `app/lib/state/ai_providers.dart`, `app/android/app/src/debug/AndroidManifest.xml`
|
||||
> · 테스트: `app/test/data/ai/model_lifecycle_test.dart` (마이그레이션 신규 케이스)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
v0.4.1 실 단말에서 LLM 로드가 `BackendInitException: Failed to create engine. Model may be invalid` 로 실패 (#342). 2026-07-09 에뮬레이터 재현으로 근본 원인 확정:
|
||||
|
||||
- **근본 원인**: `ModelConfig.filename` 기본값이 `gemma4_e2b_q4.bin`. LiteRT-LM 네이티브 엔진은 **파일 확장자로 컨테이너 포맷을 판별**하므로 `.bin` 이름이면 `litert_lm_engine_create` 가 전 백엔드(gpu·cpu)에서 실패한다. 같은 바이트의 파일을 `.litertlm` 으로 rename 하면 통과.
|
||||
- **기각된 가설**: maxTokens 2048→1024 (GPU alloc 여유 가설). 1024는 오히려 추론을 깬다 — Gemma 4 E2B 컴파일 그래프의 prefill signature 보다 KV cache 가 작아져 `DYNAMIC_UPDATE_SLICE failed to prepare → Failed to invoke the compiled model` (llm_litert_compiled_model_executor.cc:738). **2048 필수.**
|
||||
|
||||
검증: 2026-07-14 에뮬레이터 E2E — 2.41GB 다운로드(SHA-256 통과) → 엔진 로드(xnnpack_cache 생성) → 채팅 tool call 왕복(`list_active_habits` → OK → 한국어 응답) 전 구간 성공.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
- **포함**:
|
||||
- A. **파일명 fix (프로덕션)**: `ModelConfig.filename` 기본값 → `gemma-4-E2B-it.litertlm`. 확장자 요구사항을 필드 doc comment 로 고정.
|
||||
- B. **레거시 마이그레이션 (프로덕션)**: 기존 설치본은 디스크에 `.bin` 파일 + meta `ai_model_path` 가 `.bin` 경로. 파일명만 바꾸면 이 사용자들은 여전히 로드 실패 → 2.4GB 재다운로드 강요. `ModelLifecycle` 이 meta path ≠ canonical path 를 감지하면 **파일 rename + meta 갱신** (같은 바이트이므로 SHA meta 유지).
|
||||
- C. **dev 인프라 (에뮬레이터 테스트 루프)**:
|
||||
- `GEMMA_MODEL_URL` dart-define — 호스트 로컬 모델 서버(`http://10.0.2.2:...`) 주입. 기본값 = 기존 HuggingFace URL (동작 불변).
|
||||
- `LLM_BACKEND` dart-define — 에뮬레이터는 SwiftShader(소프트웨어 Vulkan)가 GPU init 을 **네이티브 SIGABRT** 로 죽이고 Dart 폴백은 native abort 를 못 잡음 → `cpu` 강제 필요. 기본값 = 미지정(SDK gpu→cpu 폴백, 실단말 동작 불변).
|
||||
- debug manifest `usesCleartextTraffic` — dart:io 도 Android cleartext 정책을 따르므로 debug 빌드만 허용. release 불변.
|
||||
- **제외 (out of scope)**:
|
||||
- 실단말 검증 (#657 종료 조건이지만 사용자 단말 필요 — 별도 세션).
|
||||
- `download()` 의 per-chunk `meta.put` 스로틀(~2MB/s) 및 non-Range 서버 resume corruption — follow-up 후보.
|
||||
|
||||
## 3. 인수조건 (Acceptance Criteria)
|
||||
|
||||
- [ ] **AC-1** 신규 설치: 다운로드된 모델 파일명이 `gemma-4-E2B-it.litertlm`, `checkAvailability()` = ready, 엔진 로드 성공.
|
||||
- [ ] **AC-2** 레거시 설치(`.bin` 파일 + meta path): 첫 availability 체크에서 파일이 canonical 이름으로 rename 되고 meta `ai_model_path` 가 갱신되며 결과는 ready. 재다운로드 없음.
|
||||
- [ ] **AC-3** 마이그레이션 멱등: 두 번째 호출은 no-op. rename 실패(파일 잠김 등) 시 corrupt/missing 으로 강등하지 않고 기존 상태 보존 (다음 기회 재시도).
|
||||
- [ ] **AC-4** meta path 파일이 없으면 마이그레이션 skip, 기존 missing 흐름 유지.
|
||||
- [ ] **AC-5** `maxTokens: 2048` 유지 + 근거 주석 (1024 회귀 방지).
|
||||
- [ ] **AC-6** `LLM_BACKEND`/`GEMMA_MODEL_URL` 미지정 빌드는 기존 동작과 바이트 동일 (기본값 경로).
|
||||
- [ ] **AC-7** release manifest 에 cleartext 허용 없음 (debug sourceSet 한정).
|
||||
- [ ] **AC-8** 기존 테스트 전건 회귀 없음 + `flutter analyze` clean.
|
||||
|
||||
## 4. 함수 등재 (Function Inventory)
|
||||
|
||||
| 함수 | 위치 | 복잡도 | 설계 |
|
||||
|------|------|--------|------|
|
||||
| `ModelLifecycle._migrateLegacyPath()` | model_lifecycle.dart | 분기 4 (meta 없음 / 동일 경로 / 파일 없음 / rename 실패) | 아래 §5 |
|
||||
| `ModelConfig.filename` 기본값 변경 | model_lifecycle.dart | 상수 | doc comment 로 확장자 불변식 고정 |
|
||||
| `_preferredBackend` getter | gemma_llm_service.dart | switch 3분기 | dart-define → enum 매핑, 미지정 = null |
|
||||
| `_kModelUrl` 상수 | ai_providers.dart | 상수 | dart-define, 기본값 = HF URL |
|
||||
|
||||
## 5. 마이그레이션 설계 (`_migrateLegacyPath`)
|
||||
|
||||
```
|
||||
quickCheck() / checkAvailability()
|
||||
└─ (opt-in ✓, downloadState not in-progress 확인 후)
|
||||
_migrateLegacyPath():
|
||||
metaPath = meta[ai_model_path] // null → return (기존 missing 흐름)
|
||||
canonical = supportDir/config.filename
|
||||
if metaPath == canonical → return // 이미 정상 (멱등)
|
||||
if !File(metaPath).exists → return // 파일 유실 — missing 흐름에 위임
|
||||
try File(metaPath).rename(canonical) // 같은 디렉터리 — 원자적
|
||||
catch → return // 보수적: 상태 불변, 다음 기회 재시도
|
||||
meta[ai_model_path] = canonical // SHA meta 는 그대로 (같은 바이트)
|
||||
```
|
||||
|
||||
- **호출 지점**: `quickCheck`/`checkAvailability` 둘 다, opt-in·downloadState 게이트 통과 직후. 콜드 패스에서 한 번 rename 되면 이후는 문자열 비교 1회라 hot path(quickCheck) 비용 무시 가능.
|
||||
- **왜 availability 체크 안에서**: 앱의 모든 LLM 진입점(#311 warm-up, 설정 화면, lazy load)이 availability 체크를 먼저 지나므로 별도 시동 훅 불필요.
|
||||
- **rename 실패를 삼키는 이유**: 실패해도 사용자 상태는 "이전과 동일하게 로드 실패"일 뿐 악화되지 않고, 다음 체크에서 재시도된다. 예외를 올리면 availability 가 corrupt 로 오판된다 (명시적 트레이드오프).
|
||||
|
||||
## 6. 리스크 & 검증
|
||||
|
||||
| 리스크 | 대응 |
|
||||
|--------|------|
|
||||
| 레거시 사용자 `.bin` rename 후에도 로드 실패 | 에뮬레이터에서 동일 시나리오 재현 검증 완료 (rename → 엔진 통과). 실단말 검증이 종료 조건 |
|
||||
| dart-define 미지정 빌드 동작 변화 | 기본값 = 기존값 (AC-6). 테스트 회귀로 확인 |
|
||||
| cleartext 가 release 로 누출 | debug sourceSet 매니페스트만 수정 (AC-7) |
|
||||
Reference in New Issue
Block a user