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

11 KiB

Reference: AI 프레임 제안 (#215, v0.2.0)

상태: 구현 후 동기화 · 추적성 — Redmine #215 · 설계서: docs/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)

abstract class LlmService {
  bool get isLoaded;
  Future<void> load();
  Future<void> unload();              // idempotent
  Future<Map<String, dynamic>> generateStructured(String prompt, Map schema);
}

계약:

  • loadisLoaded == true.
  • 미로드 상태에서 generateStructured 호출 → StateError.
  • 스키마/응답 깨짐 → FormatException.
  • timeout 은 호출자 책임 (suggestFrame 가 10s 적용).

구현 2개:

  • MockLlmServiceenqueueResponse(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 라 도달 불가).

StorageAdaptersupportDir() + 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> 반드시 overridemain.dartMockLlmService 주입
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 후보.