5 Commits
v0.4.1 ... main

Author SHA1 Message Date
c18dca1def [Designer] #342 UX round 2 — chat 빈 상태 + 한국식 날짜 + 표현 방식
남은 P1/P2 3건.

- ChatScreen 빈 상태: 아이콘 + 한 줄 설명 + 예시 prompt 4개
  (tap → _textCtrl 자동 채움, 자동 send X).
- CheckIn 날짜: '2026-06-15' raw → '6월 15일 (월)' 한국식.
  DB 저장은 _ymd 유지.
- HabitCreate '프레임 레벨' → '표현 방식' + helperText.
  아이템: '조건부 행동 (예: 아침에 햇빛 받기)' / '정체성 (예: 나는 일찍 자는 사람)'.
- 설계서 #342 README — D 섹션 + AC-D1/D2/D3 추가.
- CHANGELOG v0.4.2 UX round 2 블록.

167 tests passed, analyze clean.

Refs #342
2026-06-15 15:28:03 +09:00
e81f3e44a4 [Designer] #342 UX round 1 — raw enum → 한국어 라벨 + 스트릭 hero
dev v0.4.2 위 hotfix. v0.4.1 단말 테스트에서 발견된 raw 식별자
노출 P0 3 + P1 2.

- ui/labels.dart 신규 — habitTypeLabel(FromDb) / rewardTierLabel.
  domain enum 의 한국어 라벨 단일 지점 (domain layer 분리).
- habit_list 부제: 'build · L3 · …' → '만들기 · …'.
  FrameLevel 노출 제거 (시스템 규약).
- streak: 'T0' / 'T1' raw → '🌱 새싹' / '🥉 3회 도전' …,
  영문 'Never miss twice' → '이틀 연속 빠졌어요. 한 단계 강등됐습니다',
  현재 스트릭을 displayLarge hero 로 위계 강조.
- habit_create 드롭다운: '만들기 (build)' → '만들기'.
- 설계서 docs/design/342-v042-hotfix/README.md — A/B/C 11 AC.
- CHANGELOG v0.4.2 에 UX round 1 섹션 추가.

167 tests passed, analyze clean. APK 재빌드 보류 (사용자 결정).

Refs #342
2026-06-15 15:23:05 +09:00
3b8ea95aa6 [hotfix] #342 v0.4.2 — ChatScreen SafeArea + LLM 에러 진단 노출
Fix
- ChatScreen body 를 SafeArea(top: false) 로 감쌈. Android edge-to-edge
  모드에서 시스템 nav bar 가 입력창을 덮던 문제 해결.

Dev (#342)
- userTurn catch 블록이 e.toString() + stack trace 를 error 상태에 저장.
- 빨간 에러 컨테이너를 SingleChildScrollView + SelectableText (monospace)
  + 최대 화면 1/3 높이 제약. 스크롤 + 복사 가능. release 빌드에서도
  full stack 노출 (#342 종료 후 follow-up 으로 좁힘).

테스트: chat_session_controller_test 8/8 통과.
APK: app-release.apk 301.0MB SHA 02a5d1c8.

Refs #342
2026-06-15 14:30:21 +09:00
94a9cd474b [Architect] #312 design spec — tool call prefix corpus & 조건부 push
설계서 3 + 절차서 1.
- README.md: 기능 설계서 (15 케이스 corpus, 임계 5/15, 경로 A/B)
- fn-corpus_logger.md: optional debug logger (kDebugMode + dart-define 가드)
- fn-userTurn_partial_push.md: chat_providers.dart 의 break 분기 수정안 (경로 A/B)
- corpus-procedure.md: 빌드/캡처/15 프롬프트/임계 판정 절차

R1-R5 모두 해소 (Architect 채택안).
ADR-0006 슬롯 = 경로 B 채택 시 작성 (Developer 단계).

Refs #312
2026-06-15 14:17:47 +09:00
41457ab96e [08-Documenter] #311 설계서 Approved + reference + 사용자 가이드 FAQ
- docs/design/311-llm-warmup/ 3파일 상태 Draft → Approved (v0.4.1)
- docs/reference/311-llm-warmup.md 신규 (상태 머신, API, UI binding,
  마이크로카피, 테스트)
- docs/guides/ai-chat-using.md 헤더 + FAQ 2건 추가 (warmup 인지/회복)

Refs #311
2026-06-15 13:25:38 +09:00
19 changed files with 1009 additions and 48 deletions

View File

@@ -3,6 +3,27 @@
본 프로젝트의 모든 의미있는 변경은 본 파일에 기록한다.
형식: [Keep a Changelog](https://keepachangelog.com/) · 버전: [SemVer](https://semver.org/).
## [0.4.2] — 2026-06-15 (hotfix, dev)
### 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.
## [0.4.1] — 2026-06-15
### Added — ChatScreen LLM warm-up (Redmine #311, follow-up of #260)

View File

@@ -206,12 +206,16 @@ class ChatSessionController extends StateNotifier<ChatSessionState> {
clearStreamingText: true,
error: '도구 호출 루프가 너무 길어 중단했습니다.',
);
} catch (e) {
} catch (e, st) {
if (!mounted) return;
// 개발 단계 (#342) — 실 단말 진단을 위해 release 빌드에서도 full
// message + stack 노출. 사용자 친화 메시지로 다시 좁히는 작업은
// #342 종료 후 follow-up.
final detail = 'LLM 응답 실패: ${e.runtimeType}\n$e\n\n--- STACK ---\n$st';
state = state.copyWith(
isStreaming: false,
clearStreamingText: true,
error: 'LLM 응답 실패: ${e.runtimeType}',
error: detail,
);
}
}

44
app/lib/ui/labels.dart Normal file
View 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주 완주';
}
}

View File

