[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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user