import '../../core/time.dart'; import '../../data/db/daos/tracker_dao.dart'; import '../../domain/models/tracker_entry.dart'; import '../../domain/streak/compute_streak.dart'; import 'tool_definition.dart'; import 'tool_envelope.dart'; final ToolDefinition logTrackerEntryTool = ToolDefinition( name: 'log_tracker_entry', description: '습관의 하루 체크인을 기록한다. value 는 done (완료) 또는 blank (의도적 공란).', parametersSchema: const { 'type': 'object', 'properties': { 'habit_id': {'type': 'string'}, 'value': { 'type': 'string', 'description': 'done 또는 blank.', }, 'date': { 'type': 'string', 'description': 'YYYY-MM-DD. 생략하면 오늘.', }, 'note': {'type': 'string'}, }, 'required': ['habit_id', 'value'], }, // R5: done 만 destructive (블랭크는 의도적 공란 — 확인 없이 통과). // 실 mutation 가시성을 위해 done 만 모달. isDestructive: true, summarize: (args) { final v = args['value']; final d = args['date'] ?? '오늘'; return '습관 ${args['habit_id']} 의 $d 기록을 ' '\'${v == 'done' ? '완료' : '공란'}\' 으로 저장합니다.'; }, handler: _logTrackerEntryHandler, ); final ToolDefinition getStreakTool = ToolDefinition( name: 'get_streak', description: '특정 habit_id 의 스트릭(연속일수) 와 5-tier 보상 등급을 계산해서 반환한다. ' '기록 없는 날은 패널티 아니지만 명시적 blank 는 패널티.', parametersSchema: const { 'type': 'object', 'properties': { 'habit_id': {'type': 'string'}, }, 'required': ['habit_id'], }, handler: _getStreakHandler, ); Future _logTrackerEntryHandler( Map args, ToolDeps deps) async { final habitId = args['habit_id']; final value = args['value']; if (habitId is! String || habitId.isEmpty) { return const ToolErr('validation', 'habit_id 는 비어있지 않은 문자열이어야 합니다.'); } if (value is! String || (value != 'done' && value != 'blank')) { return const ToolErr('validation', 'value 는 done 또는 blank 이어야 합니다.'); } final date = (args['date'] is String && (args['date'] as String).isNotEmpty) ? args['date'] as String : dateOnly(nowKst()); if (!_isValidDate(date)) { return const ToolErr('validation', 'date 는 YYYY-MM-DD 형식이어야 합니다.'); } // habit_id 가 실 사용자 소유인지 확인. final habits = await deps.habitDao.activeHabitsForUser(deps.userId); final owned = habits.any((h) => h.id == habitId); if (!owned) { return ToolErr('not_found', '활성 습관 \'$habitId\' 를 찾을 수 없습니다.'); } // 같은 (habit, date) 가 이미 있으면 덮어쓰기 대신 안내 — OQ-7 (no UNIQUE // constraint, 핸들러 레벨 dedup). 의도된 재기록은 사용자가 별도 액션. final existing = await deps.trackerDao.entriesForHabit(habitId); final same = existing.where((e) => e.date == date).toList(); if (same.isNotEmpty) { return ToolErr( 'duplicate', '$date 에 이미 \'${same.first.value}\' 로 기록되어 있습니다. ' '덮어쓰려면 기존 항목을 삭제 후 다시 시도해주세요.', ); } final id = await deps.trackerDao.recordCheckIn(TrackerEntryDraft( habitId: habitId, date: date, value: value, note: args['note'] is String ? args['note'] as String : null, )); return ToolOk({ 'entry_id': id, 'habit_id': habitId, 'date': date, 'value': value, }); } Future _getStreakHandler( Map args, ToolDeps deps) async { final habitId = args['habit_id']; if (habitId is! String || habitId.isEmpty) { return const ToolErr('validation', 'habit_id 는 비어있지 않은 문자열이어야 합니다.'); } final habits = await deps.habitDao.activeHabitsForUser(deps.userId); final habit = habits.where((h) => h.id == habitId).firstOrNull; if (habit == null) { return ToolErr('not_found', '활성 습관 \'$habitId\' 를 찾을 수 없습니다.'); } final rows = await deps.trackerDao.entriesForHabit(habitId); final entries = rows .map((r) => TrackerEntryModel( id: r.id, habitId: r.habitId, date: r.date, value: r.value == 'done' ? TrackerValue.done : TrackerValue.blank, )) .toList(); final state = computeStreak( entries: entries, asOf: nowKst(), habitStartedAt: habit.startedAt, ); return ToolOk({ 'habit_id': habitId, 'current_streak': state.currentStreak, 'longest_streak': state.longestStreak, 'done_count_30d': state.doneCountInWindow30, 'done_count_phase42': state.doneCountInPhase42, 'tier': state.currentTier.dbValue, 'never_miss_twice_broken': state.neverMissTwiceBroken, }); } bool _isValidDate(String s) { if (s.length != 10) return false; try { final parts = s.split('-'); if (parts.length != 3) return false; final y = int.parse(parts[0]); final m = int.parse(parts[1]); final d = int.parse(parts[2]); final dt = DateTime(y, m, d); return dt.year == y && dt.month == m && dt.day == d; } catch (_) { return false; } }