@@ -76,10 +76,16 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
),
],
),
body: depsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('초기화 실패: $e')),
data: (_) => _buildBody(context),
// Android edge-to-edge: 시스템 nav bar (3-button / gesture handle) 가
// 입력창을 가리지 않도록 SafeArea 로 감싼다. AppBar 가 이미 top inset
// 을 처리하므로 top 만 false.
body: SafeArea(
top: false,
child: depsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('초기화 실패: $e')),
data: (_) => _buildBody(context),
),
),
);
}
@@ -105,36 +111,52 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
children: [
if (warmup is ChatWarmupFailed) _WarmupErrorBanner(warmup: warmup),
if (state.error != null)
// #342 dev — 단말에서 원인 진단을 위해 stack 까지 노출되는 케이스를
// 위해 multi-line + scrollable + selectable. 높이는 화면의 1/3 까지만.
Container(
width: double.infinity,
color: theme.colorScheme.errorContainer,
padding: const EdgeInsets.all(12),
child: Text(
state.error!,
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height / 3,
),
child: SingleChildScrollView(
child: SelectableText(
state.error!,
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
fontFamily: 'monospace',
fontSize: 12,
),
),
),
),
Expanded(
child: ListView.builder(
controller: _scrollCtrl,
padding: const EdgeInsets.all(12),
itemCount: state.messages.length +
(state.streamingText != null &&
state.streamingText!.isNotEmpty
? 1
: 0),
itemBuilder: (context, i) {
if (i < state.messages.length) {
return _MessageBubble(message: state.messages[i]);
}
return _MessageBubble(
message: ModelChatMessage(state.streamingText ?? ''),
streaming: true,
);
},
),
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 +
(state.streamingText != null &&
state.streamingText!.isNotEmpty
? 1
: 0),
itemBuilder: (context, i) {
if (i < state.messages.length) {
return _MessageBubble(message: state.messages[i]);
}
return _MessageBubble(
message: ModelChatMessage(state.streamingText ?? ''),
streaming: true,
);
},
),
),
const Divider(height: 1),
Padding(
@@ -233,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

View File

@@ -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]})';
}

View File

@@ -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(

View File

@@ -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,
),

View File

@@ -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),
),
),

View File

@@ -1,7 +1,7 @@
name: life_helper
description: "Huberman + Atomic Habits + Tiny Habits + If-Then. Local-first habit/checklist/todo."
publish_to: 'none'
version: 0.4.1+5
version: 0.4.2+6
environment:
sdk: ^3.12.2

View File

@@ -1,8 +1,8 @@
# 설계서: ChatScreen LLM warm-up (#311)
> **상태**: Draft
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #311 · 관련 ADR: 없음 (Backward-compatible 추가)
> **상태**: Approved (v0.4.1, 2026-06-15)
> **작성**: [AI] Architect · **최종수정**: 2026-06-15 (08-Documenter 마감)
> **추적성** — Redmine: #311 · 관련 ADR: 없음 (Backward-compatible 추가) · Release: v0.4.1 (`121108f`)
> · 구현 파일: `app/lib/state/chat_warmup_provider.dart` (신규) · `app/lib/data/ai/llm_service.dart` (수정) · `app/lib/data/ai/model_lifecycle.dart` (`quickCheck` 추가) · `app/lib/data/ai/gemma_llm_service.dart` (concurrent load guard) · `app/lib/ui/screens/chat_screen.dart` (warmup binding)
> · 테스트: `app/test/state/chat_warmup_test.dart` (신규) · `app/test/data/ai/model_lifecycle_test.dart` (quickCheck 케이스 추가) · `app/test/ui/chat_screen_test.dart` (warmup 라벨/disabled 케이스 추가)

View File

@@ -1,6 +1,6 @@
# 함수 설계서: `ChatWarmupController.start` (#311)
> **부모 설계서**: ./README.md · **상태**: Draft
> **부모 설계서**: ./README.md · **상태**: Approved (v0.4.1, 2026-06-15)
> **작성**: [AI] Architect · **구현**: `app/lib/state/chat_warmup_provider.dart:start` · **테스트**: `app/test/state/chat_warmup_test.dart`
## 1. 시그니처

View File

