[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:
210
app/lib/ai/tools/habit_tools.dart
Normal file
210
app/lib/ai/tools/habit_tools.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
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<ToolResult> _addHabitHandler(
|
||||
Map<String, dynamic> 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>[]
|
||||
: [
|
||||
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<ToolResult> _listActiveHabitsHandler(
|
||||
Map<String, dynamic> 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;
|
||||
}
|
||||
Reference in New Issue
Block a user