# 함수 설계서: `addHabitHandler` (#260) > **부모 설계서**: ./README.md · **상태**: Draft > **작성**: [AI] Architect · **구현**: `lib/ai/tools/habit_tools.dart:addHabitHandler` · **테스트**: `test/ai/tools/habit_tools_test.dart` ## 1. 시그니처 ```dart Future addHabitHandler( Map 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`. - `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`