[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:
171
app/lib/ai/tools/catalog_tools.dart
Normal file
171
app/lib/ai/tools/catalog_tools.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import '../../domain/catalog/catalog_item.dart';
|
||||
import '../../domain/catalog/display_category.dart';
|
||||
import 'tool_definition.dart';
|
||||
import 'tool_envelope.dart';
|
||||
|
||||
/// Read-only catalog tools. `search_catalog` returns trimmed list rows;
|
||||
/// `query_protocol` returns the full record. Splitting keeps `search` cheap
|
||||
/// in tokens (OQ-2) and the model fetches detail only when needed.
|
||||
|
||||
final ToolDefinition searchCatalogTool = ToolDefinition(
|
||||
name: 'search_catalog',
|
||||
description: '카테고리/키워드로 Huberman 프로토콜 카탈로그를 검색한다. '
|
||||
'결과는 id + 제목 + 60자 요약만. 상세는 query_protocol 로.',
|
||||
parametersSchema: const {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'category': {
|
||||
'type': 'string',
|
||||
'description': '카테고리 키 (lightCircadian, sleep, movement, nutrition, '
|
||||
'focusCognition, recoveryStress, emotionRelationship, breakHabit). '
|
||||
'생략하면 전체.',
|
||||
},
|
||||
'keyword': {
|
||||
'type': 'string',
|
||||
'description': '제목/요약에 포함될 키워드. 생략 가능.',
|
||||
},
|
||||
'limit': {
|
||||
'type': 'integer',
|
||||
'description': '최대 결과 개수 (1~10, 기본 10).',
|
||||
},
|
||||
},
|
||||
'required': [],
|
||||
},
|
||||
handler: _searchCatalogHandler,
|
||||
);
|
||||
|
||||
final ToolDefinition queryProtocolTool = ToolDefinition(
|
||||
name: 'query_protocol',
|
||||
description: '카탈로그 ID 로 프로토콜 상세를 조회한다. '
|
||||
'Protocol/Break/Diet 종류에 따라 다른 필드 셋을 반환.',
|
||||
parametersSchema: const {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'id': {
|
||||
'type': 'string',
|
||||
'description': '카탈로그 항목 ID (예: morning_sunlight).',
|
||||
},
|
||||
},
|
||||
'required': ['id'],
|
||||
},
|
||||
handler: _queryProtocolHandler,
|
||||
);
|
||||
|
||||
Future<ToolResult> _searchCatalogHandler(
|
||||
Map<String, dynamic> args, ToolDeps deps) async {
|
||||
final categoryRaw = args['category'];
|
||||
final keywordRaw = args['keyword'];
|
||||
final limitRaw = args['limit'];
|
||||
|
||||
DisplayCategory? category;
|
||||
if (categoryRaw is String && categoryRaw.isNotEmpty) {
|
||||
category = DisplayCategory.values
|
||||
.where((c) => c.name == categoryRaw)
|
||||
.firstOrNull;
|
||||
if (category == null) {
|
||||
return ToolErr('validation',
|
||||
'알 수 없는 카테고리: $categoryRaw. 허용값: ${DisplayCategory.values.map((c) => c.name).join(', ')}');
|
||||
}
|
||||
}
|
||||
|
||||
final keyword = (keywordRaw is String) ? keywordRaw.trim() : '';
|
||||
if (keyword.length > 50) {
|
||||
return const ToolErr('validation', 'keyword 는 50자 이하여야 합니다.');
|
||||
}
|
||||
|
||||
var limit = 10;
|
||||
if (limitRaw is int) {
|
||||
limit = limitRaw;
|
||||
} else if (limitRaw is num) {
|
||||
limit = limitRaw.toInt();
|
||||
}
|
||||
if (limit < 1 || limit > 10) {
|
||||
return const ToolErr('validation', 'limit 는 1~10 사이여야 합니다.');
|
||||
}
|
||||
|
||||
final all = await deps.catalog.all();
|
||||
Iterable<CatalogItem> filtered = all;
|
||||
if (category != null) {
|
||||
filtered = filtered.where((it) => it.displayCategory == category);
|
||||
}
|
||||
if (keyword.isNotEmpty) {
|
||||
final lk = keyword.toLowerCase();
|
||||
filtered = filtered.where((it) =>
|
||||
it.title.toLowerCase().contains(lk) ||
|
||||
it.summary.toLowerCase().contains(lk));
|
||||
}
|
||||
final results = filtered.take(limit).toList();
|
||||
|
||||
return ToolOk({
|
||||
'count': results.length,
|
||||
'items': results
|
||||
.map((it) => {
|
||||
'id': it.id,
|
||||
'title': it.title,
|
||||
'category': it.displayCategory.name,
|
||||
'summary': it.summary,
|
||||
})
|
||||
.toList(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<ToolResult> _queryProtocolHandler(
|
||||
Map<String, dynamic> args, ToolDeps deps) async {
|
||||
final id = args['id'];
|
||||
if (id is! String || id.isEmpty) {
|
||||
return const ToolErr('validation', 'id 는 비어있지 않은 문자열이어야 합니다.');
|
||||
}
|
||||
final item = await deps.catalog.byId(id);
|
||||
if (item == null) {
|
||||
return ToolErr('not_found', '카탈로그에서 \'$id\' 를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return ToolOk(_serializeItem(item));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _serializeItem(CatalogItem item) {
|
||||
final base = {
|
||||
'id': item.id,
|
||||
'title': item.title,
|
||||
if (item.titleEn != null) 'title_en': item.titleEn,
|
||||
'category': item.displayCategory.name,
|
||||
'summary': item.summary,
|
||||
if (item.evidenceStrength != null)
|
||||
'evidence_strength': item.evidenceStrength,
|
||||
'reference_ids': item.referenceIds,
|
||||
};
|
||||
switch (item) {
|
||||
case ProtocolCatalogItem p:
|
||||
return {
|
||||
...base,
|
||||
'kind': 'protocol',
|
||||
'what': p.what,
|
||||
'when': p.whenText,
|
||||
'dose': p.dose,
|
||||
'why': p.why,
|
||||
'how': p.how,
|
||||
'check': p.checkText,
|
||||
if (p.caution != null) 'caution': p.caution,
|
||||
if (p.minDoseForStart != null) 'min_dose_for_start': p.minDoseForStart,
|
||||
};
|
||||
case BreakCatalogItem b:
|
||||
return {
|
||||
...base,
|
||||
'kind': 'break',
|
||||
'break_category': b.breakCategory,
|
||||
'huberman_summary': b.hubermanSummary,
|
||||
'phases': b.phases,
|
||||
'default_common_frames': b.defaultCommonFrames,
|
||||
if (b.medicalWarning != null) 'medical_warning': b.medicalWarning,
|
||||
};
|
||||
case DietCatalogItem d:
|
||||
return {
|
||||
...base,
|
||||
'kind': 'diet',
|
||||
'name': d.name,
|
||||
'core': d.core,
|
||||
if (d.koreanContextFit != null)
|
||||
'korean_context_fit': d.koreanContextFit,
|
||||
};
|
||||
}
|
||||
}
|
||||
70
app/lib/ai/tools/confirm_gate.dart
Normal file
70
app/lib/ai/tools/confirm_gate.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'tool_definition.dart';
|
||||
|
||||
/// Modal Confirm gate for destructive tools (ADR-0005 §OQ-3).
|
||||
///
|
||||
/// Shown by [ToolDispatcher] right before invoking a destructive handler.
|
||||
/// Returns `true` only if the user explicitly tapped the confirm action;
|
||||
/// outside-tap / back-press / unmounted-context all return `false`.
|
||||
class ConfirmGate {
|
||||
const ConfirmGate();
|
||||
|
||||
Future<bool> show(
|
||||
BuildContext context,
|
||||
ToolDefinition tool,
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
if (!context.mounted) return false;
|
||||
final summary = tool.summarize?.call(args) ?? _fallbackSummary(args);
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (ctx) {
|
||||
final theme = Theme.of(ctx);
|
||||
return AlertDialog(
|
||||
title: const Text('이 작업을 수행할까요?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(tool.description, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(summary),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
FilledButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('수행'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
String _fallbackSummary(Map<String, dynamic> args) {
|
||||
try {
|
||||
return const JsonEncoder.withIndent(' ').convert(args);
|
||||
} catch (_) {
|
||||
return args.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
54
app/lib/ai/tools/tool_definition.dart
Normal file
54
app/lib/ai/tools/tool_definition.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import '../../data/catalog/catalog_repository.dart';
|
||||
import '../../data/db/daos/habit_dao.dart';
|
||||
import '../../data/db/daos/tracker_dao.dart';
|
||||
import '../../domain/models/frame_pattern.dart';
|
||||
import 'tool_envelope.dart';
|
||||
|
||||
/// Shared dependencies for every tool handler.
|
||||
///
|
||||
/// Frame patterns are passed in pre-loaded (memo'd at provider level) so the
|
||||
/// R7 avoidance check doesn't reparse seed JSON on every tool call.
|
||||
class ToolDeps {
|
||||
final HabitDao habitDao;
|
||||
final TrackerDao trackerDao;
|
||||
final CatalogRepository catalog;
|
||||
final List<FramePatternModel> framePatterns;
|
||||
final String userId;
|
||||
|
||||
const ToolDeps({
|
||||
required this.habitDao,
|
||||
required this.trackerDao,
|
||||
required this.catalog,
|
||||
required this.framePatterns,
|
||||
required this.userId,
|
||||
});
|
||||
}
|
||||
|
||||
typedef ToolHandler =
|
||||
Future<ToolResult> Function(Map<String, dynamic> args, ToolDeps deps);
|
||||
|
||||
/// Single tool the model can call.
|
||||
///
|
||||
/// `parametersSchema` follows the draft-07 JSON Schema shape that
|
||||
/// flutter_gemma 0.16.5's `Tool.parameters` expects — see ADR-0005 (Dart is
|
||||
/// the schema source-of-truth).
|
||||
class ToolDefinition {
|
||||
final String name;
|
||||
final String description;
|
||||
final Map<String, dynamic> parametersSchema;
|
||||
final bool isDestructive;
|
||||
final ToolHandler handler;
|
||||
|
||||
/// Optional summariser used by `ConfirmGate` to render destructive args in
|
||||
/// a sentence rather than raw JSON. Read-only tools leave this null.
|
||||
final String Function(Map<String, dynamic> args)? summarize;
|
||||
|
||||
const ToolDefinition({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.parametersSchema,
|
||||
required this.handler,
|
||||
this.isDestructive = false,
|
||||
this.summarize,
|
||||
});
|
||||
}
|
||||
104
app/lib/ai/tools/tool_dispatcher.dart
Normal file
104
app/lib/ai/tools/tool_dispatcher.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'confirm_gate.dart';
|
||||
import 'tool_definition.dart';
|
||||
import 'tool_envelope.dart';
|
||||
import 'tool_registry.dart';
|
||||
|
||||
/// Routes a single `FunctionCallResponse` from the LLM to the matching
|
||||
/// handler. See design `fn-tool_dispatcher.md`.
|
||||
///
|
||||
/// `dispatch` never throws — every failure path returns a `ToolResult`.
|
||||
class ToolDispatcher {
|
||||
final ToolRegistry registry;
|
||||
final ConfirmGate confirmGate;
|
||||
|
||||
ToolDispatcher({
|
||||
required this.registry,
|
||||
ConfirmGate? confirmGate,
|
||||
}) : confirmGate = confirmGate ?? const ConfirmGate();
|
||||
|
||||
Future<ToolResult> dispatch({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> rawArgs,
|
||||
required BuildContext? confirmContext,
|
||||
required ToolDeps deps,
|
||||
}) async {
|
||||
// 1. Lookup.
|
||||
final tool = registry.byName(toolName);
|
||||
if (tool == null) {
|
||||
return ToolErr('unknown_tool', '알 수 없는 도구: $toolName');
|
||||
}
|
||||
|
||||
// 2. Validate against schema.
|
||||
final validation = _validateArgs(tool.parametersSchema, rawArgs);
|
||||
if (validation != null) {
|
||||
return ToolErr('validation', '인자 오류: $validation');
|
||||
}
|
||||
|
||||
// 3. Destructive → Confirm gate.
|
||||
if (tool.isDestructive) {
|
||||
if (confirmContext == null) {
|
||||
return const ToolCancelled();
|
||||
}
|
||||
final ok = await confirmGate.show(confirmContext, tool, rawArgs);
|
||||
if (!ok) return const ToolCancelled();
|
||||
}
|
||||
|
||||
// 4. Run handler.
|
||||
try {
|
||||
return await tool.handler(rawArgs, deps);
|
||||
} catch (e) {
|
||||
return ToolErr('handler_error', '도구 실행 실패: ${e.runtimeType}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal JSON-schema-ish validator covering only what our tools use:
|
||||
/// - object root with `properties` + optional `required`
|
||||
/// - per-property `type` ∈ {string, integer, number, boolean, object, array}
|
||||
///
|
||||
/// Returns null on success, a short error message on failure. Extra keys are
|
||||
/// allowed (model hallucination tolerated; logged at call site if needed).
|
||||
String? _validateArgs(Map<String, dynamic> schema, Map<String, dynamic> args) {
|
||||
final required = schema['required'];
|
||||
if (required is List) {
|
||||
for (final field in required) {
|
||||
if (field is String && !args.containsKey(field)) {
|
||||
return '필수 필드 \'$field\' 가 없습니다.';
|
||||
}
|
||||
}
|
||||
}
|
||||
final props = schema['properties'];
|
||||
if (props is! Map) return null;
|
||||
for (final entry in args.entries) {
|
||||
final propSchema = props[entry.key];
|
||||
if (propSchema is! Map) continue; // unknown key — tolerate
|
||||
final expected = propSchema['type'];
|
||||
if (expected is! String) continue;
|
||||
final v = entry.value;
|
||||
if (!_matchesType(expected, v)) {
|
||||
return '\'${entry.key}\' 타입 불일치 (기대=$expected, 실제=${v.runtimeType})';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _matchesType(String expected, dynamic v) {
|
||||
switch (expected) {
|
||||
case 'string':
|
||||
return v is String;
|
||||
case 'integer':
|
||||
return v is int;
|
||||
case 'number':
|
||||
return v is num;
|
||||
case 'boolean':
|
||||
return v is bool;
|
||||
case 'object':
|
||||
return v is Map;
|
||||
case 'array':
|
||||
return v is List;
|
||||
default:
|
||||
return true; // unknown type — passthrough
|
||||
}
|
||||
}
|
||||
63
app/lib/ai/tools/tool_envelope.dart
Normal file
63
app/lib/ai/tools/tool_envelope.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Tool execution result. See design fn-tool_dispatcher.md §4.
|
||||
///
|
||||
/// Always JSON-serialisable so the model can consume it. `toJson()` returns a
|
||||
/// shape with a stable `status` discriminator — easier for the LLM to parse
|
||||
/// than relying on key presence.
|
||||
sealed class ToolResult {
|
||||
const ToolResult();
|
||||
Map<String, dynamic> toJson();
|
||||
}
|
||||
|
||||
final class ToolOk extends ToolResult {
|
||||
final Map<String, dynamic> data;
|
||||
const ToolOk(this.data);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'status': 'ok', 'data': data};
|
||||
}
|
||||
|
||||
final class ToolErr extends ToolResult {
|
||||
final String code;
|
||||
final String reason;
|
||||
const ToolErr(this.code, this.reason);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() =>
|
||||
{'status': 'error', 'code': code, 'reason': reason};
|
||||
}
|
||||
|
||||
final class ToolCancelled extends ToolResult {
|
||||
const ToolCancelled();
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() =>
|
||||
{'status': 'cancelled', 'reason': 'user did not confirm'};
|
||||
}
|
||||
|
||||
/// Encode a [ToolResult] to a JSON string ≤ [maxBytes].
|
||||
///
|
||||
/// Tool result token budget (ADR-0005 / OQ-2): keep model context bounded.
|
||||
/// If serialised payload exceeds [maxBytes], we replace the tail of the data
|
||||
/// field with a truncation hint instead of letting the LLM blow its window.
|
||||
String encodeToolResult(ToolResult result, {int maxBytes = 2048}) {
|
||||
final encoded = jsonEncode(result.toJson());
|
||||
if (encoded.length <= maxBytes) return encoded;
|
||||
|
||||
// Truncate strategy: only ToolOk has unbounded payload. Replace data with
|
||||
// a hint pointing at follow-up tools. Errors/cancellations are always small.
|
||||
if (result is ToolOk) {
|
||||
final hint = {
|
||||
'status': 'ok',
|
||||
'data': {
|
||||
'_truncated': true,
|
||||
'_hint': '결과가 ${encoded.length} 바이트로 잘렸습니다. '
|
||||
'구체 ID 가 필요하면 query_protocol 같은 단건 조회 도구를 사용하세요.',
|
||||
},
|
||||
};
|
||||
return jsonEncode(hint);
|
||||
}
|
||||
// Defensive: if some future ToolResult adds bulk, fall back to hard cut.
|
||||
return encoded.substring(0, maxBytes);
|
||||
}
|
||||
33
app/lib/ai/tools/tool_registry.dart
Normal file
33
app/lib/ai/tools/tool_registry.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'catalog_tools.dart';
|
||||
import 'habit_tools.dart';
|
||||
import 'tool_definition.dart';
|
||||
import 'tracker_tools.dart';
|
||||
|
||||
/// Static registry of all tools exposed to the LLM.
|
||||
///
|
||||
/// Order is the order surfaced to the model (`flutter_gemma` preserves the
|
||||
/// list). Read-only tools first, then destructive — mirrors a "look before
|
||||
/// you leap" prompt bias.
|
||||
final List<ToolDefinition> kAllTools = [
|
||||
// read-only
|
||||
searchCatalogTool,
|
||||
queryProtocolTool,
|
||||
listActiveHabitsTool,
|
||||
getStreakTool,
|
||||
// destructive (confirm gate)
|
||||
addHabitTool,
|
||||
logTrackerEntryTool,
|
||||
];
|
||||
|
||||
class ToolRegistry {
|
||||
final Map<String, ToolDefinition> _byName;
|
||||
|
||||
ToolRegistry(List<ToolDefinition> tools)
|
||||
: _byName = {for (final t in tools) t.name: t};
|
||||
|
||||
factory ToolRegistry.defaults() => ToolRegistry(kAllTools);
|
||||
|
||||
ToolDefinition? byName(String name) => _byName[name];
|
||||
|
||||
Iterable<ToolDefinition> get all => _byName.values;
|
||||
}
|
||||
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