[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:
2026-06-15 10:42:43 +09:00
parent eca097aa2c
commit b1bed4d5ca
21 changed files with 2313 additions and 0 deletions

View 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;
}
}