- 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
13 KiB
함수 설계서: suggestFrame + buildFewShotPrompt + parseFrameCandidates (#215)
부모 설계서: ./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 만 의존) 로 테스트 가능성을 우선한다.
§A. suggestFrame (메인)
1. 시그니처
Future<List<FrameCandidate>> suggestFrame(
SuggestFrameInput input, {
required LlmService llm,
required List<FramePattern> framePatterns,
FrameValidator validator = const FrameValidator(),
Duration timeout = const Duration(seconds: 10),
});
2. 책임 (1줄)
raw text 를 Gemma 4 에 보내 L2/L3 프레임 후보 ≤ 3 개를 받아 반환한다. L0/L1 응답은 자동 폐기.
3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|---|---|---|---|
input.rawText |
String | 1 ≤ length ≤ 200, NFC normalize | 사용자 자유 입력 |
input.habitType |
enum {build, break} | 필수 | few-shot 매칭 방향 결정 |
input.anchorHint |
String? | nullable | optional "아침 양치 후" 등 |
llm |
LlmService |
추상 인터페이스 (DI) | flutter_gemma 구현체 또는 mock |
framePatterns |
List<FramePattern> |
#204 시드 30개 | few-shot 동적 추출 소스 |
validator |
FrameValidator |
기본값 OK | validateFrameLevel 의 래퍼 |
timeout |
Duration | 1~30s | LlmService.generateStructured 의 타임아웃 |
4. 출력
- 반환:
List<FrameCandidate>— 길이 0~3. - 부수효과: 없음 (순수). LlmService 호출은 인자로 받은 의존성을 통해서만.
- graceful: 실패 시 throw 하지 않고 빈 리스트 반환. 호출자 (UI provider) 가 메시지 결정.
5. 동작 / 알고리즘
1. input 경계 검증
- rawText.trim().length in [1, 200] 아니면 → return [] (호출자에 위임)
- NFC normalize 이미 안 되어 있으면 적용
2. prompt = buildFewShotPrompt(input, framePatterns)
- §B 참조
3. JSON schema = FrameCandidate function calling schema (README §6)
4. try:
json = await llm.generateStructured(prompt, schema).timeout(timeout)
catch TimeoutException, StateError, FormatException, Exception:
log meta (latency, error type) — NO prompt body
return []
5. candidates = parseFrameCandidates(json) — §C 참조
6. validated = candidates.where((c) {
final result = validator.validate(FrameInput(
level: c.level,
framedText: c.framedText,
originalText: input.rawText,
));
return result.status != FrameStatus.error; // L0/L1 또는 hard avoid → 폐기
}).toList()
7. return validated.take(3).toList()
6. 에러 & 실패 모드
| 조건 | 처리 | 반환 |
|---|---|---|
rawText 빈/200자 초과 |
즉시 반환 | [] |
| LlmService timeout | catch → log latency, error_type=timeout |
[] |
| LlmService throw StateError (모델 미로드) | catch → log | [] |
| 응답이 malformed JSON | parseFrameCandidates 가 FormatException → catch |
[] |
| 모든 후보가 L0/L1 또는 hard avoid | 정상 흐름. validated 빈 리스트 | [] |
| validator 자체 throw | 비정상. catch → log + skip 후보 | 부분 리스트 |
모든 예외를 catch — 도메인 함수는 throw 하지 않음 (graceful). 호출자가 빈 리스트 시 UI 메시지 결정.
7. 엣지케이스
rawText = " "(whitespace) → trim 후 length=0 →[].rawText가 코드 / 이모지 / 영어만 → prompt 에 그대로 들어가 모델이 한국어 응답 시도. 결과 품질 낮을 가능성 — AC-10 평가 대상.framePatterns = [](시드 미로드) →buildFewShotPrompt가 fallback 시스템 prompt 만 사용 — quality 저하 경고.habitType = break+ raw text 가 build 패턴에 가까움 → few-shot 매칭이 약함. 모델이 break 방향으로 frame 시도.- LlmService 가 같은 호출에 다른 응답 — 정상. cache 없음.
8. 복잡도 / 성능
- 호출 빈도: 사용자가 "AI 제안" 탭한 시점만. throttle 5회/세션.
- 시간: cold start 1–3초 (모델 로드 포함), warm 0.5–2초. 본 함수 자체 (LlmService 호출 제외) 는 O(N) — N = framePatterns 길이 = 30. 사실상 < 5ms.
- 공간: prompt string ≈ 2–4KB. JSON response ≈ 1KB.
9. 의존성
- 호출 함수:
buildFewShotPrompt(§B),parseFrameCandidates(§C),validator.validate(=validateFrameLevel래퍼, #204). - 외부 API:
LlmService.generateStructured(인터페이스, 구현체 =GemmaLlmService). - 모델:
FramePattern(#204 카탈로그),FrameCandidate(도메인),SuggestFrameInput(도메인).
10. 테스트 케이스
- 정상: rawText="술 끊고 싶어", habitType=break, mock LlmService 가 valid JSON 3개 반환 →
result.length == 3, 모두 L2/L3. - L0/L1 폐기: mock 응답에 L1 1개 + L2 2개 →
result.length == 2. - timeout: mock LlmService 가 Future.delayed(15s) → timeout 10s →
[]. - malformed JSON: mock 응답
{"foo": "bar"}→parseFrameCandidatesthrow → catch →[]. - 빈 rawText:
rawText: " "→ LlmService 미호출,[]. - rawText > 200자: 201자 입력 →
[]. - framePatterns 비어있음: → LlmService 호출은 하되 prompt 가 fallback. mock 으로 응답 시 정상 동작 보장.
- LlmService throw StateError (모델 미로드): catch →
[]. - non-blocking 보장: 어떤 예외 케이스에서도 throw 하지 않음 (assert no exception thrown).
11. 추적성
- 인수조건: #215 AC-6, AC-7, AC-9 (graceful).
- 관련 ADR: ADR-0003 (on-device LLM + function calling + few-shot 동적 추출).
§B. buildFewShotPrompt
1. 시그니처
String buildFewShotPrompt(
SuggestFrameInput input,
List<FramePattern> framePatterns, {
int maxFewShot = 5,
});
2. 책임 (1줄)
FramePattern 카탈로그에서 raw text + habit type 키워드 매칭 상위 N개를 추출해 system + few-shot + user 섹션으로 구성된 prompt string 을 반환한다.
3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|---|---|---|---|
input |
SuggestFrameInput |
검증된 입력 | rawText, habitType, anchorHint |
framePatterns |
List<FramePattern> |
시드 30 가정 | matching pool |
maxFewShot |
int | 1~10 | top-N few-shot 갯수 |
4. 출력
- 반환: prompt string (≈ 2–4KB).
- 부수효과: 없음 (순수). 입력 인자만으로 결과 결정.
5. 동작 / 알고리즘
1. tokens = rawText 의 단어 토큰화 (whitespace + 한국어 형태소 lite)
- 형태소 분석기 비도입. 정규식 split + 길이 ≥ 2 한국어 substring 만 남김
2. scored = framePatterns
.where((p) => p.habitType == input.habitType || p.habitType == null)
.map((p) => MapEntry(p, scoreMatch(tokens, p.keywords)))
.where((e) => e.value > 0)
.toList()
..sort((a, b) => b.value - a.value)
3. selected = scored.take(maxFewShot).toList()
- scored 빈 리스트면 framePatterns 중 임의 3개 fallback (habit_type 만 일치)
4. prompt 조립:
<SYSTEM>
당신은 Huberman 프로토콜 한국어 코치입니다. 사용자의 raw text 를
L2 (조건부 긍정) 또는 L3 (정체성) 프레임의 한국어 문장으로 변환합니다.
- L2 예: "스트레스 받을 때 책 한 페이지를 펼친다"
- L3 예: "나는 글을 읽는 사람이다"
- L0/L1 (회피/부정) 금지: "안", "끊다", "그만두다"
- 응답은 반드시 함수 호출 emit_frame_candidates(candidates: [...]) 로.
<FEW_SHOT>
for p in selected:
# 예시 {n}: {p.title}
L0: {p.level_l0_example}
L2: {p.level_l2_example}
L3: {p.level_l3_example}
<USER>
habit_type: {input.habitType}
raw_text: "{input.rawText}"
anchor_hint: {input.anchorHint ?? "없음"}
위 raw_text 를 L2/L3 후보 3개로 변환하세요.
5. return prompt
scoreMatch(tokens, keywords)= 두 리스트 교집합 크기 + 한국어 substring 부분 매칭 보정. 정확한 점수 공식은 구현 시 단순한 set intersection 으로 시작 — 평가 후 보강.
6. 에러 & 실패 모드
| 조건 | 처리 | 반환 |
|---|---|---|
| framePatterns 비어있음 | fallback: system + user 만 (few-shot 섹션 생략) | prompt 단축본 |
| rawText 비어있음 | 호출자 (suggestFrame) 가 사전 검증. 본 함수는 어떻게든 prompt 반환 |
empty user_input prompt |
| keyword 매칭 0개 | 임의 3개 fallback (habitType 일치 기준) | 정상 prompt |
7. 엣지케이스
- 한국어 형태소 분석기 없음 → keyword 매칭 false negative 다수. v1 baseline. v1.1 에서
mecab-ko도입 검토. - 같은 raw text 가 두 번 들어와도 결정론적 → cache 없음, 매번 같은 prompt 생성.
anchorHint길이 폭주 (사용자가 100자 입력) → prompt 비대화. UI 단에서 ≤ 50자 제한.
8. 복잡도 / 성능
- O(N × M) — N = framePatterns 길이 (30), M = 평균 keyword 갯수 (≈ 3). 사실상 < 5ms.
- prompt string concat — O(L), L = 총 길이 (≈ 4KB).
9. 의존성
SuggestFrameInput,FramePattern도메인 모델만.- Dart core (String, List).
- 외부 의존 0 — 순수 함수.
10. 테스트 케이스
- 정상 매칭: rawText="술 끊고", patterns 에 술 관련 3개 + 운동 5개 → selected 의 첫 3개가 술 관련.
- fallback: rawText="xyz unknown", 매칭 0 → habit_type=break 인 임의 3개로 fallback.
- 빈 patterns: framePatterns=[] → few-shot 섹션 없는 prompt + L2/L3 가이드만.
- anchorHint null: prompt 에 "anchor_hint: 없음" 명시.
- maxFewShot=1: selected.length = 1.
- 결정론: 같은 입력 두 번 → 같은 출력 string.
- NFC: rawText 가 NFD form 으로 들어오면 caller 가 normalize 책임 (본 함수는 가정).
11. 추적성
- 인수조건: #215 AC-6 (few-shot 동적 추출), AC-10 (한국어 품질).
- 관련 ADR: ADR-0003 (SoT few-shot 동적 추출 원칙).
§C. parseFrameCandidates
1. 시그니처
List<FrameCandidate> parseFrameCandidates(Map<String, dynamic> json);
2. 책임 (1줄)
function calling JSON 응답을 FrameCandidate[] 으로 변환한다. 형식 위반 시 FormatException.
3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|---|---|---|---|
json |
Map<String, dynamic> |
function calling 응답 | {"candidates": [...]} 구조 가정 |
4. 출력
- 반환:
List<FrameCandidate>— 0~3 길이. - 부수효과: 없음 (순수).
5. 동작 / 알고리즘
1. raw = json['candidates']
- null 또는 not List → throw FormatException("candidates missing")
2. result = []
3. for each item in raw:
- levelStr = item['level'] as String? ?? throw FormatException
- level = FrameLevel.parse(levelStr) — L0/L1/L2/L3 enum
- 알 수 없는 값 → skip (log)
- framedText = item['framed_text'] as String? ?? throw FormatException
- trim, length in [1, 120] 아니면 skip
- confidence = (item['confidence'] as num?)?.toDouble() ?? 0.5
- clamp(0.0, 1.0)
- sourcePatternId = item['source_pattern_id'] as String? // optional
- result.add(FrameCandidate(level, framedText, confidence, sourcePatternId))
4. return result
L0/L1 폐기는 본 함수가 아닌 호출자
suggestFrame에서validateFrameLevel로 수행. parseFrameCandidates 는 형식 검증만.
6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|---|---|---|
| json 에 candidates 키 없음 | throw | FormatException("candidates missing") |
| candidates not List | throw | FormatException("candidates not array") |
| item 에 level 누락 | throw | FormatException(...) |
| level 값이 enum 외 ("L99") | item skip + log | 부분 리스트 |
| framed_text 길이 위반 | item skip + log | 부분 리스트 |
| confidence not number | 0.5 fallback | 정상 진행 |
7. 엣지케이스
candidates: []→ 빈 리스트 반환 (예외 아님).- 4개 이상 후보 반환 → 모두 파싱.
suggestFrame에서 take(3). - 동일한 framed_text 가 2개 → 중복 그대로 반환. dedup 은 호출자 선택.
- Unicode 이모지 포함 → 허용 (length 카운트는 grapheme 가 아닌 UTF-16 길이).
8. 복잡도 / 성능
- O(N) — N = candidates 길이 (보통 3).
- 사실상 < 1ms.
9. 의존성
FrameCandidate,FrameLevel도메인 모델.- Dart core (Map, List).
10. 테스트 케이스
- 정상: 3 valid items → length 3.
- candidates 누락:
{"foo": "bar"}→ throw FormatException. - candidates not list:
{"candidates": "string"}→ throw. - L0 + L2 + L3 mix: 모두 파싱 (L0 폐기는 호출자 책임).
- 알 수 없는 level "L99": skip → length 2 (3 중 2).
- framed_text 길이 120 초과: skip.
- confidence 누락: 0.5 fallback.
- confidence -0.1: clamp 0.0.
- 빈 candidates list:
[]→ 빈 리스트 반환 (예외 X). - 이모지 포함: 정상 파싱.
11. 추적성
- 인수조건: #215 AC-7 (function calling JSON 파싱 + L0/L1 폐기 결합).
- 관련 ADR: ADR-0003 (function calling 강제).