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
172 lines
5.2 KiB
Dart
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,
|
|
};
|
|
}
|
|
}
|