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 _searchCatalogHandler( Map 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 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 _queryProtocolHandler( Map 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 _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, }; } }