@@ -1,6 +1,6 @@
# 함수 설계서: `GemmaLlmService.load` concurrent guard (#311)
> **부모 설계서**: ./README.md · **상태**: Draft
> **부모 설계서**: ./README.md · **상태**: Approved (v0.4.1, 2026-06-15)
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/gemma_llm_service.dart:load` (수정) · **테스트**: `app/test/data/ai/gemma_llm_service_test.dart` (concurrent 케이스 추가) / `chat_warmup_test.dart` (시뮬)
## 1. 시그니처

View File

@@ -0,0 +1,207 @@
# 설계서: Tool call 직전 prefix 토큰 corpus & 조건부 push (#312)
> **상태**: Draft
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #312 · 관련 ADR: ADR-0006 (조건부, Developer 단계에서 corpus 결과 확정 후 작성)
> · 구현 파일: `app/lib/state/chat_providers.dart:144-153` (수정 후보), `app/lib/ai/diagnostics/corpus_logger.dart` (신규) · 테스트: `app/test/state/chat_session_prefix_test.dart` (신규)
## 1. 목적 (Why)
> Planner 인용: tool call 직전 Gemma 4 E2B 가 뱉는 prefix 자연어를 실측 corpus 로 측정하고, 30% 임계에 따라 partial push 구현 또는 의도적 폐기 결정 기록을 남긴다.
`chat_providers.dart:144-153``for await` 루프는 `LlmFunctionCall` 도착 시 `accumulated` 텍스트를 버리고 `break` 한다. Gemma 4 가 tool call 전에 "수면 카탈로그를 보여드릴게요" 같은 의미있는 한국어 prefix 를 자주 뱉는다면, 그 정보가 사용자 화면에서 사라지는 UX 손실이 발생한다. 본 이슈는 손실량을 실측한 뒤 push 구현 또는 의도적 폐기 둘 중 하나로 확정한다.
## 2. 범위 (Scope)
- **포함**:
- 디버그 빌드 전용 `CorpusLogger` 인터페이스 + `ChatSessionController` 에 optional inject.
- corpus 수집 절차 문서 (`corpus-procedure.md`) 와 결과 표 (`docs/research/312-tool-prefix-corpus.md`).
- 임계 (5건 이상 / 15) 충족 시 `userTurn` 의 break 직전 `accumulated.trim().isNotEmpty``ModelChatMessage` push.
- 임계 미달 시 폐기 주석 + ADR 0006 결정 기록.
- 어느 경로든 `ChatSessionController` 단위 테스트 1+ 건.
- **제외 (out of scope)**:
- `ParallelFunctionCallResponse` first-only 한계 (AC4 의 follow-up 이슈로 발행만).
- Gemma 4 thinking/reasoning tag 처리 (`isThinking:false` 비활성 중).
- 일반 streaming UX 폴리시 (cursor, 토큰 속도, scroll).
- 프로덕션 빌드의 로깅 — `CorpusLogger``kDebugMode` 가드 + null default.
## 3. 인수조건 (Acceptance Criteria)
- [ ] **AC1**: `docs/research/312-tool-prefix-corpus.md` 에 15 케이스 표 (사용자 입력 / tool name / `accumulated` raw / 의미있는 prefix 여부 (Y/N) / 사유). Planner 의 운영 정의를 본 설계서가 §6 에서 확정 (R2 해소).
- [ ] **AC2 (조건부 구현)**: corpus 결과가 ≥5/15 이면 `userTurn` 의 break 직전 push (fn-userTurn_partial_push §5 경로 A). ≤4/15 이면 폐기 주석 + ADR 0006 + 경로 B (no-op 회귀 가드).
- [ ] **AC3 (단위 테스트)**: `ChatSessionController` 단위 테스트 — fake `LlmService``text → text → function_call` 순으로 emit 했을 때 결과 `state.messages` 의 길이와 순서를 검증. 경로 A 면 [User, Model(prefix), ToolCall] 3 개. 경로 B 면 [User, ToolCall] 2 개 + prefix 누락이 의도적임을 주석으로 명시.
- [ ] **AC4**: Parallel call 한계를 `userTurn` 코드 인근 주석 + 본 설계서 §11 에 명시 + Redmine 신규 이슈 발행 (예: "#312-followup ParallelFunctionCallResponse 다중 호출 처리").
## 4. 컨텍스트 & 제약
- **의존성**:
- `app/lib/data/ai/llm_service.dart``LlmChatEvent` sealed class (`LlmTextChunk` / `LlmFunctionCall` / `LlmDone`).
- `app/lib/state/chat_providers.dart``ChatSessionController.userTurn` 의 multi-turn 루프.
- 실 단말 — Gemma 4 E2B `.litertlm` 모델 (#218). corpus 는 실제 inference 결과여야 함 (mock 무효).
- **제약**:
- 사용자가 수동으로 APK 를 실행해 corpus 를 수집 — reproducibility 낮음. 본 설계는 logger 인터페이스로 수집 부담을 최소화하는 데 집중.
- `kDebugMode` 가드로 production 영향 0. 빌드 사이즈 +수 KB 이내.
- `LlmService` 인터페이스 변경 금지 — logger 는 `ChatSessionController` 의 ctor 파라미터로만 주입.
- **가정**:
- Gemma 4 E2B 의 함수 호출 메커니즘은 SDK 가 prompt 를 자동 렌더 (cf. `feedback_flutter_gemma_api_quirks`). 따라서 prefix 텍스트는 SDK 가 자체 emit 하는 자연어이지 사용자가 prompt 로 유도한 것이 아니다.
- 동일 프롬프트라도 sampling 노이즈로 다른 결과가 나올 수 있음. 15 케이스 1 회 측정이 절대 진리는 아니지만 의사 결정에는 충분.
## 5. 아키텍처 개요
- **모듈 / 파일**:
- 신규: `app/lib/ai/diagnostics/corpus_logger.dart``abstract class CorpusLogger` + `DebugCorpusLogger` 구현 + factory.
- 수정: `app/lib/state/chat_providers.dart``ChatSessionController``final CorpusLogger? logger` 필드 추가, ctor 에 optional 인자. `userTurn` 의 event 루프에서 hook 호출. corpus 결과 확정 후 §5 의 break 직전에 push 분기 (경로 A) 또는 폐기 주석 (경로 B).
- 신규: `docs/research/312-tool-prefix-corpus.md` — corpus 표 (Developer 가 수집 후 채움).
- 신규: `docs/design/312-tool-prefix-corpus/corpus-procedure.md` — 절차 매뉴얼.
- **데이터 흐름**:
```
[User input] → ChatSessionController.userTurn
LlmService.startChat → _session.sendUser(text)
↓ (Stream<LlmChatEvent>)
for await event:
LlmTextChunk → accumulated += text
→ logger?.onTextChunk(turn, text) ← 신규 hook
→ state.streamingText = accumulated
LlmFunctionCall → toolCall = event
→ logger?.onFunctionCall(turn, ← 신규 hook
accumulated, event.name, event.args)
→ [경로 A] if accumulated.trim().isNotEmpty:
state.messages.add(ModelChatMessage(accumulated))
→ break
state.messages.add(ToolCallChatMessage(...))
```
- **I/O ↔ 순수 경계**: `CorpusLogger` 의 구현체가 I/O (file or stdout). `userTurn` 의 push 분기 자체는 순수 (state mutation 뿐) — 테스트 용이.
```
ChatSessionController ────── (optional) ──── CorpusLogger
│ │
│ userTurn () │ onTextChunk()
│ │ onFunctionCall()
│ ↓
│ file / stdout
state.messages
ChatScreen (ListView)
```
## 6. 데이터 모델
### 6.1 "의미있는 prefix" 운영 정의 (R2 확정)
- **포함 (의미있음, Y)**:
- 공백 제외 한국어 자연어 ≥10 자.
- 정보 전달 의도 있음 (예: "수면 카탈로그에서 추천 항목을 보여드릴게요").
- **제외 (의미없음, N)**:
- 빈 문자열 또는 공백/줄바꿈만.
- boilerplate: "search_catalog 를 호출합니다", "잠시만요", "조회 중...", "...", 영어 함수명 문구.
- 단순 응대: "네", "알겠습니다", "확인했어요" 단독.
- 사용자 입력 그대로 echo.
### 6.2 corpus 표 schema (`docs/research/312-tool-prefix-corpus.md`)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| `#` | int | 1-15 |
| `category` | enum | catalog / add_habit / log_tracker_entry / streak |
| `user_input` | str | 그대로 |
| `tool_name` | str | Gemma 가 호출한 도구 |
| `accumulated_raw` | str (multiline) | tool call 도착 시점의 누적 텍스트 (no trim) |
| `meaningful` | Y/N | §6.1 기준 |
| `note` | str | Y/N 사유 1줄 |
### 6.3 임계 (R4 확정)
- **5+ / 15 (≥33%) → 경로 A (push 구현)**.
- **4 또는 그 이하 → 경로 B (폐기 + ADR 0006)**.
- borderline (정확히 5) 도 경로 A 채택 (UX 손실 보수적 보호). 정확히 4 면 +5 케이스 추가 수집 후 재판정 (총 20 케이스, 임계 7).
## 7. 함수 명세 (Function Specs)
| 함수 | 책임 (1줄) | 시그니처 (잠정) | 입력 | 출력 | 에러 / 실패 | 복잡? |
|------|-----------|----------------|------|------|-------------|-------|
| `CorpusLogger.onTextChunk` | 텍스트 청크 도착 시점 기록 | `void onTextChunk(int turn, String text)` | turn idx, chunk | void | 구현체 I/O 실패는 swallow (debug only) | **복잡** ([fn](./fn-corpus_logger.md)) |
| `CorpusLogger.onFunctionCall` | tool call 도착 시점의 누적 prefix + tool name 기록 | `void onFunctionCall(int turn, String accumulated, String toolName, Map<String,dynamic> args)` | turn idx, prefix, name, args | void | I/O swallow | **복잡** ([fn](./fn-corpus_logger.md)) |
| `DebugCorpusLogger.maybeCreate` | factory — `kDebugMode` + dart-define 가드 | `static CorpusLogger? maybeCreate()` | none | nullable logger | exception swallow → null | 단순 (factory) |
| `ChatSessionController.userTurn` (수정) | event 루프 + 조건부 push | (기존 시그니처) | (기존) | (기존) | (기존) | **복잡** ([fn](./fn-userTurn_partial_push.md)) |
> 복잡 기준: state mutation 분기 / 외부 I/O (logger) / corpus 결과에 따라 코드 경로 갈라짐.
## 8. 흐름 / 알고리즘
### Phase A — corpus 수집 (Developer 수동 작업)
1. `flutter run --debug --dart-define=ENABLE_CORPUS_LOG=1` 으로 APK 빌드 & 단말 설치.
2. `corpus-procedure.md` 의 15 프롬프트를 차례로 ChatScreen 에 입력.
3. `flutter logs` 또는 logcat `--tag CorpusLogger` 로 raw event dump 수집.
4. dump 를 `docs/research/312-tool-prefix-corpus.md` 표에 정리.
5. §6.3 임계로 경로 A / B 결정.
### Phase B — 조건부 구현
- 경로 A (push):
```
} else if (event is LlmFunctionCall) {
toolCall = event;
// #312 — corpus 측정 결과 X/15 가 의미있는 prefix → push 채택.
if (accumulated.trim().isNotEmpty) {
_appendPrefixMessage(accumulated);
}
break;
}
```
- 경로 B (폐기 명시):
```
} else if (event is LlmFunctionCall) {
toolCall = event;
// #312 — corpus 측정 결과 X/15 만 의미있는 prefix → 의도적 폐기.
// ADR-0006 참조. accumulated 는 버린다.
break;
}
```
### Phase C — 테스트
- fake `LlmService` 가 `[Text("수면 카탈로그를 보여드릴게요 "), FunctionCall(search_catalog, {...})]` 순으로 emit.
- 경로 A: `state.messages == [User, Model("수면 카탈로그를 보여드릴게요"), ToolCall(...)]` (trim 적용).
- 경로 B: `state.messages == [User, ToolCall(...)]` (prefix 누락 명시적 검증).
- 보너스: 빈 prefix 케이스 — `[FunctionCall(...)]` 직접 emit → 경로 A 도 ModelChatMessage 추가 안 함 (trim guard).
## 9. 엣지케이스 & 에러 처리
- **빈 prefix**: `accumulated.trim().isEmpty` → push 안 함 (경로 A 의 가드).
- **whitespace only prefix** (Gemma 가 `"\n\n"` 같은 토큰 뱉음): trim 가드로 push 안 함.
- **prefix 가 사용자 입력 echo**: §6.1 의 운영 정의로 corpus 수집 시 N 판정. 구현 단계에선 trim/length 가드만 — echo 감지는 false positive 위험 (정상 paraphrase 까지 잡힐 수 있음). corpus 결과로 임계 산정에 영향만.
- **logger I/O 실패**: `DebugCorpusLogger` 내부 try/catch swallow. 절대 `userTurn` 흐름을 깨면 안 됨.
- **production 빌드**: `DebugCorpusLogger.maybeCreate()` → null 반환. `logger?.onTextChunk` 의 null-aware 가 0 비용.
- **dart-define 미설정 + debug**: factory 가 null 반환 (opt-in). 평소 debug run 도 영향 없음.
## 10. 테스트 계획
### 단위 테스트 (AC3)
- [ ] `chat_session_prefix_test.dart` — 경로 A
- Given: fake LlmService 가 `[Text("수면 카탈로그를 보여드릴게요 "), FunctionCall("search_catalog", {})]` emit.
- When: `userTurn("수면 습관 추천")`.
- Then: state.messages 의 마지막 3 개 = [User, Model("수면 카탈로그를 보여드릴게요"), ToolCall("search_catalog", {}, _)].
- [ ] `chat_session_prefix_test.dart` — 경로 A trim guard
- Given: fake 가 `[Text("\n\n"), FunctionCall(...)]` emit.
- Then: state.messages 에 ModelChatMessage 추가 안 됨.
- [ ] (경로 B 채택 시) `chat_session_prefix_test.dart` — 폐기 회귀 가드
- Given: fake 가 `[Text("의미있는 prefix"), FunctionCall(...)]` emit.
- Then: state.messages 에 ModelChatMessage("의미있는 prefix") 없음 — corpus 결과로 폐기가 정당화됨을 명시적으로 assert.
### logger 단위 테스트
- [ ] `CorpusLogger.maybeCreate` — `kDebugMode=true && ENABLE_CORPUS_LOG=1` → non-null.
- [ ] `maybeCreate` — release → null.
- [ ] `DebugCorpusLogger.onFunctionCall` — 호출 시 stdout 에 `[CorpusLogger]` 라벨 + JSON 한 줄 emit.
### 통합 — 수동 corpus (AC1)
- 절차서 `corpus-procedure.md` 따라 수행 + 결과 표.
## 11. 리스크 & 대안 검토
- **선택**: optional logger inject + `kDebugMode` 가드. 프로덕션 0 영향, corpus 수집은 dart-define on/off.
- **대안 1 (기각)**: ChatSessionController 에 직접 logging 코드 박기 — 프로덕션 영향, 테스트 mock 어려움.
- **대안 2 (기각)**: Riverpod provider 로 logger 주입 — 단일 진실 (controller ctor) 보다 모호. 본 이슈는 수명 짧음 (corpus 끝나면 logger 제거 검토).
- **트레이드오프**:
- logger 가 ChatSessionController API 표면을 늘림 → 본 이슈 후 제거 가능 (코드 적음).
- dart-define 가드는 IDE 자동완성에서 안 보임 → corpus-procedure.md 에 명시.
- **ADR 0006**: 경로 B (폐기) 채택 시에만 작성. push 채택 시 본 설계서 자체가 결정 기록 — ADR 별도 발행 안 함.
- **ParallelFunctionCallResponse (AC4)**: 본 이슈 OOS. follow-up 이슈 발행 권고 — "#312-followup ParallelFunctionCallResponse 다중 호출 처리: 현재 first call only yield 후 return. 다중 tool 시나리오가 Gemma 4 E2B 에서 발생하는지 corpus 결과로 함께 측정 가능 (보너스 컬럼)."
## 12. 미해결 질문 (Open Questions)
없음. R1-R5 해소 완료:
- R1 → 15 케이스 1 회 측정 (§4 가정).
- R2 → §6.1 운영 정의 확정.
- R3 → optional CorpusLogger (§5, §11).
- R4 → 임계 5/15 확정 (§6.3).
- R5 → state.messages 순서 보존 + trim guard (§9).

