Files
life-helper/docs/design/260-gemma-tool-calling/fn-add_habit_handler.md
joungmin eca097aa2c [02-Architect] #260 design spec + ADR-0005
- 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
2026-06-15 10:15:44 +09:00

6.8 KiB
Raw Blame History

함수 설계서: 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