Files
life-helper/docs/reference/215-ai-frame-suggest.md
joungmin ed340839a0 [Documenter] #215 Reference + guide + design Approved
- docs/reference/215-ai-frame-suggest.md — v0.2.0 모듈/함수/Riverpod/meta_kv 사양
- docs/guides/ai-help-onboarding.md — AI 도움 켜기/끄기 사용자 가이드
- docs/design/215-gemma-frame-suggest/{README,fn-suggest_frame,fn-model_lifecycle}
  상태 Draft → Approved, 추적성 헤더에 실제 구현 파일/테스트 경로 + 레퍼런스/가이드 cross-link
- docs/README.md — 현재 발행된 문서 인덱스 섹션 추가

Refs #215
2026-06-12 13:32:29 +09:00

192 lines
11 KiB
Markdown

# Reference: AI 프레임 제안 (#215, v0.2.0)
> **상태**: 구현 후 동기화 · **추적성** — Redmine #215 · 설계서: [docs/design/215-gemma-frame-suggest/](../design/215-gemma-frame-suggest/) · ADR-0003 · 태그 `v0.2.0`
>
> 본 문서는 v0.2.0 시점의 **실제 코드 사양**이다. 설계 의도/대안은 설계서·ADR 을 참조.
## 1. 모듈 지도
```
lib/
data/ai/
llm_service.dart — LlmService 추상 + MockLlmService
gemma_llm_service.dart — GemmaLlmService (stub, OQ-1 후 활성)
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` — stub. `load` / `generateStructured` 모두 `throw UnimplementedError`. `unload` 만 idempotent. OQ-1 해결 후 flutter_gemma 호출로 채움.
### `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` 제외) 삭제. **현재 `File.delete()` try/catch 미감쌈** (F2, placeholder URL 라 도달 불가). |
`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>` | placeholder URL+SHA (OQ-1) |
| `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 | (DEFER — OQ-1 해결 후 corpus 평가) | — |
신규 합계 31 (parse 8 + few_shot 7 + suggest 9+3 + lifecycle 7 + AC6 widget 4 + AC2 state 3). 전체 71 통과 / analyze 0.
## 8. 알려진 제약
- **OQ-1**: `_kModelUrlPlaceholder = 'https://example.invalid/...'`, `_kModelShaPlaceholder = 'PENDING_OQ_1'`. v0.2.0 의 옵트인 다운로드는 graceful 실패가 정상 동작.
- **F1**: 설계서 §8.8 / §9 의 "60초 idle 시 `unload()`" 미구현. `GemmaLlmService` 가 stub 라 현재 무의미.
- **F2**: `ModelLifecycle.purge()``File.delete()` try/catch 미감쌈. placeholder URL → 파일 미존재 → 도달 불가.
## 9. 다음 단계 / 확장 포인트
- OQ-1 해결: `_kModelUrlPlaceholder` 자리에 실 Gemma 4 E2B Q4_0 URL+SHA 고정. `GemmaLlmService.load` / `generateStructured` 본문 채우기 (flutter_gemma 패키지 추가).
- 시나리오 #2~#6 (앵커 추출 / dose variants / if-then / lapse 구조화 / 주간 요약): 모두 `LlmService.generateStructured` 에 새 schema 추가하는 형태로 확장. 도메인 함수는 `lib/domain/ai/` 에 신규 파일.
- 멀티 모델 슬롯 (E2B + E4B): ADR-0004 후보.