View File

@@ -0,0 +1,89 @@
# Corpus 수집 절차 (#312)
> **부모 설계서**: ./README.md · **목적**: Developer 가 corpus 를 재현 가능하게 수집할 수 있도록 절차 명문화.
## 1. 빌드
```bash
cd app
flutter build apk --debug --dart-define=ENABLE_CORPUS_LOG=true
# 또는 단말 연결 후
flutter run --debug --dart-define=ENABLE_CORPUS_LOG=true
```
`ENABLE_CORPUS_LOG=true` 가 빠지면 `DebugCorpusLogger.maybeCreate()` 가 null 반환 → logging 없음.
## 2. 로그 캡처
```bash
# adb 연결된 단말
adb logcat | grep CorpusLogger > /tmp/corpus-raw.log
# 또는 flutter run 의 stdout
flutter run --debug --dart-define=ENABLE_CORPUS_LOG=true 2>&1 \
| grep CorpusLogger > /tmp/corpus-raw.log
```
각 줄은 `[CorpusLogger] {"kind":"text_chunk"|"function_call",...}` 형태의 JSON.
## 3. 프롬프트 시퀀스 (15 케이스)
ChatScreen 진입 후 아래를 순서대로 입력. 각 프롬프트 사이에 ↻ 로 세션 초기화 (turn 격리).
### catalog 카테고리 (5)
1. 수면 관련 습관 추천해줘
2. 아침에 할 수 있는 습관 뭐 있어?
3. 스트레스 관리 프로토콜 알려줘
4. 운동 관련 카탈로그 보여줘
5. 명상 어떤 게 있어?
### add_habit 카테고리 (5)
6. 아침 햇빛 보기 습관 추가해줘
7. 매일 물 2L 마시기 추가해줘
8. 잠들기 전 스트레칭 등록할래
9. 출근 전 명상 5분 추가해줘
10. 점심 후 산책 습관 만들어줘
### log_tracker_entry 카테고리 (3)
11. 오늘 햇빛 봤어 체크해줘
12. 어제 운동한 거 기록해줘
13. 오늘 명상 완료
### streak 카테고리 (2)
14. 내 연속 기록 어때?
15. 스트릭 보여줘
## 4. 결과 표 작성
`/tmp/corpus-raw.log` 의 각 `function_call` 이벤트에서 `accumulated_prefix` 를 추출해 `docs/research/312-tool-prefix-corpus.md` 표에 채운다.
표 schema (README §6.2 참조):
```markdown
| # | category | user_input | tool_name | accumulated_raw | meaningful | note |
|---|----------|-----------|-----------|-----------------|------------|------|
| 1 | catalog | 수면 관련 습관 추천해줘 | search_catalog | "수면 카탈로그를 보여드릴게요" | Y | 정보 전달 의도 + 14자 |
| 2 | catalog | 아침에 할 수 있는 습관 뭐 있어? | search_catalog | "" | N | 빈 prefix |
| ... |
```
판정 기준은 README §6.1 의 운영 정의:
- **Y**: 공백 제외 한국어 자연어 ≥10 자 + 정보 전달 의도.
- **N**: 빈/공백/boilerplate/단순 응대/echo.
## 5. 임계 판정
- Y 카운트 ≥5 → 경로 A (push 구현).
- Y 카운트 =4 → +5 케이스 추가 수집 (총 20, 임계 7).
- Y 카운트 ≤3 → 경로 B (폐기 + ADR-0006).
## 6. 후처리
- corpus 결과를 Redmine #312`## [AI] Developer` 섹션 (또는 별도 댓글) 에 요약: Y/N count + 채택 경로.
- 채택 경로에 따라 `fn-userTurn_partial_push.md` 의 경로 A 또는 B 를 구현.
- 경로 B 채택 시 ADR-0006 작성 (`docs/adr/0006-tool-call-prefix-discard.md` 또는 적합한 제목).
- corpus 수집 종료 후 `--dart-define=ENABLE_CORPUS_LOG=true` 사용 빈도 0 → 향후 CorpusLogger 제거 follow-up 이슈 발행 검토.
## 7. 보너스 — ParallelFunctionCallResponse 측정
같은 raw log 에서 한 turn 안에 `function_call` 이 2 회 이상 emit 되는지 확인. 발생 시 AC4 의 follow-up 이슈에 빈도 데이터 포함.

