Files
life-helper/app/lib/ai/tools/catalog_tools.dart
joungmin b1bed4d5ca [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
2026-06-15 10:42:43 +09:00

172 lines
5.2 KiB
Dart

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