- design/260-gemma-tool-calling/README.md — overall (12 AC + 7 OQ + 모듈 구조) - fn-tool_dispatcher.md — multi-tool router (validate → confirm gate → handler → envelope) - fn-add_habit_handler.md — destructive 대표 (R3/R7/R8 enforce) - fn-confirm_gate.md — 모달 AlertDialog 흐름 (OQ-3 = 모달 확정) - fn-chat_session_controller.md — multi-turn loop 상태 머신 (MAX_TURNS=4) - ADR-0005 — in-app tool runtime + R 규칙 = 핸들러 책임 + schema SoT=Dart + 모달 OQ-1/2/4 = README §11 결정. OQ-3 = 모달 (사용자 결정). 신규 OQ-5/6/7 = Developer 가 구현 중 검증. Refs #260
6.8 KiB
6.8 KiB
함수 설계서: addHabitHandler (#260)
부모 설계서: ./README.md · 상태: Draft 작성: [AI] Architect · 구현:
lib/ai/tools/habit_tools.dart:addHabitHandler· 테스트:test/ai/tools/habit_tools_test.dart
1. 시그니처
Future<ToolResult> addHabitHandler(
Map<String, dynamic> args,
ToolDeps deps,
);
대표 destructive 핸들러. 다른 destructive (log_tracker_entry) 도 동일 패턴을 따름.
2. 책임 (단일 책임, 1줄)
LLM 이 제안한 새 습관 args 를 검증 → R1~R10 enforce → HabitDao.insertWithVariants 호출 → 결과 envelope 반환.
3. 입력
| 파라미터 | 타입 | 제약 | 설명 |
|---|---|---|---|
args.protocol_id |
String | non-empty, catalog 에 존재 | 사용자가 선택한 카탈로그 ID |
args.frame_level |
String | ∈ {"L2", "L3"} | R3: L0/L1 reject |
args.framed_text |
String | 1~200자 | 사용자가 보는 문장 |
args.anchor_when |
String? | ≤ 50자 | 선택 — 예: "기상 후" |
args.anchor_after_what |
String? | ≤ 50자 | 선택 — 예: "세수" |
args.dose_text |
String? | ≤ 100자 | 선택 — variant 단일 생성 (없으면 시드의 default_dose) |
deps |
ToolDeps | non-null | userId / HabitDao / CatalogRepository |
type 인자 없음 — catalog item 의 종류 (Protocol/Break) 에 따라 자동 결정. 핸들러가 catalog lookup 으로 판정.
4. 출력
- 반환:
Future<ToolResult>.ToolOk(data: {habit_id, title, type, frame_level})성공ToolErr(code: 'validation' | 'not_found' | 'r3_quota' | 'r7_avoidance' | 'r8_xor', reason: 한국어)
- 부수효과: 성공 시 DB write (Habits + HabitDoseVariants 트랜잭션). 실패 시 무변화.
5. 동작 / 알고리즘
1. validate args (schema 는 dispatch 가 이미 했지만, 의미 검증 추가):
- protocol_id = args['protocol_id'] (String)
- frameLevel = FrameLevel.fromString(args['frame_level'])
if frameLevel == null || frameLevel in {l0, l1}:
return ToolErr('validation', 'frame_level 은 L2 또는 L3 이어야 합니다.')
- framedText = args['framed_text']
if framedText.trim().isEmpty:
return ToolErr('validation', 'framed_text 가 비어있습니다.')
2. catalog lookup (1차 효과로 type 결정):
item = await deps.catalog.byId(protocol_id)
if item == null:
return ToolErr('not_found', '카탈로그에서 \'$protocol_id\' 를 찾을 수 없습니다.')
if item is ProtocolCatalogItem: habitType = build
else if item is BreakCatalogItem: habitType = breakHabit
else if item is DietCatalogItem: habitType = build // diet 는 build 로 매핑
else: return ToolErr('validation', '지원되지 않는 카탈로그 타입')
3. R7 avoidance keyword 검증:
patterns = await framePatterns() // ref 로 캐시된 것 활용 권장 (deps 에 주입)
hits = detectAvoidanceKeywords(framedText, patterns)
if hits.isNotEmpty:
return ToolErr('r7_avoidance',
'L${frameLevel} 프레임에 회피 키워드 "${hits.first.keyword}" 가 감지됐어요. '
'"${hits.first.suggestion}" 같은 표현으로 다시 시도해주세요.')
4. R3 active habit quota 검증:
count = await deps.habitDao.countActive(userId: deps.userId, type: habitType)
quota = judgeActiveHabitQuota(type: habitType, currentActiveCount: count)
if !quota.allowed:
return ToolErr('r3_quota', quota.reason)
5. HabitDraft 빌드 (R8 XOR enforce 는 HabitDao 내부 assertion):
draft = HabitDraft(
userId: deps.userId,
type: habitType,
title: item.title,
protocolId: habitType == HabitType.build ? protocol_id : null,
breakProtocolId: habitType == HabitType.breakHabit ? protocol_id : null,
frameLevel: frameLevel,
frameFramedText: framedText,
anchorWhen: args['anchor_when'],
anchorAfterWhat: args['anchor_after_what'],
startedAt: today_kst_yyyymmdd(),
variants: dose_text != null
? [VariantDraft(label: '기본', doseText: dose_text, isMinimum: false, sortOrder: 0)]
: [], // 빈 variants 면 HabitDao 가 시드 default 로 채우거나 그대로 빈 채로 저장
)
6. insert:
habitId = await deps.habitDao.insertWithVariants(draft)
// R8 위배 시 assertXorProtocol 가 throw → catch → return ToolErr('r8_xor', ...)
7. return ToolOk(data: {
'habit_id': habitId,
'title': item.title,
'type': habitType.name,
'frame_level': frameLevel.name,
})
6. 에러 & 실패 모드
| 조건 | 처리 | 반환 |
|---|---|---|
| frame_level L0/L1 | 사용자 안내 | ToolErr('validation', '...') |
| protocol_id 미존재 | 사용자 안내 | ToolErr('not_found', '...') |
| 회피 키워드 감지 | 사용자 안내 + 대안 제시 | ToolErr('r7_avoidance', '...') |
| R3 quota 초과 | 사용자 안내 | ToolErr('r3_quota', '...') |
| R8 XOR 위배 (이론상 불가, 안전망) | 로깅 + 안내 | ToolErr('r8_xor', '...') |
| HabitDao 예외 | dispatch 가 catch → ToolErr('handler_error', ...) |
불변식: 모든 error path 는 한국어 reason 을 포함. 모델이 이를 받아 사용자에게 안내.
7. 엣지케이스
- 공백 anchor: trim 후 empty → null 로 변환.
- catalog 이 DietCatalogItem 인데 frame_level 이 L3: 통과. (diet 도 정체성 프레임 가능)
- frame_level "l2" 소문자:
FrameLevel.fromString이 정규화. 미지원 시 validation error. - 중복 추가: 같은 protocol_id + framed_text 로 재호출 — 별도 dedup 없음. R3 quota 가 3 째에서 차단. (OQ-1)
- anchor_when 만 있고 anchor_after_what 없음: 모두 허용 (어느 하나만 있어도 됨).
8. 복잡도 / 성능
- DB query 2회 (catalog byId + countActive) + 1회 write 트랜잭션.
framePatterns매번 로드 시 N (≈ 30) 패턴 keyword 매칭 — O(text_len × pattern_count). text ≤ 200자, patterns ≤ 30 → 무시 가능.- 평균 < 100ms.
9. 테스트 케이스 (필수)
| 케이스 | 사전 | 입력 | 기대 |
|---|---|---|---|
| 정상 build | seed + 0 active | {protocol_id: 'morning_sunlight', frame_level: 'L2', framed_text: '아침에 햇빛 보기'} |
ToolOk, habits row +1 |
| 정상 break | seed + 0 active | {protocol_id: 'alcohol', ...} |
ToolOk, type=breakHabit |
| L0 reject | seed | frame_level: 'L0' |
ToolErr('validation') |
| 미존재 protocol | seed | protocol_id: 'no_such' |
ToolErr('not_found') |
| 회피 키워드 | seed | framed_text: '술 끊기' |
ToolErr('r7_avoidance') |
| R3 quota (build 3개) | seed + build 3개 | 새 build 추가 | ToolErr('r3_quota') |
10. 의존
CatalogRepository.byId(id)HabitDao.countActive(...),HabitDao.insertWithVariants(draft)judgeActiveHabitQuota(...)detectAvoidanceKeywords(text, patterns)framePatterns(ToolDeps 에 주입)FrameLevel,HabitType,HabitDraft,VariantDraft