View File

@@ -0,0 +1,134 @@
# 함수 설계서: `CorpusLogger` (#312)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect · **구현**: `app/lib/ai/diagnostics/corpus_logger.dart` (신규) · **테스트**: `app/test/ai/diagnostics/corpus_logger_test.dart` (신규)
## 1. 시그니처
```dart
abstract class CorpusLogger {
void onTextChunk(int turn, String text);
void onFunctionCall(
int turn,
String accumulatedPrefix,
String toolName,
Map<String, dynamic> args,
);
}
class DebugCorpusLogger implements CorpusLogger {
/// kDebugMode + --dart-define=ENABLE_CORPUS_LOG=1 일 때만 non-null.
/// production 빌드에서는 항상 null.
static CorpusLogger? maybeCreate();
@override
void onTextChunk(int turn, String text);
@override
void onFunctionCall(...);
}
```
## 2. 책임 (단일 책임, 1줄)
ChatSessionController 의 event 루프에서 발생한 텍스트 청크와 function call 의 raw payload 를 디버그 빌드에서 stdout 으로 dump 한다 — corpus 수집 부담을 줄이기 위한 한시적 진단 도구.
## 3. 입력
### `onTextChunk`
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `turn` | int | ≥0 | userTurn 내 multi-turn 루프의 turn index (0=첫 LLM 응답). |
| `text` | String | non-null | 도착한 텍스트 청크 (raw, 누적 X). |
### `onFunctionCall`
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `turn` | int | ≥0 | 동일. |
| `accumulatedPrefix` | String | non-null | tool call 도착 시점까지의 누적 텍스트 (chunks 의 concat). |
| `toolName` | String | non-null, non-empty | Gemma 가 호출한 도구 이름. |
| `args` | `Map<String, dynamic>` | non-null | tool 인자. JSON serializable 가정. |
### `maybeCreate`
- 입력 없음.
## 4. 출력
- `onTextChunk` / `onFunctionCall`: **반환 없음**. 부수효과 = stdout 한 줄 emit (디버그). I/O 실패 시 swallow.
- `maybeCreate`: **반환** `CorpusLogger?``kDebugMode` 가 true 이고 `const bool.fromEnvironment('ENABLE_CORPUS_LOG')` 가 true 일 때 `DebugCorpusLogger()` 인스턴스, 그 외 null.
## 5. 동작 / 알고리즘
### `DebugCorpusLogger.onTextChunk`
```
1. _emit({
'kind': 'text_chunk',
'turn': turn,
'text': text,
});
```
### `DebugCorpusLogger.onFunctionCall`
```
1. _emit({
'kind': 'function_call',
'turn': turn,
'accumulated_prefix': accumulatedPrefix,
'tool_name': toolName,
'args': args,
});
```
### `_emit(Map<String, dynamic> payload)`
```
1. try:
2. final line = '[CorpusLogger] ' + jsonEncode(payload);
3. developer.log(line, name: 'CorpusLogger');
4. catch (_):
5. // swallow — diagnostic 이 user flow 를 깨지 않게.
```
### `DebugCorpusLogger.maybeCreate`
```
1. if (!kDebugMode) return null;
2. const enabled = bool.fromEnvironment('ENABLE_CORPUS_LOG', defaultValue: false);
3. if (!enabled) return null;
4. return DebugCorpusLogger._();
```
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `jsonEncode` 가 args 의 non-serializable 키로 throw | `_emit` 의 try/catch swallow | void (silent) |
| `developer.log` I/O 실패 | swallow | void |
| `maybeCreate` 가 production 호출 | `kDebugMode=false` 분기에서 null | null (정상) |
| ctor 직접 호출 시도 | private ctor `_()` 로 차단 | 컴파일 에러 |
## 7. 엣지케이스
- **매우 빈번한 호출**: 토큰 단위 stream 이라 `onTextChunk` 가 초당 수십 회. `developer.log` 가 stdout flush 부담 — 단, debug only + 한시적이라 수용.
- **args 에 BigInt / DateTime**: `jsonEncode` 가 throw → swallow. corpus 결과 누락 시 코드 보강 (toString fallback) 가능하지만 본 설계는 swallow 만.
- **multi-turn 루프**: 같은 userTurn 내에서 turn 0, 1, 2 ... 각각의 prefix 가 모두 캡처되어야 비교 가능 — caller (ChatSessionController) 가 정확한 turn idx 를 넘긴다.
- **logger null 이지만 inject 됨**: callsite 가 `logger?.onTextChunk(...)` 패턴이므로 null-safe.
## 8. 복잡도 / 성능
- 시간: 각 호출 O(payload 크기). `jsonEncode` 가 prefix 길이에 선형.
- 공간: emit 마다 임시 string. 영구 보관 없음 (stdout sink).
- 호출 빈도: 토큰당 1회 (text_chunk), tool call 당 1회 (function_call). 한 userTurn 에 수십-수백 호출 가능 — debug only 라 수용.
## 9. 의존성
- `dart:convert``jsonEncode`.
- `dart:developer``log`.
- `flutter/foundation.dart``kDebugMode`.
- 환경 변수: `ENABLE_CORPUS_LOG` (dart-define).
## 10. 테스트 케이스
- [ ] **maybeCreate**: production 시뮬 (kDebugMode false fake) → null 반환.
- [ ] **maybeCreate**: debug + ENABLE_CORPUS_LOG=false → null.
- [ ] **maybeCreate**: debug + ENABLE_CORPUS_LOG=true → non-null DebugCorpusLogger.
- [ ] **onFunctionCall happy**: 인자 정상 → stdout 에 `[CorpusLogger]` 라벨 + JSON 한 줄 emit (capture for verification).
- [ ] **onFunctionCall non-serializable args**: `{'date': DateTime.now()}` → throw 안 함 (swallow), test 가 timeout 없이 종료.
- [ ] **onTextChunk** 빈 텍스트 → swallow 없이 정상 emit (filter 안 함, 무엇이 들어왔는지 그대로 기록하는 게 corpus 의 정직성).
> ENABLE_CORPUS_LOG 의 dart-define 기반 테스트는 `--dart-define=ENABLE_CORPUS_LOG=true` 로 별도 `flutter test` invocation 또는 mockable wrapper 로 분리. Architect 권고 = wrapper (`bool _readEnableFlag()` 를 visibleForTesting 으로 expose) 로 테스트 간소화.
## 11. 추적성
- 인수조건: AC1 (corpus 수집 인프라).
- 관련 ADR: 없음 (한시적 진단 도구).
- 본 이슈 종료 후 제거 여부 검토 — follow-up 이슈로 발행 권장 ("CorpusLogger 정리 — corpus 결과 반영 후 logger 제거 또는 영구화 결정").

