[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
This commit is contained in:
@@ -61,3 +61,28 @@ docs/
|
||||
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
|
||||
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
|
||||
```
|
||||
|
||||
## 현재 발행된 문서 (인덱스)
|
||||
|
||||
### 설계서 (`design/`)
|
||||
|
||||
- [204-flutter-bootstrap](./design/204-flutter-bootstrap/) — Phase 1 MVP Drift 21 테이블 + 도메인 함수 + UI 4 화면
|
||||
- [215-gemma-frame-suggest](./design/215-gemma-frame-suggest/) — Phase 2-A on-device Gemma 4 프레임 자동 생성
|
||||
|
||||
### ADR (`adr/`)
|
||||
|
||||
- [0001-dose-variants.md](./adr/0001-dose-variants.md) — Dose Variants 도입
|
||||
- [0002-dose-variants-normalized.md](./adr/0002-dose-variants-normalized.md) — Dose Variants 정규화 방식
|
||||
- [0003-on-device-llm-gemma.md](./adr/0003-on-device-llm-gemma.md) — On-device LLM (Gemma 4) 도입
|
||||
|
||||
### 레퍼런스 (`reference/`)
|
||||
|
||||
- [215-ai-frame-suggest.md](./reference/215-ai-frame-suggest.md) — AI 프레임 제안 모듈 사양 (v0.2.0)
|
||||
|
||||
### 가이드 (`guides/`)
|
||||
|
||||
- [ai-help-onboarding.md](./guides/ai-help-onboarding.md) — AI 도움 켜기·끄기 사용자 가이드
|
||||
|
||||
### 파이프라인 (`pipeline/`)
|
||||
|
||||
- [QUEUE-PROTOCOL.md](./pipeline/QUEUE-PROTOCOL.md) — 8 페르소나 큐 프로토콜
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
# 설계서: Phase 2-A — Gemma 4 on-device 프레임 자동 생성 (#215)
|
||||
|
||||
> **상태**: Draft
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-12
|
||||
> **추적성** — Redmine: #215 · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md)
|
||||
> · 구현 파일: `app/lib/data/ai/`, `app/lib/domain/ai/`, `app/lib/state/ai_providers.dart` (TBD by Developer)
|
||||
> · 테스트: `app/test/domain/ai/`, `app/test/data/ai/` (TBD by Developer)
|
||||
> **상태**: Approved (v0.2.0, 커밋 `0d1db2d`)
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-12 (Documenter)
|
||||
> **추적성** — Redmine: #215 · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) · 릴리스 태그: `v0.2.0`
|
||||
> · 구현 파일:
|
||||
> - `app/lib/data/ai/llm_service.dart` (abstract + MockLlmService)
|
||||
> - `app/lib/data/ai/gemma_llm_service.dart` (stub — OQ-1 후 활성)
|
||||
> - `app/lib/data/ai/model_lifecycle.dart` (download/verify/purge)
|
||||
> - `app/lib/domain/ai/frame_candidate.dart`
|
||||
> - `app/lib/domain/ai/suggest_frame.dart`
|
||||
> - `app/lib/domain/ai/few_shot_builder.dart`
|
||||
> - `app/lib/domain/ai/parse_response.dart`
|
||||
> - `app/lib/state/ai_providers.dart` (Riverpod providers + ModelDownloadController)
|
||||
> - `app/lib/ui/screens/settings_screen.dart`
|
||||
> - `app/lib/ui/widgets/frame_suggestion_dialog.dart`
|
||||
> - `app/lib/ui/screens/habit_create_screen.dart` (`_AiSuggestButton`)
|
||||
> · 테스트:
|
||||
> - `app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dart`
|
||||
> - `app/test/data/ai/model_lifecycle_test.dart`
|
||||
> - `app/test/state/model_download_controller_test.dart`
|
||||
> - `app/test/ui/ai_suggest_button_visibility_test.dart`
|
||||
> · 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../../reference/215-ai-frame-suggest.md)
|
||||
> · 사용 가이드: [docs/guides/ai-help-onboarding.md](../../guides/ai-help-onboarding.md)
|
||||
>
|
||||
> **알려진 follow-up** (Reviewer F1/F2 + OQ-1):
|
||||
> - OQ-1: 실제 Gemma 4 E2B Q4_0 모델 URL + SHA-256 — 현재 placeholder (`example.invalid`).
|
||||
> - F1: 60초 idle auto-unload 미구현 — stub 상태라 무의미. OQ-1 해결 시 추가.
|
||||
> - F2: `ModelLifecycle.purge()` 의 `File.delete()` try/catch 미감쌈 — placeholder URL 라 도달 불가.
|
||||
> **하위 문서**:
|
||||
> - [fn-suggest_frame.md](./fn-suggest_frame.md) — Gemma 4 호출 + few-shot 조립 + 응답 파싱
|
||||
> - [fn-model_lifecycle.md](./fn-model_lifecycle.md) — 모델 다운로드 / 로드 / 언로드 / 삭제 + LlmService 인터페이스
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# 함수 설계서: `ModelLifecycle` + `LlmService` (#215)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/model_lifecycle.dart`, `llm_service.dart`, `gemma_llm_service.dart` (TBD) · **테스트**: `app/test/data/ai/{model_lifecycle,gemma_llm_service}_test.dart` (TBD)
|
||||
> **부모 설계서**: ./README.md · **상태**: Approved (v0.2.0)
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/model_lifecycle.dart`, `llm_service.dart`, `gemma_llm_service.dart` (stub) · **테스트**: `app/test/data/ai/model_lifecycle_test.dart` + `app/test/state/model_download_controller_test.dart` (총 10 케이스)
|
||||
> · 동기화 노트: `GemmaLlmService` 는 모든 메서드가 `UnimplementedError` 를 던지는 stub 상태 — OQ-1 (실 모델 URL+SHA) 해결 시 활성. v1 런타임은 `MockLlmService` 가 `llmServiceProvider` 에 주입.
|
||||
|
||||
> 본 문서는 모델 수명주기와 추론 인터페이스를 묶어 다룬다. 모두 I/O 경계 — flutter_gemma + 파일시스템 + 네트워크 + 네이티브 메모리.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 함수 설계서: `suggestFrame` + `buildFewShotPrompt` + `parseFrameCandidates` (#215)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/domain/ai/suggest_frame.dart`, `few_shot_builder.dart`, `parse_response.dart` (TBD) · **테스트**: `app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dart` (TBD)
|
||||
> **부모 설계서**: ./README.md · **상태**: Approved (v0.2.0)
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/domain/ai/suggest_frame.dart`, `few_shot_builder.dart`, `parse_response.dart` · **테스트**: `app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dart` (총 27 케이스)
|
||||
|
||||
> 본 문서는 도메인 핵심 알고리즘 함수 3개를 묶어 다룬다. 셋 다 순수 함수 (또는 LlmService 만 의존) 로 테스트 가능성을 우선한다.
|
||||
|
||||
|
||||
96
docs/guides/ai-help-onboarding.md
Normal file
96
docs/guides/ai-help-onboarding.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# AI 도움 켜기·끄기 (사용자 가이드)
|
||||
|
||||
> 적용 버전: **v0.2.0 이상** · Redmine #215 · 관련 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../reference/215-ai-frame-suggest.md)
|
||||
|
||||
life-helper 는 사용자가 입력한 자유 문장(예: "술 끊고 싶어")을 Huberman 프로토콜 기반 L2/L3 프레임 문장으로 변환해주는 **단말 내 AI 보조**를 제공합니다. 모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다.
|
||||
|
||||
## 누구를 위한 가이드인가
|
||||
|
||||
- 새 습관을 추가할 때 "어떻게 표현하면 좋을지" 막막한 사용자.
|
||||
- AI 기능을 켜기 전에 데이터/저장공간/배터리 영향을 미리 확인하고 싶은 사용자.
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
1. **기본 OFF.** AI 기능은 사용자가 명시적으로 켜야 동작합니다.
|
||||
2. **단말 처리.** 입력 텍스트는 단말 밖으로 나가지 않습니다.
|
||||
3. **수동 입력 100% 유지.** AI 가 꺼져 있거나 모델 다운로드가 안 되어 있어도, "프레임 문구" 입력란에 직접 작성하는 경로는 항상 살아있습니다.
|
||||
|
||||
## AI 도움 켜기
|
||||
|
||||
1. 하단 탭에서 **설정** 진입.
|
||||
2. "AI 도움 켜기" 토글 탭.
|
||||
3. 동의 다이얼로그가 뜹니다:
|
||||
- **파일 크기 ≈ 1.5GB** (Gemma 4 E2B Q4_0 모델 — 단말에 한 번만 다운로드)
|
||||
- **WiFi 연결 권장** (셀룰러 대역폭 절약)
|
||||
- 모든 처리는 단말 — 입력 텍스트 외부 송출 없음
|
||||
4. **"동의하고 다운로드"** 탭 → 백그라운드 다운로드 시작.
|
||||
|
||||
### 다운로드 진행 화면
|
||||
|
||||
설정 화면 "AI 도움" 섹션 아래에 진행 상태가 표시됩니다:
|
||||
|
||||
| 상태 | 표시 | 가능한 조작 |
|
||||
|---|---|---|
|
||||
| 다운로드 중 | 진행률 % + 받은 용량 | **일시정지** 버튼 |
|
||||
| 일시정지됨 | 마지막 진행률 | **재개** 버튼 (이어받기) |
|
||||
| 실패 | 한국어 안내 메시지 (네트워크/서버/손상별) | **다시 시도** 버튼 |
|
||||
| 준비 완료 | "준비 완료" 라벨 + 받은 용량 | (조작 없음 — 사용 가능) |
|
||||
|
||||
다운로드 도중 앱을 강제 종료해도 다음 실행 시 같은 자리에서 이어받습니다 (HTTP Range 기반).
|
||||
|
||||
### 다운로드 실패 시 한국어 안내
|
||||
|
||||
| 화면 메시지 | 의미 | 권장 조치 |
|
||||
|---|---|---|
|
||||
| "네트워크 연결을 확인하고 다시 시도해주세요." | 일시적 끊김 | WiFi 확인 후 [다시 시도] |
|
||||
| "서버 응답이 올바르지 않습니다. 잠시 후 다시 시도해주세요." | 서버 측 문제 | 시간 두고 [다시 시도] |
|
||||
| "다운로드가 중단되었어요. 다시 시도하면 이어받습니다." | 스트림 중단 | [다시 시도] — 받은 데이터는 보존 |
|
||||
| "파일이 손상되었어요. 다시 시도하면 처음부터 받습니다." | 무결성 검증 실패 | [다시 시도] — 자동으로 처음부터 |
|
||||
|
||||
## AI 사용하기
|
||||
|
||||
1. **새 습관** 화면 진입.
|
||||
2. "제목" 에 자유 문장 입력 (예: "술 끊고 싶어").
|
||||
3. "프레임 문구" 입력란 아래 **✨ AI 제안** 버튼 탭.
|
||||
- AI 도움이 꺼져 있으면 버튼이 보이지 않습니다.
|
||||
- AI 도움은 켜졌지만 모델 다운로드가 아직 완료되지 않았다면 버튼은 비활성 상태로 보이고 "AI 도움을 먼저 켜주세요" 툴팁이 표시됩니다.
|
||||
4. 다이얼로그에 후보가 **최대 3개** 표시됩니다 (L2 조건부 긍정 2개 + L3 정체성 1개 권장).
|
||||
5. 마음에 드는 후보 카드 탭 → "프레임 문구" 입력란이 자동으로 채워지고 프레임 레벨이 자동 선택됩니다.
|
||||
6. 저장.
|
||||
|
||||
### 후보가 없거나 마음에 안 들 때
|
||||
|
||||
- "더 구체적으로 입력해주시면 더 좋은 제안을 드릴 수 있어요" 메시지가 보이면 제목을 더 명확히 작성한 뒤 [다시 시도].
|
||||
- 제안을 받지 못해도 **프레임 문구를 직접 입력하셔도 괜찮습니다**.
|
||||
|
||||
## AI 도움 끄기
|
||||
|
||||
1. **설정** → "AI 도움 켜기" 토글 OFF.
|
||||
2. 확인 다이얼로그:
|
||||
- 모델 파일이 단말에서 **즉시 삭제** 됩니다.
|
||||
- 약 1.5GB 의 저장공간이 확보됩니다.
|
||||
- 다시 켜면 다시 다운로드해야 합니다.
|
||||
3. **"끄고 삭제"** 탭 → "공간 확보됨 1500 MB" 토스트.
|
||||
|
||||
진행 중인 다운로드가 있어도 깔끔히 중단되고, `.tmp` 임시 파일까지 함께 삭제됩니다.
|
||||
|
||||
## 자주 묻는 질문
|
||||
|
||||
**Q. 입력 텍스트가 외부로 나가나요?**
|
||||
A. 아니요. 단말 내 추론만 사용합니다. 다운로드는 모델 파일을 받을 때 한 번만 발생합니다.
|
||||
|
||||
**Q. AI 가 만들어준 문장이 마음에 안 들면?**
|
||||
A. 직접 입력란을 고쳐 쓰면 됩니다. AI 제안은 채우기 도우미일 뿐, 저장 시점 검증(L0/L1 금지 등)은 변하지 않습니다.
|
||||
|
||||
**Q. 모델 파일이 너무 큽니다.**
|
||||
A. 언제든 끌 수 있고, 끄면 즉시 삭제됩니다. 다시 켜면 다시 받아야 한다는 점만 유의하세요.
|
||||
|
||||
**Q. v0.2.0 에서 다운로드가 항상 실패합니다.**
|
||||
A. v0.2.0 은 모델 URL 이 미확정 (OQ-1) 인 상태로 출시되어, 실제 다운로드는 의도된 graceful 실패 경로로 안내됩니다. 실 모델 통합은 후속 버전 (v0.3.x) 에서 제공됩니다. 그동안 수동 입력 경로는 정상 동작합니다.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- 설계서: [docs/design/215-gemma-frame-suggest/](../design/215-gemma-frame-suggest/)
|
||||
- 결정 기록: [docs/adr/0003-on-device-llm-gemma.md](../adr/0003-on-device-llm-gemma.md)
|
||||
- API 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../reference/215-ai-frame-suggest.md)
|
||||
- 변경 이력: [CHANGELOG.md](../../CHANGELOG.md)
|
||||
191
docs/reference/215-ai-frame-suggest.md
Normal file
191
docs/reference/215-ai-frame-suggest.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 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 후보.
|
||||
Reference in New Issue
Block a user