import '../../core/time.dart'; import '../../data/db/daos/habit_dao.dart'; import '../../domain/catalog/catalog_item.dart'; import '../../domain/frame/validate_frame_level.dart'; import '../../domain/models/habit.dart'; import '../../domain/rules/active_habit_quota.dart'; import 'tool_definition.dart'; import 'tool_envelope.dart'; final ToolDefinition addHabitTool = ToolDefinition( name: 'add_habit', description: '카탈로그 항목 1개를 사용자의 활성 습관으로 추가한다. ' 'frame_level 은 L2 (조건부 긍정) 또는 L3 (정체성) 만 허용. ' 'L0/L1 (회피·부정 명령) 은 R3 위반으로 거부됨.', parametersSchema: const { 'type': 'object', 'properties': { 'protocol_id': { 'type': 'string', 'description': '카탈로그 항목 ID (search_catalog 결과의 id).', }, 'frame_level': { 'type': 'string', 'description': 'L2 또는 L3.', }, 'framed_text': { 'type': 'string', 'description': '사용자에게 보일 1줄 문구 (1~200자).', }, 'anchor_when': { 'type': 'string', 'description': '시점 트리거 (예: "기상 후"). 선택.', }, 'anchor_after_what': { 'type': 'string', 'description': '직전 행동 트리거 (예: "세수"). 선택.', }, 'dose_text': { 'type': 'string', 'description': '용량/강도 문구 (예: "5분"). 선택.', }, }, 'required': ['protocol_id', 'frame_level', 'framed_text'], }, isDestructive: true, summarize: (args) { final text = args['framed_text'] ?? args['protocol_id']; final lv = args['frame_level'] ?? '?'; return '\'$text\' ($lv 프레임) 를 활성 습관으로 추가합니다.'; }, handler: _addHabitHandler, ); final ToolDefinition listActiveHabitsTool = ToolDefinition( name: 'list_active_habits', description: '현재 활성 상태인 습관 목록을 반환한다. ' 'R3 quota 점검 또는 사용자 현황 안내 전 호출.', parametersSchema: const { 'type': 'object', 'properties': {}, 'required': [], }, handler: _listActiveHabitsHandler, ); Future _addHabitHandler( Map args, ToolDeps deps) async { // 1. 의미 검증. final protocolId = args['protocol_id']; if (protocolId is! String || protocolId.isEmpty) { return const ToolErr('validation', 'protocol_id 는 비어있지 않은 문자열이어야 합니다.'); } final frameLevelRaw = args['frame_level']; if (frameLevelRaw is! String) { return const ToolErr('validation', 'frame_level 은 L2 또는 L3 이어야 합니다.'); } final frameLevel = FrameLevelX.fromDb(frameLevelRaw.toUpperCase()); if (frameLevel == null || frameLevel == FrameLevel.l0 || frameLevel == FrameLevel.l1) { return const ToolErr( 'validation', 'frame_level 은 L2 (조건부 긍정) 또는 L3 (정체성) 이어야 합니다. ' 'L0/L1 은 코끼리 회피 문제로 거부됩니다.', ); } final framedTextRaw = args['framed_text']; if (framedTextRaw is! String) { return const ToolErr('validation', 'framed_text 가 누락됐습니다.'); } final framedText = framedTextRaw.trim(); if (framedText.isEmpty) { return const ToolErr('validation', 'framed_text 가 비어있습니다.'); } if (framedText.length > 200) { return const ToolErr('validation', 'framed_text 는 200자 이하여야 합니다.'); } // 2. 카탈로그 lookup → habitType 결정. final item = await deps.catalog.byId(protocolId); if (item == null) { return ToolErr('not_found', '카탈로그에서 \'$protocolId\' 를 찾을 수 없습니다.'); } final HabitType habitType; switch (item) { case ProtocolCatalogItem _: habitType = HabitType.build; case BreakCatalogItem _: habitType = HabitType.breakHabit; case DietCatalogItem _: habitType = HabitType.build; } // 3. R7 회피 키워드. final hits = detectAvoidanceKeywords(framedText, deps.framePatterns); if (hits.isNotEmpty) { final first = hits.first; return ToolErr( 'r7_avoidance', '\'${first.keyword}\' 같은 회피 표현이 감지됐어요. ' '\'${first.source.l2Suggestion}\' 같은 긍정 표현으로 다시 시도해주세요.', ); } // 4. R3 quota. final count = await deps.habitDao .countActive(userId: deps.userId, type: habitType); final quota = judgeActiveHabitQuota( type: habitType, currentActiveCount: count, ); if (!quota.allowed) { return ToolErr('r3_quota', quota.reason); } // 5. Draft 빌드. final anchorWhen = _trimmedOrNull(args['anchor_when']); final anchorAfterWhat = _trimmedOrNull(args['anchor_after_what']); final doseText = _trimmedOrNull(args['dose_text']); final variants = doseText == null ? const [] : [ VariantDraft( label: '기본', doseText: doseText, isMinimum: false, sortOrder: 0, ), ]; final draft = HabitDraft( userId: deps.userId, type: habitType, title: item.title, protocolId: habitType == HabitType.build ? protocolId : null, breakProtocolId: habitType == HabitType.breakHabit ? protocolId : null, frameLevel: frameLevel, frameFramedText: framedText, anchorWhen: anchorWhen, anchorAfterWhat: anchorAfterWhat, startedAt: dateOnly(nowKst()), variants: variants, ); // 6. Insert (R8 XOR assert 는 dao 내부). try { final habitId = await deps.habitDao.insertWithVariants(draft); return ToolOk({ 'habit_id': habitId, 'title': item.title, 'type': habitType.dbValue, 'frame_level': frameLevel.dbValue, }); } on AssertionError catch (e) { return ToolErr('r8_xor', 'R8 XOR 위반: ${e.message}'); } } Future _listActiveHabitsHandler( Map args, ToolDeps deps) async { final habits = await deps.habitDao.activeHabitsForUser(deps.userId); final items = habits .map((h) => { 'id': h.id, 'title': h.title, 'type': h.type, 'frame_level': h.frameLevel, 'framed_text': h.frameFramedText, 'started_at': h.startedAt, if (h.protocolId != null) 'protocol_id': h.protocolId, if (h.breakProtocolId != null) 'break_protocol_id': h.breakProtocolId, }) .toList(); final buildCount = habits.where((h) => h.type == 'build').length; final breakCount = habits.where((h) => h.type == 'break').length; return ToolOk({ 'count': habits.length, 'build_count': buildCount, 'break_count': breakCount, 'build_quota_remaining': kMaxActiveBuild - buildCount, 'break_quota_remaining': kMaxActiveBreak - breakCount, 'items': items, }); } String? _trimmedOrNull(dynamic v) { if (v is! String) return null; final t = v.trim(); return t.isEmpty ? null : t; }