View File

@@ -0,0 +1,116 @@
# 함수 설계서: `ChatSessionController.userTurn` partial push 분기 (#312)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect · **구현**: `app/lib/state/chat_providers.dart:144-153` 수정 · **테스트**: `app/test/state/chat_session_prefix_test.dart` (신규)
## 1. 시그니처
변경 없음 (메서드 시그니처 유지):
```dart
Future<void> userTurn(String text, BuildContext context) async;
```
본 설계서는 메서드 내부 event 루프의 `LlmFunctionCall` 분기만 다룬다.
## 2. 책임 (단일 책임, 1줄)
Event 루프가 `LlmFunctionCall` 을 받았을 때, corpus 결과 (≥5/15) 가 prefix 보존을 정당화한 경우에만 `accumulated``ModelChatMessage` 로 push 한 뒤 tool 처리로 break.
## 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| (loop local) `accumulated` | String | non-null, 빈 가능 | LlmTextChunk 누적 결과. |
| (loop local) `event` | LlmFunctionCall | non-null | Gemma 의 함수 호출 이벤트. |
| (instance) `logger` | `CorpusLogger?` | nullable | optional 진단. corpus 단계에서만 활성. |
| (instance) `state.messages` | `List<ChatMessage>` | non-null | 누적 메시지 history. |
## 4. 출력
- **반환**: 없음 (loop 내부 분기).
- **부수효과**:
- `logger?.onFunctionCall(...)` (corpus 활성 시).
- 경로 A: `state.messages``ModelChatMessage(accumulated)` append (단 trim 후 non-empty).
- 양 경로 공통: `toolCall = event; break;`.
## 5. 동작 / 알고리즘
### 경로 A (corpus 결과 ≥5/15 → push 채택)
```dart
} else if (event is LlmFunctionCall) {
toolCall = event;
// #312 — corpus 결과 X/15 (≥5) 가 의미있는 prefix → push.
logger?.onFunctionCall(turn, accumulated, event.name, event.args);
final trimmed = accumulated.trim();
if (trimmed.isNotEmpty) {
state = state.copyWith(
messages: [
...state.messages,
ModelChatMessage(trimmed),
],
);
}
break;
}
```
### 경로 B (corpus 결과 ≤4/15 → 폐기)
```dart
} else if (event is LlmFunctionCall) {
toolCall = event;
// #312 — corpus 측정 결과 X/15 만 의미있는 prefix → 의도적 폐기.
// ADR-0006 (docs/adr/0006-tool-call-prefix-discard.md) 참조.
// accumulated 는 버린다 — 회귀 가드는
// app/test/state/chat_session_prefix_test.dart 의 "폐기 회귀" 테스트.
logger?.onFunctionCall(turn, accumulated, event.name, event.args);
break;
}
```
`logger?.onTextChunk(turn, event.text)``LlmTextChunk` 분기에 동일하게 추가 (양 경로 공통).
### 양 경로 공통 추가 사항
- 컨트롤러 생성자에 optional `CorpusLogger? logger` 추가.
- Riverpod provider 가 `DebugCorpusLogger.maybeCreate()` 를 호출해 inject (production 에서는 null).
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `accumulated.trim()` 이 빈 문자열 | 경로 A 의 if 가드 → push 안 함 | 정상 break |
| `state.copyWith` 가 빈 messages 로 호출 | 정상 (no-op equivalent) | 정상 |
| logger 가 throw | logger 구현체 내부에서 swallow (fn-corpus_logger §6) | 정상 |
| `event.args` 가 null | `LlmFunctionCall` 계약상 non-null — 발생 시 LlmService 버그. catch 없음 (fail-fast). | LlmService 단에서 처리 |
## 7. 엣지케이스
- **빈 prefix 후 tool**: accumulated="" → 경로 A 의 trim guard 가 push 차단. ChatScreen 에 빈 버블 노출 안 됨.
- **whitespace only prefix** (`"\n\n "`): trim 후 empty → push 안 함.
- **prefix 가 multi-turn 루프의 turn 1+ 에서 발생**: 첫 turn 에서 tool 호출, 두 번째 turn 에서 LLM 이 또 prefix 후 tool 호출. 이때도 동일 로직 — accumulated 가 turn 별로 reset 되어 있음 (`var accumulated = '';` 가 for 루프 내부) 이므로 OK.
- **마지막 turn 의 prefix + 자연어 종료**: tool call 이 안 들어오고 `toolCall == null` 분기로 빠지면 기존 코드가 `ModelChatMessage(accumulated)` push — 본 설계와 무관.
- **prefix 가 그대로 사용자 입력 echo**: 운영 정의상 corpus 에서 N 으로 판정되나 구현은 echo 감지 안 함 (false positive 위험). 코드는 단순 trim/length 만.
## 8. 복잡도 / 성능
- 시간: O(accumulated.length) for trim. 무시 가능.
- 공간: `ModelChatMessage` 1개 (trim 된 prefix 길이).
- 호출 빈도: tool call 당 1회. userTurn 당 최대 `kChatMaxTurns` (4) 회.
## 9. 의존성
- 본 파일 (`chat_providers.dart`) 내 sealed `ChatMessage` (`UserChatMessage`/`ModelChatMessage`/`ToolCallChatMessage`).
- `LlmChatEvent` sealed class (`LlmTextChunk` / `LlmFunctionCall`).
- `CorpusLogger?` (fn-corpus_logger.md).
## 10. 테스트 케이스
- [ ] **경로 A happy**:
- Given: fake LlmService emit `[Text("수면 카탈로그를 보여드릴게요 "), FunctionCall("search_catalog", {"category":"sleep"})]`.
- When: `userTurn("수면 습관 추천")`.
- Then: `state.messages` 의 마지막 3 = `[UserChatMessage("수면 습관 추천"), ModelChatMessage("수면 카탈로그를 보여드릴게요"), ToolCallChatMessage("search_catalog", {category:"sleep"}, _)]`.
- [ ] **경로 A trim guard**:
- Given: fake emit `[Text("\n\n "), FunctionCall(...)]`.
- Then: `state.messages` 에 ModelChatMessage 추가 안 됨. 마지막 2 = `[User, ToolCall]`.
- [ ] **경로 A 빈 prefix**:
- Given: fake emit `[FunctionCall(...)]` (text chunk 없음).
- Then: state.messages 마지막 2 = `[User, ToolCall]`.
- [ ] **경로 B 폐기 회귀** (경로 B 채택 시):
- Given: fake emit `[Text("의미있는 한국어 prefix"), FunctionCall(...)]`.
- Then: state.messages 에 ModelChatMessage 없음 — corpus 결과로 폐기가 정당화됨을 명시적으로 assert.
> 모든 케이스는 mocked `LlmService` + 실제 ChatSessionController. 실 Gemma 통합은 corpus 수집 (Phase A) 에서 검증.
## 11. 추적성
- 인수조건: AC2 (조건부 구현), AC3 (단위 테스트).
- 관련 ADR: ADR-0006 (조건부 — 경로 B 채택 시).

