코드 결함 1건 + 문서 정확성 nit 3건. 사용자 동작에 영향 있는 건 (1)
번만, 나머지는 문서 정정.
(1) _LazyLlmService._delegate sticky cache 수정 (main.dart)
- 기존: 첫 호출 시점에 잡힌 delegate (Mock vs Gemma) 가 앱 재시작까지
유지 — 옵트인 OFF 상태에서 첫 suggestFrame 호출 → Mock 잡힘 → 사용자
옵트인 ON + 다운로드 완료 후에도 같은 Mock 만 반환 (사용자는 AI 가
켜진 줄 알고 mock 응답 받음).
- 수정: 매 _resolve() 호출마다 checkAvailability 재평가. 캐시는
(Gemma↔Mock 종류) + (Gemma 의 modelPath) 모두 일치할 때만 재사용 →
state 변화 시 자동 교체. flutter_gemma installModel 자체가
idempotent 라 반복 resolve 비용 무시 가능.
(2) reference doc nit 3건 — 04-QA round 2 가 08-Documenter 로 인계한
nit 를 Reviewer 가 직접 정정:
- L184: "device_info_plus 로 systemFeatures / totalMem 조회" → 실
구현은 MethodChannel `life_helper/device_caps`. device_info_plus
는 deps 에 있지만 4GB 임계 측정엔 미사용 (isLowRamDevice 는 ~1GB).
- L186: F1 후속 이슈 번호 "#222 등" → "#219 별도 이슈".
- L191: follow-up 매핑 — 임의 "#219 ProGuard rules 정제" 항목 제거.
Planner OOS 기준 #219=F1 unload, #220=F2 purge, #221=AC10 corpus,
#222=production keystore 로 정정.
검증: flutter analyze 무이슈, flutter test 88/88 통과.
Refs #218
12 KiB
Reference: AI 프레임 제안 (#215 + #218, v0.3.0-dev)
상태: 구현 후 동기화 · 추적성 — Redmine #215 / #218 · 설계서: docs/design/215-gemma-frame-suggest/, docs/design/218-gemma-real-integration/ · ADR-0003 · 태그
v0.2.0(placeholder) →v0.3.0(real Gemma 4)본 문서는 v0.3.0-dev 시점의 실제 코드 사양이다. 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 [].
흐름:
input.rawText.trim()길이 검사 (1~200자). 벗어나면 빈 리스트.buildFewShotPrompt(input, framePatterns)로 prompt 조립.llm.generateStructured(prompt, schema).timeout(10s)호출. 어떤 예외든 catch → 빈 리스트.parseFrameCandidates(json)으로 디코드.FormatExceptioncatch → 빈 리스트.- 각 후보에
validateFrameLevel적용.reject인 후보만 드랍. _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)
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(...). 완료 시modelAvailabilityProviderinvalidate.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 후보.