ADR-0005 in-process tool runtime — 6 tools (catalog 2 + tracker 2 + habit 2), ToolDispatcher with JSON-schema validation + modal ConfirmGate for destructive ops, multi-turn LlmChatSession abstraction wired to flutter_gemma 0.16.5 (ToolChoice.auto), ChatSessionController with MAX_TURNS=4 safety + 8-turn history hint, ChatScreen entry behind AI opt-in. R3/R7/R8 enforced inside handlers. 41 new tests (envelope, catalog/tracker/habit tools, dispatcher, controller loop) — 151 total passing. Refs #260
154 lines
5.2 KiB
Dart
154 lines
5.2 KiB
Dart
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<ToolResult> _logTrackerEntryHandler(
|
|
Map<String, dynamic> 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<ToolResult> _getStreakHandler(
|
|
Map<String, dynamic> 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;
|
|
}
|
|
}
|