View 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 진단 완료까지 블로커).

View File

@@ -1,6 +1,6 @@
# AI 코치와 대화하기 (사용자 가이드)
> 적용 버전: **v0.4.0 이상** · Redmine #260 · 관련 레퍼런스: [docs/reference/260-in-app-tool-calling.md](../reference/260-in-app-tool-calling.md)
> 적용 버전: **v0.4.0 이상** (warm-up 은 **v0.4.1+**) · Redmine #260 / #311 · 관련 레퍼런스: [도구 호출](../reference/260-in-app-tool-calling.md) · [Warm-up](../reference/311-llm-warmup.md)
>
> 본 가이드는 v0.3.0 의 [AI 도움 켜기·끄기](ai-help-onboarding.md) 가 끝났다는 전제에서 시작합니다. 모델이 단말에 준비되어 있어야 합니다.
@@ -52,6 +52,12 @@ A. 4 회를 넘기면 자동으로 멈추고 "도구 호출 루프가 너무 길
**Q. 응답이 너무 느린 것 같아요.**
A. 단말 모델이라 1턴 평균 2~5초가 정상입니다. AI 가 도구를 호출하면 round trip 이 한 번 더 들어가 6~10초가 될 수 있어요.
**Q. ChatScreen 진입 시 입력창에 "AI 준비 중…" 이 떠요.**
A. v0.4.1 부터 모델을 백그라운드로 미리 시동합니다 (warm-up). 첫 진입 시 한 번만 보이고 평균 2~8초 안에 사라집니다. 그동안 메시지를 미리 타이핑해도 되고, send 버튼만 비활성 상태로 기다립니다. 다시 진입하면 즉시 사용 가능합니다.
**Q. "AI 모델 파일을 찾을 수 없어요." / "AI 를 시작하지 못했어요." 가 떠요.**
A. 파일을 못 찾는 경우는 [설정으로 가기] 로 이동해 재다운로드, 일시적 시작 실패는 [다시 시도] 로 회복합니다. 설정에서 다운로드를 완료하고 돌아오면 자동으로 다시 시도합니다.
**Q. 도구 결과가 잘렸어요.**
A. 모델 컨텍스트 보호를 위해 도구 결과는 2KB 로 제한됩니다. 잘림이 발생하면 AI 가 자동으로 단건 조회 (`프로토콜 상세`) 도구를 다시 부르도록 안내됩니다.

