Files
life-helper/docs/reference/215-ai-frame-suggest.md
joungmin 25be18063e [08-Documenter] #218 docs marked Approved + v0.3.0 sync
- 설계서 218-gemma-real-integration/README.md → Approved + AC 체크박스 채움 + 실제 구현/테스트 파일 경로 추적성 갱신
- fn-gemma_llm_service.md → Approved (v2)
- reference/215-ai-frame-suggest.md → v0.3.0 (commit da60dd1 핀)
- guides/ai-help-onboarding.md → 적용 버전 v0.3.0 + RAM 4GB 요구사항 명시
- docs/README.md 인덱스 v0.3.0 표기

AC-7 (실 단말 E2E) 만 DEFER — 사용자 실기 검증 결과로 별도 갱신.

Refs #218
2026-06-12 16:22:40 +09:00

194 lines
12 KiB
Markdown

# Reference: AI 프레임 제안 (#215 + #218, v0.3.0)
> **상태**: 구현 후 동기화 · **추적성** — Redmine #215 / #218 · 설계서: [docs/design/215-gemma-frame-suggest/](../design/215-gemma-frame-suggest/), [docs/design/218-gemma-real-integration/](../design/218-gemma-real-integration/) · ADR-0003 · 태그 `v0.2.0` (placeholder) → `v0.3.0` (real Gemma 4, commit da60dd1)
>
> 본 문서는 v0.3.0 의 **실제 코드 사양**이다. v0.2.0 의 placeholder 상태는 #218 에서 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합으로 대체됨. 설계 의도/대안은 설계서·ADR 을 참조.
## 1. 모듈 지도
```
lib/
data/ai/
llm_service.dart — LlmService 추상 + MockLlmService
gemma_llm_service.dart — GemmaLlmService (flutter_gemma 0.16.5 + Gemma 4 E2B 실 구현)
model_lifecycle.dart — 다운로드/검증/purge + ModelLifecycle + StorageAdapter
domain/ai/
frame_candidate.dart — FrameCandidate, FrameLevel (enum)
suggest_frame.dart — suggestFrame() 메인 함수 + L2:2+L3:1 분포
few_shot_builder.dart — buildFewShotPrompt()
parse_response.dart — parseFrameCandidates()
state/
ai_providers.dart — Riverpod providers + ModelDownloadController
ui/
screens/settings_screen.dart — AI 도움 토글 + 다운로드 진행률
widgets/frame_suggestion_dialog.dart — 후보 카드 선택
screens/habit_create_screen.dart — _AiSuggestButton (3분기)
```
## 2. 도메인 모델
### `FrameCandidate` (`lib/domain/ai/frame_candidate.dart`)
| 필드 | 타입 | 의미 |
|---|---|---|
| `level` | `FrameLevel` | `l0` / `l1` / `l2` / `l3` (출력에는 L2/L3 만 살아남음) |
| `framedText` | `String` | 모델이 생성한 한국어 문장 (≤120자) |
| `confidence` | `double` | 0.0~1.0 (모델이 반환한 score, 없으면 0.5) — UI 표시 X |
| `sourcePatternId` | `String?` | few-shot 매칭에 쓰인 `FramePattern.id` |
### Function-calling 스키마 (`kFrameCandidatesSchema`)
`suggest_frame.dart` 상단의 `const Map<String, dynamic>`. `emit_frame_candidates` 함수의 parameters. `minItems:1 / maxItems:3`, 각 `item.required = ['level','framed_text']`.
## 3. 핵심 함수
### `suggestFrame(input, {llm, framePatterns, timeout=10s}) → List<FrameCandidate>`
순수에 가까움 (`llm` + `framePatterns` 만 의존). **절대 throw 하지 않음**. 모든 실패 → `const []`.
흐름:
1. `input.rawText.trim()` 길이 검사 (1~200자). 벗어나면 빈 리스트.
2. `buildFewShotPrompt(input, framePatterns)` 로 prompt 조립.
3. `llm.generateStructured(prompt, schema).timeout(10s)` 호출. 어떤 예외든 catch → 빈 리스트.
4. `parseFrameCandidates(json)` 으로 디코드. `FormatException` catch → 빈 리스트.
5. 각 후보에 `validateFrameLevel` 적용. `reject` 인 후보만 드랍.
6. `_shapeDistribution(validated, l2Quota:2, l3Quota:1)` — L2 먼저 최대 2개 + L3 최대 1개. **부족 시 패딩 X** (graceful — 적은 카드).
### `buildFewShotPrompt(input, framePatterns, {maxFewShot=5}) → String`
순수. `_tokenize` (whitespace + 한국어 punctuation 분리) → `_scorePattern` (avoidanceKeyword 3점 + domain 1점) → 상위 N개. 매칭 0 이면 시드 앞에서 N개. patterns 비어있으면 시스템 prompt 만 emit.
마지막에 명시 지시: `"L2 2개 + L3 1개, 총 3개 후보로 변환. 순서는 L2 → L2 → L3. emit_frame_candidates 함수로 호출."`
### `parseFrameCandidates(json) → List<FrameCandidate>`
- 최상위 `candidates` 없거나 `List` 아니면 `throw FormatException`.
- 개별 item 결손 (level/framed_text 미존재, 빈 문자열, >120자) → 조용히 skip.
- `level` 은 대소문자 무시 매칭.
- `confidence` 결손 시 0.5 기본값, 범위 밖이면 `clamp(0, 1)`.
## 4. 데이터 계층
### `LlmService` (abstract)
```dart
abstract class LlmService {
bool get isLoaded;
Future<void> load();
Future<void> unload(); // idempotent
Future<Map<String, dynamic>> generateStructured(String prompt, Map schema);
}
```
계약:
- `load``isLoaded == true`.
- 미로드 상태에서 `generateStructured` 호출 → `StateError`.
- 스키마/응답 깨짐 → `FormatException`.
- timeout 은 **호출자 책임** (`suggestFrame` 가 10s 적용).
구현 2개:
- `MockLlmService``enqueueResponse(Map)` / `enqueueError(Object)`. `callCount`, `lastPrompt`, `lastSchema`, `responseDelay` 노출. **v1 런타임 기본 주입**.
- `GemmaLlmService` — flutter_gemma 0.16.5 + Gemma 4 E2B 실 구현 (#218). `load``FlutterGemma.initialize``installModel(modelType: gemma4, fileType: litertlm).fromFile(modelPath).install()``getActiveModel(maxTokens: 2048)`. `generateStructured``createChat(modelType: gemma4, supportsFunctionCalls: true, toolChoice: required, tools: [Tool(...)])` + `collectFunctionCall(stream, fnName)` 로 SDK 의 native function calling 사용. `_lazyLlmService` (main.dart) 가 ModelLifecycle 의 availability 에 따라 Gemma vs Mock 자동 분기.
### `ModelLifecycle` (`lib/data/ai/model_lifecycle.dart`)
생성자 의존성: `MetaDao meta`, `ModelConfig config`, `StorageAdapter? storage`, `http.Client? httpClient`.
| 메서드 | 시그니처 | 비고 |
|---|---|---|
| `checkAvailability` | `Future<ModelAvailability>` | `ready` / `missing` / `corrupt` / `downloading`. 옵트인 OFF → 항상 `missing`. SHA mismatch → `corrupt`. 어떤 I/O 예외든 catch → `corrupt`. |
| `download` | `Stream<DownloadProgress>` | Range 기반 resume. SHA-256 검증 후 `.tmp → final.rename()`. 모든 실패는 `state: failed` 로 emit (throw X). |
| `purge` | `Future<int>` | 해제된 byte 수. 파일 + `.tmp` + meta_kv 4키 (`ai_opt_in` 제외) 삭제. F2 hardening (#218): `File.delete()` / `temp.delete()` / `meta.remove()` 각각 try/catch 로 감쌈 → 단일 OS 플레이크가 opt-out 을 막지 않음. freed-bytes 는 성공한 삭제만 합산. |
`StorageAdapter``supportDir()` + `rangeGet(Uri, int)` 2개 메서드만 — 테스트가 `_FakeStorage` 로 주입.
### meta_kv 키 5개 (`AiMetaKeys`)
| 키 | 값 | 의미 |
|---|---|---|
| `ai_opt_in` | `'true'` / `'false'` | 사용자 옵트인 |
| `ai_model_path` | 절대경로 | 다운로드 완료 시 |
| `ai_model_sha256` | hex string | 검증 통과 시 |
| `ai_download_state` | `'idle'` / `'downloading'` / `'paused'` / `'completed'` / `'failed'` | 진행 상태 |
| `ai_download_bytes` | int as string | 재시작 시 resume 좌표 |
→ Drift schema 변경 0. `meta_kv` 테이블은 #204 에서 이미 존재.
## 5. 상태 계층 (Riverpod, `lib/state/ai_providers.dart`)
| Provider | 타입 | 책임 |
|---|---|---|
| `modelLifecycleProvider` | `Provider<ModelLifecycle>` | 실 URL: `https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm`, SHA-256 `181938105e...39a63c`, 2.41GB (#218) |
| `aiSettingsProvider` | `FutureProvider<bool>` | meta_kv 읽어서 옵트인 상태 |
| `aiSettingsControllerProvider` | `Provider<AiSettingsController>` | `setOptIn(bool) → int(freed)` |
| `modelDownloadControllerProvider` | `StateNotifierProvider<ModelDownloadController, DownloadProgress?>` | start / pause / resume / cancel |
| `modelAvailabilityProvider` | `FutureProvider<ModelAvailability>` | `lifecycle.checkAvailability()` |
| `framePatternsProvider` | `FutureProvider<List<FramePatternModel>>` | Drift → 도메인 |
| `llmServiceProvider` | `Provider<LlmService>` | **반드시 override**`main.dart``MockLlmService` 주입 |
| `frameSuggestionsProvider` | `FutureProvider.autoDispose.family<List<FrameCandidate>, SuggestFrameInput>` | `llm.load` (실패 시 빈 리스트) → `suggestFrame` |
### `AiSettingsController.setOptIn(value)`
- `value=true`: `meta_kv['ai_opt_in']='true'` → invalidate(settings, availability) → `ModelDownloadController.start()` 호출 (AC2 — 다운로드 스트림 시작).
- `value=false`: `ModelDownloadController.cancel()``ModelLifecycle.purge()``meta_kv['ai_opt_in']='false'` → invalidate. 반환: 해제된 byte 수.
### `ModelDownloadController`
- `start()`: 기존 subscription cancel 후 `lifecycle.download().listen(...)`. 완료 시 `modelAvailabilityProvider` invalidate.
- `pause()`: subscription cancel + state 를 `paused` 로. `.tmp` 파일 + meta_kv 보존 → 다음 `start()` 가 Range 로 resume.
- `resume()` = `start()` alias.
- `cancel()`: subscription cancel + state = `null` (idle).
## 6. UI 계층
### `SettingsScreen` (`lib/ui/screens/settings_screen.dart`)
- `SwitchListTile` — 토글 시 옵트인은 동의 다이얼로그 (저장공간/WiFi/단말 처리 3개 bullet) → `setOptIn(true)`. 옵트아웃은 확인 다이얼로그 → `setOptIn(false)` → "공간 확보됨 X.X MB" 토스트.
- `_DownloadProgressTile` — 옵트인 ON + 진행 중일 때만 표시. 상태별 컬러 라벨 + 둥근 `LinearProgressIndicator(minHeight:6)` + `FilledButton.tonalIcon` 재개/재시도. `_friendlyError()` 가 내부 코드를 한국어로 매핑:
- `network:*` → "네트워크 연결을 확인하고 다시 시도해주세요."
- `http *` → "서버 응답이 올바르지 않습니다."
- `stream:*` → "다운로드가 중단되었어요. 다시 시도하면 이어받습니다."
- `sha mismatch` → "파일이 손상되었어요. 다시 시도하면 처음부터 받습니다."
### `_AiSuggestButton` (3분기, AC6)
| optIn | availability | 렌더 |
|---|---|---|
| false | * | `SizedBox.shrink()` (숨김) |
| true | `!= ready` | `TextButton` (disabled) + `Tooltip("AI 도움을 먼저 켜주세요")` |
| true | `ready` | `TextButton` (enabled, tap → `FrameSuggestionDialog.show`) |
### `FrameSuggestionDialog`
`AlertDialog` 안에 `frameSuggestionsProvider(input).when(loading/error/data)`. data 가 empty 면 "더 구체적으로 입력해주시면 더 좋은 제안을 드릴 수 있어요" + "다시 시도" 버튼. data 가 있으면 `_CandidateCard` 리스트 — L3 는 `scheme.primary` 배지, L2 는 `scheme.secondary` 배지. 탭 시 `Navigator.pop(c)``FrameCandidate` 반환.
## 7. 테스트 매핑
| AC | 테스트 파일 | 케이스 수 |
|---|---|---|
| AC1 | `flutter analyze` + `flutter build apk --debug/release` | CI |
| AC2 | `test/state/model_download_controller_test.dart` | 3 |
| AC3, AC8 | `test/data/ai/model_lifecycle_test.dart` | 7 |
| AC4 | `test/domain/ai/suggest_frame_test.dart` (분포 3) | 3 |
| AC5 | `test/domain/ai/suggest_frame_test.dart` (FrameLevel 사용) | 1 |
| AC6 | `test/ui/ai_suggest_button_visibility_test.dart` | 4 |
| AC7 | `test/domain/ai/parse_response_test.dart` | 8 |
| AC9 | `test/domain/ai/suggest_frame_test.dart` (graceful) | 다수 |
| AC10 | (별도 이슈 #221 — corpus 품질 평가) | — |
| #218 AC8 | `test/data/ai/gemma_llm_service_test.dart` (`collectFunctionCall` 8 + ParallelFCR 2) | 10 |
신규 합계 41 (parse 8 + few_shot 7 + suggest 9+3 + lifecycle 7 + AC6 widget 4 + AC2 state 3 + gemma_llm 10). v0.3.0-dev 시점 전체 81 통과 / analyze 0.
## 8. 알려진 제약
- **#218 AC-6 (디바이스 게이트)**: 저사양 단말 (RAM < 4GB) 에서는 SettingsScreen 의 AI 토글 disabled + "이 단말에서는 AI 도움을 사용할 수 없어요 (RAM 4GB 이상 필요)" 안내. RAM 조회 = MethodChannel `life_helper/device_caps``MainActivity.kt` 에서 `ActivityManager.MemoryInfo.totalMem`. `device_info_plus` 도 deps 에 있지만 RAM 임계 (4GB) 측정엔 미사용 (해당 패키지는 `isLowRamDevice` ≈ 1GB 만 제공). iOS 는 #218 범위 밖.
- **#218 AC-7 (실 단말 E2E)**: cold-start ≤ 8s / opt-in→ready→suggestFrame 시나리오는 실 Android 8GB+ 단말 필요. Host CI 는 검증 불가 → 별도 디바이스 랩 단계.
- **F1**: 설계서 §8.8 / §9 의 "60초 idle 시 `unload()`" 미구현. #219 별도 이슈 — 단발 호출 후 즉시 unload 가 안전한 기본값.
- **#221 AC10 corpus 품질 평가**: 30+ 한국어 라이브 입력 corpus 로 L2 ≥ 70% / L3 ≥ 50% 측정. 실 모델 통합 후 별도 이슈.
## 9. 다음 단계 / 확장 포인트
- **#215 follow-up 4 이슈** (#218 다음): **#219** 60s idle auto-unload (F1), **#220** purge hardening (F2), **#221** AC10 한국어 corpus 품질 평가 (≥70%), **#222** production keystore / Play Store 준비.
- 시나리오 #2~#6 (앵커 추출 / dose variants / if-then / lapse 구조화 / 주간 요약): 모두 `LlmService.generateStructured` 에 새 schema 추가하는 형태로 확장. 도메인 함수는 `lib/domain/ai/` 에 신규 파일.
- 멀티 모델 슬롯 (E2B + E4B): ADR-0004 후보.