[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
This commit is contained in:
141
docs/design/260-gemma-tool-calling/fn-add_habit_handler.md
Normal file
141
docs/design/260-gemma-tool-calling/fn-add_habit_handler.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 함수 설계서: `addHabitHandler` (#260)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `lib/ai/tools/habit_tools.dart:addHabitHandler` · **테스트**: `test/ai/tools/habit_tools_test.dart`
|
||||
|
||||
## 1. 시그니처
|
||||
```dart
|
||||
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`
|
||||
Reference in New Issue
Block a user