[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:
2026-06-15 10:15:44 +09:00
parent 321d3af53b
commit eca097aa2c
6 changed files with 844 additions and 0 deletions

View 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`