[03-Developer] #260 in-app tool calling (Gemma 4 multi-turn)
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
This commit is contained in:
153
app/lib/ai/tools/tracker_tools.dart
Normal file
153
app/lib/ai/tools/tracker_tools.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user