View File

@@ -0,0 +1,103 @@
# LLM warm-up (#311)
> 적용 버전: **v0.4.1 이상** · Redmine #311 · 설계서: [docs/design/311-llm-warmup/](../design/311-llm-warmup/)
`ChatScreen` 진입 시 LLM 모델을 백그라운드로 warm-up 하여 첫 send 의 cold native-init 비용 (수 초) 을 제거한다.
## 상태 머신
`app/lib/state/chat_warmup_provider.dart``sealed class ChatWarmupState`.
| 상태 | 진입 조건 | UI 동작 |
|------|-----------|---------|
| `Idle` | 초기값 | 평상 |
| `Loading` | `quickCheck = ready` + `llm.isLoaded = false``llm.load()` in-flight | hintText 교체, send 자리 spinner |
| `Ready` | `load()` 성공 또는 `llm.isLoaded = true` fast path | 평상, send 즉시 활성 |
| `Failed(kind)` | `load()` throw | `_WarmupErrorBanner` 표시 |
| `Unavailable` | `quickCheck != ready` (opt-out / downloading / missing) | 평상 (warmup 라벨 X, 기존 lazy 경로 fallback) |
`ChatWarmupFailureKind`:
- `fileMissing``FileSystemException`. 회복 = `SettingsScreen` push (재다운로드).
- `runtime` — 기타. 회복 = `retry()`.
## API
### `ChatWarmupController`
```dart
final chatWarmupProvider =
StateNotifierProvider.autoDispose<ChatWarmupController, ChatWarmupState>(...);
```
- `Future<void> start()` — quickCheck → load → state 전이. 재진입 가드 (Loading 중 호출은 no-op). `ChatScreen.initState``postFrameCallback` 에서 호출.
- `Future<void> retry()` — Idle reset 후 start() 재호출.
- dispose 시 `_disposed = true``_safeSet` 가 후속 state 변경 무시.
### `ModelLifecycle.quickCheck()`
```dart
Future<ModelAvailability> quickCheck();
```
`checkAvailability()` 의 lightweight 버전. SHA-256 재해싱 (~2.4GB) 을 건너뛰고 meta_kv + 파일 존재만으로 ready 추정. tamper 감지는 `checkAvailability()` 의 cold path (SettingsScreen) 에 위임.
| meta_kv 상태 | quickCheck 반환 |
|---|---|
| `ai_opt_in != 'true'` | `missing` |
| `ai_download_state in (downloading, paused)` | `downloading` |
| `ai_model_path` null | `missing` |
| `ai_model_sha256` null | `corrupt` |
| 파일 부재 | `missing` |
| 그 외 | `ready` |
| 내부 throw | `corrupt` (보수적 fallback) |
### Concurrent load guard
`GemmaLlmService.load()` + `MockLlmService.load()``_loadingFuture` 가드 공유:
```dart
Future<void> load() {
if (_loaded) return Future.value();
final existing = _loadingFuture;
if (existing != null) return existing;
final future = _doLoad();
_loadingFuture = future;
return future.whenComplete(() { _loadingFuture = null; });
}
```
ChatScreen warm-up + `userTurn` lazy load 가 race 해도 native init 1회만 실행. `_doLoad` throw 시 `whenComplete``_loadingFuture = null` 처리 → 다음 caller 가 새 시도.
## UI binding (`chat_screen.dart`)
- `TextField.enabled = !state.isStreaming` — warmup state 와 무관 (타이핑 미리 작성 가능, UX R1+R2).
- `hintText` 분기 — warmup loading 시 `AI 준비 중… 첫 시작은 몇 초 걸려요`, 그 외 평상.
- send 영역 — `state.isStreaming || isWarming` 이면 `CircularProgressIndicator(strokeWidth: 2)`, 그 외 `IconButton.filled`.
- `_WarmupErrorBanner` (`Failed` 일 때만 표시):
- 메시지 본문 = 상태 기술만 (명령형 X, AC12).
- 우측 정렬 `OutlinedButton`:
- `fileMissing``[설정으로 가기]` + `Navigator.push(SettingsScreen).then((_) => retry())` (pop 후 자동 retry).
- `runtime``[다시 시도]` + `retry()` (즉시).
## 마이크로카피
| 상황 | 한국어 |
|------|--------|
| warmup 중 hintText | `AI 준비 중… 첫 시작은 몇 초 걸려요` |
| 평상 hintText | `습관 추가, 기록, 카탈로그 질문…` |
| Failed(fileMissing) | `AI 모델 파일을 찾을 수 없어요.` |
| Failed(runtime) | `AI 를 시작하지 못했어요.` |
명령형 ("다시 시도해주세요" 등) 은 본문 금지 — 행동은 버튼이 담당.
## 테스트
- `app/test/state/chat_warmup_test.dart` — 8 unit (AC1-2, 5-7, 11-12 + retry + 재진입 가드).
- `app/test/data/ai/model_lifecycle_test.dart` — quickCheck 4 신규.
- `app/test/ui/chat_screen_test.dart` — widget E2E 보류 (NOTE comment 사유). `CircularProgressIndicator` 무한 ticker ↔ `pumpAndSettle` race 가 framework-level 한계.
## 관련 문서
- 설계서: [docs/design/311-llm-warmup/](../design/311-llm-warmup/)
- 사용자 가이드: [AI 코치와 대화하기](../guides/ai-chat-using.md)
- 변경 이력: [CHANGELOG.md](../../CHANGELOG.md) [0.4.1]