From b1bed4d5ca243cc422f65e22ade2cc0b4265f6e3 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 10:42:43 +0900 Subject: [PATCH] [03-Developer] #260 in-app tool calling (Gemma 4 multi-turn) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/lib/ai/tools/catalog_tools.dart | 171 ++++++++++++ app/lib/ai/tools/confirm_gate.dart | 70 +++++ app/lib/ai/tools/habit_tools.dart | 210 +++++++++++++++ app/lib/ai/tools/tool_definition.dart | 54 ++++ app/lib/ai/tools/tool_dispatcher.dart | 104 ++++++++ app/lib/ai/tools/tool_envelope.dart | 63 +++++ app/lib/ai/tools/tool_registry.dart | 33 +++ app/lib/ai/tools/tracker_tools.dart | 153 +++++++++++ app/lib/data/ai/gemma_llm_service.dart | 88 ++++++ app/lib/data/ai/llm_service.dart | 109 ++++++++ app/lib/main.dart | 7 + app/lib/state/chat_providers.dart | 251 ++++++++++++++++++ app/lib/ui/screens/chat_screen.dart | 243 +++++++++++++++++ app/lib/ui/screens/habit_list_screen.dart | 13 + app/test/ai/tools/_tool_test_helpers.dart | 48 ++++ app/test/ai/tools/catalog_tools_test.dart | 82 ++++++ app/test/ai/tools/habit_tools_test.dart | 130 +++++++++ app/test/ai/tools/tool_dispatcher_test.dart | 114 ++++++++ app/test/ai/tools/tool_envelope_test.dart | 66 +++++ app/test/ai/tools/tracker_tools_test.dart | 116 ++++++++ .../state/chat_session_controller_test.dart | 188 +++++++++++++ 21 files changed, 2313 insertions(+) create mode 100644 app/lib/ai/tools/catalog_tools.dart create mode 100644 app/lib/ai/tools/confirm_gate.dart create mode 100644 app/lib/ai/tools/habit_tools.dart create mode 100644 app/lib/ai/tools/tool_definition.dart create mode 100644 app/lib/ai/tools/tool_dispatcher.dart create mode 100644 app/lib/ai/tools/tool_envelope.dart create mode 100644 app/lib/ai/tools/tool_registry.dart create mode 100644 app/lib/ai/tools/tracker_tools.dart create mode 100644 app/lib/state/chat_providers.dart create mode 100644 app/lib/ui/screens/chat_screen.dart create mode 100644 app/test/ai/tools/_tool_test_helpers.dart create mode 100644 app/test/ai/tools/catalog_tools_test.dart create mode 100644 app/test/ai/tools/habit_tools_test.dart create mode 100644 app/test/ai/tools/tool_dispatcher_test.dart create mode 100644 app/test/ai/tools/tool_envelope_test.dart create mode 100644 app/test/ai/tools/tracker_tools_test.dart create mode 100644 app/test/state/chat_session_controller_test.dart diff --git a/app/lib/ai/tools/catalog_tools.dart b/app/lib/ai/tools/catalog_tools.dart new file mode 100644 index 0000000..5f17bad --- /dev/null +++ b/app/lib/ai/tools/catalog_tools.dart @@ -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 _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, + }; + } +} diff --git a/app/lib/ai/tools/confirm_gate.dart b/app/lib/ai/tools/confirm_gate.dart new file mode 100644 index 0000000..fb55e6c --- /dev/null +++ b/app/lib/ai/tools/confirm_gate.dart @@ -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 show( + BuildContext context, + ToolDefinition tool, + Map args, + ) async { + if (!context.mounted) return false; + final summary = tool.summarize?.call(args) ?? _fallbackSummary(args); + + final result = await showDialog( + 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 args) { + try { + return const JsonEncoder.withIndent(' ').convert(args); + } catch (_) { + return args.toString(); + } + } +} diff --git a/app/lib/ai/tools/habit_tools.dart b/app/lib/ai/tools/habit_tools.dart new file mode 100644 index 0000000..0069c8c --- /dev/null +++ b/app/lib/ai/tools/habit_tools.dart @@ -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 _addHabitHandler( + Map 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( + 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 _listActiveHabitsHandler( + Map 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; +} diff --git a/app/lib/ai/tools/tool_definition.dart b/app/lib/ai/tools/tool_definition.dart new file mode 100644 index 0000000..9f72f94 --- /dev/null +++ b/app/lib/ai/tools/tool_definition.dart @@ -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 framePatterns; + final String userId; + + const ToolDeps({ + required this.habitDao, + required this.trackerDao, + required this.catalog, + required this.framePatterns, + required this.userId, + }); +} + +typedef ToolHandler = + Future Function(Map 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 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 args)? summarize; + + const ToolDefinition({ + required this.name, + required this.description, + required this.parametersSchema, + required this.handler, + this.isDestructive = false, + this.summarize, + }); +} diff --git a/app/lib/ai/tools/tool_dispatcher.dart b/app/lib/ai/tools/tool_dispatcher.dart new file mode 100644 index 0000000..0d8e7f1 --- /dev/null +++ b/app/lib/ai/tools/tool_dispatcher.dart @@ -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 dispatch({ + required String toolName, + required Map 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 schema, Map 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 + } +} diff --git a/app/lib/ai/tools/tool_envelope.dart b/app/lib/ai/tools/tool_envelope.dart new file mode 100644 index 0000000..dc86710 --- /dev/null +++ b/app/lib/ai/tools/tool_envelope.dart @@ -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 toJson(); +} + +final class ToolOk extends ToolResult { + final Map data; + const ToolOk(this.data); + + @override + Map toJson() => {'status': 'ok', 'data': data}; +} + +final class ToolErr extends ToolResult { + final String code; + final String reason; + const ToolErr(this.code, this.reason); + + @override + Map toJson() => + {'status': 'error', 'code': code, 'reason': reason}; +} + +final class ToolCancelled extends ToolResult { + const ToolCancelled(); + + @override + Map 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); +} diff --git a/app/lib/ai/tools/tool_registry.dart b/app/lib/ai/tools/tool_registry.dart new file mode 100644 index 0000000..e92ff11 --- /dev/null +++ b/app/lib/ai/tools/tool_registry.dart @@ -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 kAllTools = [ + // read-only + searchCatalogTool, + queryProtocolTool, + listActiveHabitsTool, + getStreakTool, + // destructive (confirm gate) + addHabitTool, + logTrackerEntryTool, +]; + +class ToolRegistry { + final Map _byName; + + ToolRegistry(List tools) + : _byName = {for (final t in tools) t.name: t}; + + factory ToolRegistry.defaults() => ToolRegistry(kAllTools); + + ToolDefinition? byName(String name) => _byName[name]; + + Iterable get all => _byName.values; +} diff --git a/app/lib/ai/tools/tracker_tools.dart b/app/lib/ai/tools/tracker_tools.dart new file mode 100644 index 0000000..70d8617 --- /dev/null +++ b/app/lib/ai/tools/tracker_tools.dart @@ -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 _logTrackerEntryHandler( + Map 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 _getStreakHandler( + Map 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; + } +} diff --git a/app/lib/data/ai/gemma_llm_service.dart b/app/lib/data/ai/gemma_llm_service.dart index d01a8c0..48322dd 100644 --- a/app/lib/data/ai/gemma_llm_service.dart +++ b/app/lib/data/ai/gemma_llm_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_gemma/flutter_gemma.dart'; +import '../../ai/tools/tool_definition.dart' as tools; import 'llm_service.dart'; /// HuggingFace access token injected at build time via @@ -114,6 +115,93 @@ class GemmaLlmService implements LlmService { } } } + + @override + Future startChat({ + required List tools, + }) async { + if (!_loaded || _model == null) { + throw StateError('LlmService not loaded'); + } + final gemmaTools = tools + .map((t) => Tool( + name: t.name, + description: t.description, + parameters: Map.from(t.parametersSchema), + )) + .toList(); + final chat = await _model!.createChat( + modelType: ModelType.gemma4, + supportsFunctionCalls: true, + // ToolChoice.auto = 모델이 자율 결정 (multi-tool + reply-only 모두 지원). + toolChoice: ToolChoice.auto, + tools: gemmaTools, + ); + return _GemmaChatSession(chat); + } +} + +class _GemmaChatSession implements LlmChatSession { + final dynamic _chat; + bool _closed = false; + + _GemmaChatSession(this._chat); + + @override + Stream sendUser(String text) { + if (_closed) { + throw StateError('LlmChatSession is closed'); + } + return _run(Message.text(text: text, isUser: true)); + } + + @override + Stream sendToolResult({ + required String toolName, + required Map result, + }) { + if (_closed) { + throw StateError('LlmChatSession is closed'); + } + return _run(Message.toolResponse(toolName: toolName, response: result)); + } + + Stream _run(Message msg) async* { + await _chat.addQueryChunk(msg); + final Stream stream = _chat.generateChatResponseAsync(); + await for (final event in stream) { + if (event is TextResponse) { + yield LlmTextChunk(event.token); + } else if (event is FunctionCallResponse) { + yield LlmFunctionCall( + event.name, + Map.from(event.args), + ); + return; // model hands control back to caller for tool exec + } else if (event is ParallelFunctionCallResponse && + event.calls.isNotEmpty) { + // ADR-0005: parallel calls collapsed to first — sequential dispatch. + final first = event.calls.first; + yield LlmFunctionCall( + first.name, + Map.from(first.args), + ); + return; + } + // ThinkingResponse / other: skip. + } + } + + @override + Future close() async { + if (_closed) return; + _closed = true; + try { + await _chat.close(); + } catch (_) { + // Best-effort cleanup. + } + } } /// Extracts the first `FunctionCallResponse(name == expectedName)` from diff --git a/app/lib/data/ai/llm_service.dart b/app/lib/data/ai/llm_service.dart index 9e03947..313927b 100644 --- a/app/lib/data/ai/llm_service.dart +++ b/app/lib/data/ai/llm_service.dart @@ -1,3 +1,5 @@ +import '../../ai/tools/tool_definition.dart'; + /// Abstract LLM backend. /// /// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests). @@ -7,6 +9,7 @@ /// - [generateStructured] returns a parsed JSON map matching the schema. /// On schema/parse failure throws [FormatException]. /// - [unload] is idempotent. +/// - [startChat] opens a multi-turn chat session for tool calling (#260). abstract class LlmService { bool get isLoaded; @@ -20,6 +23,45 @@ abstract class LlmService { String prompt, Map schema, ); + + /// Opens a chat session that supports multi-turn user input + tool result + /// submission with the supplied [tools]. See ADR-0005. + Future startChat({ + required List tools, + }); +} + +/// Streaming chat session for the tool-calling loop. +/// +/// Lifecycle: created by [LlmService.startChat], lives for a single chat +/// screen, must be [close]d when the user dismisses the screen. Each +/// `send*` call returns a stream of [LlmChatEvent]s until the model yields +/// control (text done or a function call requested). +abstract class LlmChatSession { + Stream sendUser(String text); + + Stream sendToolResult({ + required String toolName, + required Map result, + }); + + Future close(); +} + +/// Events emitted by [LlmChatSession]. See ADR-0005 §C. +sealed class LlmChatEvent { + const LlmChatEvent(); +} + +final class LlmTextChunk extends LlmChatEvent { + final String text; + const LlmTextChunk(this.text); +} + +final class LlmFunctionCall extends LlmChatEvent { + final String name; + final Map args; + const LlmFunctionCall(this.name, this.args); } /// Programmable stub for tests. Use [enqueueResponse] / [enqueueError]. @@ -31,6 +73,12 @@ class MockLlmService implements LlmService { Map? lastSchema; Duration responseDelay = Duration.zero; + /// Queues consumed by [startChat] in order. Each entry is the event list + /// returned for a single `send*` call. + final List> chatScript = []; + int chatStartCount = 0; + MockLlmChatSession? lastChat; + @override bool get isLoaded => _loaded; @@ -52,6 +100,12 @@ class MockLlmService implements LlmService { _queue.add(_Response.error(error)); } + /// Enqueue one batch of events that will be emitted on the next + /// `sendUser` or `sendToolResult` call. Items are streamed in order. + void enqueueChatEvents(List events) { + chatScript.add(events); + } + @override Future> generateStructured( String prompt, @@ -73,6 +127,61 @@ class MockLlmService implements LlmService { if (r.error != null) throw r.error!; return r.value!; } + + @override + Future startChat({ + required List tools, + }) async { + if (!_loaded) { + throw StateError('LlmService not loaded'); + } + chatStartCount += 1; + final session = MockLlmChatSession(chatScript); + lastChat = session; + return session; + } +} + +/// Mock chat session that replays pre-queued events from [MockLlmService]. +class MockLlmChatSession implements LlmChatSession { + final List> _script; + int sendCount = 0; + final List userInputs = []; + final List<(String, Map)> toolResults = []; + bool closed = false; + + MockLlmChatSession(this._script); + + Stream _emitNext() async* { + sendCount += 1; + if (_script.isEmpty) { + throw StateError('MockLlmChatSession: no queued events'); + } + final batch = _script.removeAt(0); + for (final ev in batch) { + yield ev; + } + } + + @override + Stream sendUser(String text) { + userInputs.add(text); + return _emitNext(); + } + + @override + Stream sendToolResult({ + required String toolName, + required Map result, + }) { + toolResults.add((toolName, result)); + return _emitNext(); + } + + @override + Future close() async { + closed = true; + } } class _Response { diff --git a/app/lib/main.dart b/app/lib/main.dart index e3c38c2..f86c127 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'ai/tools/tool_definition.dart' as tools; import 'data/ai/gemma_llm_service.dart'; import 'data/ai/llm_service.dart'; import 'data/ai/model_lifecycle.dart'; @@ -77,6 +78,12 @@ class _LazyLlmService implements LlmService { Map schema, ) async => (await _resolve()).generateStructured(prompt, schema); + + @override + Future startChat({ + required List tools, + }) async => + (await _resolve()).startChat(tools: tools); } class LifeHelperApp extends StatelessWidget { diff --git a/app/lib/state/chat_providers.dart b/app/lib/state/chat_providers.dart new file mode 100644 index 0000000..dc947a9 --- /dev/null +++ b/app/lib/state/chat_providers.dart @@ -0,0 +1,251 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../ai/tools/tool_definition.dart'; +import '../ai/tools/tool_dispatcher.dart'; +import '../ai/tools/tool_envelope.dart'; +import '../ai/tools/tool_registry.dart'; +import '../core/constants.dart'; +import '../data/ai/llm_service.dart'; +import 'ai_providers.dart'; +import 'catalog_providers.dart'; +import 'providers.dart'; + +/// Multi-turn safety cap. ADR-0005 §C — guards against tool-call loops. +const int kChatMaxTurns = 4; + +/// Soft warning threshold for chat history bloat (OQ-2). +const int kChatSoftHistoryLimit = 8; + +sealed class ChatMessage { + const ChatMessage(); +} + +final class UserChatMessage extends ChatMessage { + final String text; + const UserChatMessage(this.text); +} + +final class ModelChatMessage extends ChatMessage { + final String text; + const ModelChatMessage(this.text); +} + +final class ToolCallChatMessage extends ChatMessage { + final String name; + final Map args; + final ToolResult result; + const ToolCallChatMessage(this.name, this.args, this.result); +} + +final class SystemChatMessage extends ChatMessage { + final String text; + const SystemChatMessage(this.text); +} + +class ChatSessionState { + final List messages; + final bool isStreaming; + final String? streamingText; + final String? error; + + const ChatSessionState({ + this.messages = const [], + this.isStreaming = false, + this.streamingText, + this.error, + }); + + ChatSessionState copyWith({ + List? messages, + bool? isStreaming, + String? streamingText, + String? error, + bool clearStreamingText = false, + bool clearError = false, + }) { + return ChatSessionState( + messages: messages ?? this.messages, + isStreaming: isStreaming ?? this.isStreaming, + streamingText: clearStreamingText + ? null + : (streamingText ?? this.streamingText), + error: clearError ? null : (error ?? this.error), + ); + } +} + +final toolRegistryProvider = Provider((ref) { + return ToolRegistry.defaults(); +}); + +final toolDepsProvider = FutureProvider((ref) async { + // bootstrap 가 끝나야 seed 가 채워진 framePatterns 를 신뢰할 수 있음. + await ref.watch(bootstrapProvider.future); + final framePatterns = await ref.watch(framePatternsProvider.future); + return ToolDeps( + habitDao: ref.watch(habitDaoProvider), + trackerDao: ref.watch(trackerDaoProvider), + catalog: ref.watch(catalogRepositoryProvider), + framePatterns: framePatterns, + userId: kLocalDefaultUserId, + ); +}); + +final toolDispatcherProvider = Provider((ref) { + return ToolDispatcher(registry: ref.watch(toolRegistryProvider)); +}); + +class ChatSessionController extends StateNotifier { + ChatSessionController({ + required this.llm, + required this.dispatcher, + required this.deps, + required this.tools, + }) : super(const ChatSessionState()); + + final LlmService llm; + final ToolDispatcher dispatcher; + final ToolDeps deps; + final List tools; + + LlmChatSession? _session; + + Future userTurn(String text, BuildContext context) async { + final trimmed = text.trim(); + if (trimmed.isEmpty) return; + if (state.isStreaming) return; + + state = state.copyWith( + messages: [...state.messages, UserChatMessage(trimmed)], + isStreaming: true, + streamingText: '', + clearError: true, + ); + + try { + // 1회 lazy load. + if (!llm.isLoaded) { + await llm.load(); + } + _session ??= await llm.startChat(tools: tools); + + String? pendingToolName; + Map? pendingToolResult; + Stream Function() nextStream = () => + _session!.sendUser(trimmed); + + for (var turn = 0; turn < kChatMaxTurns; turn++) { + var accumulated = ''; + LlmFunctionCall? toolCall; + + await for (final event in nextStream()) { + if (event is LlmTextChunk) { + accumulated += event.text; + if (!mounted) return; + state = state.copyWith(streamingText: accumulated); + } else if (event is LlmFunctionCall) { + toolCall = event; + break; + } + } + + if (toolCall == null) { + // 자연어 응답으로 종료. + if (!mounted) return; + state = state.copyWith( + messages: [ + ...state.messages, + ModelChatMessage(accumulated), + ], + isStreaming: false, + clearStreamingText: true, + ); + _maybeWarnHistory(); + return; + } + + // Tool 처리. + if (!mounted) return; + final result = await dispatcher.dispatch( + toolName: toolCall.name, + rawArgs: toolCall.args, + confirmContext: context.mounted ? context : null, + deps: deps, + ); + if (!mounted) return; + state = state.copyWith( + messages: [ + ...state.messages, + ToolCallChatMessage(toolCall.name, toolCall.args, result), + ], + streamingText: '', + ); + + pendingToolName = toolCall.name; + pendingToolResult = result.toJson(); + final capturedName = pendingToolName; + final capturedResult = pendingToolResult; + nextStream = () => _session!.sendToolResult( + toolName: capturedName, + result: capturedResult, + ); + } + + // MAX_TURNS 초과 안전 종료. + if (!mounted) return; + state = state.copyWith( + isStreaming: false, + clearStreamingText: true, + error: '도구 호출 루프가 너무 길어 중단했습니다.', + ); + } catch (e) { + if (!mounted) return; + state = state.copyWith( + isStreaming: false, + clearStreamingText: true, + error: 'LLM 응답 실패: ${e.runtimeType}', + ); + } + } + + void clear() { + state = const ChatSessionState(); + } + + void _maybeWarnHistory() { + final turnCount = state.messages + .whereType() + .length; + if (turnCount == kChatSoftHistoryLimit) { + state = state.copyWith( + messages: [ + ...state.messages, + const SystemChatMessage( + '대화가 길어졌어요. 다시 시작하면 모델이 더 빠르게 답할 수 있어요. (오른쪽 위 ↻ 버튼)', + ), + ], + ); + } + } + + @override + void dispose() { + _session?.close(); + super.dispose(); + } +} + +final chatSessionControllerProvider = StateNotifierProvider.autoDispose< + ChatSessionController, ChatSessionState>((ref) { + final llm = ref.watch(llmServiceProvider); + final dispatcher = ref.watch(toolDispatcherProvider); + final deps = ref.watch(toolDepsProvider).requireValue; + final tools = ref.watch(toolRegistryProvider).all.toList(growable: false); + return ChatSessionController( + llm: llm, + dispatcher: dispatcher, + deps: deps, + tools: tools, + ); +}); diff --git a/app/lib/ui/screens/chat_screen.dart b/app/lib/ui/screens/chat_screen.dart new file mode 100644 index 0000000..636e2dd --- /dev/null +++ b/app/lib/ui/screens/chat_screen.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../ai/tools/tool_envelope.dart'; +import '../../state/chat_providers.dart'; + +/// AI chat surface (#260). Multi-turn tool calling powered by Gemma 4 + +/// in-process tool runtime. ConfirmGate modals appear on destructive +/// tool calls (add_habit, log_tracker_entry). +class ChatScreen extends ConsumerStatefulWidget { + const ChatScreen({super.key}); + + @override + ConsumerState createState() => _ChatScreenState(); +} + +class _ChatScreenState extends ConsumerState { + final _textCtrl = TextEditingController(); + final _scrollCtrl = ScrollController(); + + @override + void dispose() { + _textCtrl.dispose(); + _scrollCtrl.dispose(); + super.dispose(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scrollCtrl.hasClients) return; + _scrollCtrl.animateTo( + _scrollCtrl.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + }); + } + + Future _send() async { + final text = _textCtrl.text.trim(); + if (text.isEmpty) return; + _textCtrl.clear(); + await ref + .read(chatSessionControllerProvider.notifier) + .userTurn(text, context); + _scrollToBottom(); + } + + @override + Widget build(BuildContext context) { + final depsAsync = ref.watch(toolDepsProvider); + return Scaffold( + appBar: AppBar( + title: const Text('AI 코치'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: '새 대화', + onPressed: () { + ref.read(chatSessionControllerProvider.notifier).clear(); + }, + ), + ], + ), + body: depsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('초기화 실패: $e')), + data: (_) => _buildBody(context), + ), + ); + } + + Widget _buildBody(BuildContext context) { + final state = ref.watch(chatSessionControllerProvider); + _scrollToBottom(); + return Column( + children: [ + if (state.error != null) + Container( + width: double.infinity, + color: Theme.of(context).colorScheme.errorContainer, + padding: const EdgeInsets.all(12), + child: Text( + state.error!, + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ), + Expanded( + child: ListView.builder( + controller: _scrollCtrl, + padding: const EdgeInsets.all(12), + itemCount: state.messages.length + + (state.streamingText != null && + state.streamingText!.isNotEmpty + ? 1 + : 0), + itemBuilder: (context, i) { + if (i < state.messages.length) { + return _MessageBubble(message: state.messages[i]); + } + return _MessageBubble( + message: ModelChatMessage(state.streamingText ?? ''), + streaming: true, + ); + }, + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _textCtrl, + enabled: !state.isStreaming, + decoration: const InputDecoration( + hintText: '습관 추가, 기록, 카탈로그 질문…', + border: OutlineInputBorder(), + isDense: true, + ), + maxLines: 4, + minLines: 1, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _send(), + ), + ), + const SizedBox(width: 8), + state.isStreaming + ? const Padding( + padding: EdgeInsets.all(8), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton.filled( + onPressed: _send, + icon: const Icon(Icons.send), + ), + ], + ), + ), + ], + ); + } +} + +class _MessageBubble extends StatelessWidget { + final ChatMessage message; + final bool streaming; + const _MessageBubble({required this.message, this.streaming = false}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + switch (message) { + case UserChatMessage m: + return _bubble( + context, + align: Alignment.centerRight, + color: theme.colorScheme.primaryContainer, + textColor: theme.colorScheme.onPrimaryContainer, + text: m.text, + ); + case ModelChatMessage m: + return _bubble( + context, + align: Alignment.centerLeft, + color: theme.colorScheme.surfaceContainerHighest, + textColor: theme.colorScheme.onSurface, + text: m.text + (streaming ? '▍' : ''), + ); + case ToolCallChatMessage m: + return Align( + alignment: Alignment.center, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Text( + '🛠 ${m.name} → ${_toolResultLabel(m.result)}', + style: theme.textTheme.bodySmall, + ), + ), + ); + case SystemChatMessage m: + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + m.text, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), + ), + ); + } + } + + Widget _bubble( + BuildContext context, { + required Alignment align, + required Color color, + required Color textColor, + required String text, + }) { + return Align( + alignment: align, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + ), + child: Text(text, style: TextStyle(color: textColor)), + ), + ); + } + + String _toolResultLabel(ToolResult r) { + switch (r) { + case ToolOk _: + return 'OK'; + case ToolErr e: + return '오류: ${e.code}'; + case ToolCancelled _: + return '취소됨'; + } + } +} diff --git a/app/lib/ui/screens/habit_list_screen.dart b/app/lib/ui/screens/habit_list_screen.dart index 278c3b1..8d39496 100644 --- a/app/lib/ui/screens/habit_list_screen.dart +++ b/app/lib/ui/screens/habit_list_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../state/ai_providers.dart'; import '../../state/providers.dart'; +import 'chat_screen.dart'; import 'check_in_screen.dart'; import 'habit_create_screen.dart'; import 'protocol_gallery_screen.dart'; @@ -15,11 +17,22 @@ class HabitListScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final boot = ref.watch(bootstrapProvider); final habitsAsync = ref.watch(activeHabitsProvider); + final aiOptIn = ref.watch(aiSettingsProvider).valueOrNull ?? false; return Scaffold( appBar: AppBar( title: const Text('습관'), actions: [ + if (aiOptIn) + IconButton( + icon: const Icon(Icons.smart_toy_outlined), + tooltip: 'AI 코치', + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const ChatScreen(), + )); + }, + ), IconButton( icon: const Icon(Icons.search), tooltip: '카탈로그 탐색', diff --git a/app/test/ai/tools/_tool_test_helpers.dart b/app/test/ai/tools/_tool_test_helpers.dart new file mode 100644 index 0000000..e9dbc2c --- /dev/null +++ b/app/test/ai/tools/_tool_test_helpers.dart @@ -0,0 +1,48 @@ +import 'package:drift/drift.dart' as drift; +import 'package:life_helper/ai/tools/tool_definition.dart'; +import 'package:life_helper/core/constants.dart'; +import 'package:life_helper/core/time.dart'; +import 'package:life_helper/data/catalog/catalog_repository.dart'; +import 'package:life_helper/data/db/app_database.dart'; +import 'package:life_helper/data/db/daos/habit_dao.dart'; +import 'package:life_helper/data/db/daos/tracker_dao.dart'; +import 'package:life_helper/data/seed/seed_importer.dart'; +import 'package:life_helper/domain/models/frame_pattern.dart'; + +import '../../data/seed/test_seeds.dart'; + +/// Tool tests share a tiny in-memory bootstrap. Returns the assembled +/// [ToolDeps] plus the underlying [AppDatabase] so callers can close it +/// in tearDown. +Future<({AppDatabase db, ToolDeps deps})> bootstrapToolDeps() async { + final db = AppDatabase.memory(); + // default user (seed importer doesn't insert users — bootstrap does). + await db.into(db.users).insert(UsersCompanion.insert( + id: kLocalDefaultUserId, + displayName: const drift.Value('Test'), + createdAt: nowKst().toIso8601String(), + )); + await SeedImporter(db, loadAsset: testStubLoader).importIfNeeded(); + final patterns = await db.select(db.framePatterns).get(); + final framePatterns = patterns + .map((r) => FramePatternModel( + id: r.id, + domain: r.domain, + avoidanceKeyword: r.avoidanceKeyword, + l0Example: r.l0Example, + l1SimpleReplace: r.l1SimpleReplace, + l2Suggestion: r.l2Suggestion, + l3Identity: r.l3Identity, + )) + .toList(); + return ( + db: db, + deps: ToolDeps( + habitDao: HabitDao(db), + trackerDao: TrackerDao(db), + catalog: CatalogRepository(db), + framePatterns: framePatterns, + userId: kLocalDefaultUserId, + ), + ); +} diff --git a/app/test/ai/tools/catalog_tools_test.dart b/app/test/ai/tools/catalog_tools_test.dart new file mode 100644 index 0000000..b21923e --- /dev/null +++ b/app/test/ai/tools/catalog_tools_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:life_helper/ai/tools/catalog_tools.dart'; +import 'package:life_helper/ai/tools/tool_envelope.dart'; + +import '_tool_test_helpers.dart'; + +void main() { + group('search_catalog', () { + test('전체 검색 (인자 없음)', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await searchCatalogTool.handler({}, ctx.deps); + expect(r, isA()); + final data = (r as ToolOk).data; + expect(data['count'], greaterThan(0)); + // stub 3 items: protocol + break + diet + expect(data['count'], 3); + final items = data['items'] as List; + expect(items.first.containsKey('id'), true); + expect(items.first.containsKey('summary'), true); + }); + + test('카테고리 필터', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await searchCatalogTool + .handler({'category': 'breakHabit'}, ctx.deps); + expect(r, isA()); + final items = ((r as ToolOk).data['items'] as List); + expect(items.length, 1); + expect((items.first as Map)['category'], 'breakHabit'); + }); + + test('잘못된 카테고리 → validation 에러', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await searchCatalogTool + .handler({'category': 'no_such'}, ctx.deps); + expect(r, isA()); + expect((r as ToolErr).code, 'validation'); + }); + + test('limit 범위 검증', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await searchCatalogTool.handler({'limit': 99}, ctx.deps); + expect(r, isA()); + expect((r as ToolErr).code, 'validation'); + }); + }); + + group('query_protocol', () { + test('정상 조회 → kind 분기', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await queryProtocolTool + .handler({'id': 'morning_sunlight'}, ctx.deps); + expect(r, isA()); + final data = (r as ToolOk).data; + expect(data['kind'], 'protocol'); + expect(data['what'], isNotNull); + }); + + test('break 항목 → kind=break', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = + await queryProtocolTool.handler({'id': 'alcohol'}, ctx.deps); + expect(r, isA()); + expect((r as ToolOk).data['kind'], 'break'); + }); + + test('미존재 id → not_found', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = + await queryProtocolTool.handler({'id': 'no_such'}, ctx.deps); + expect(r, isA()); + expect((r as ToolErr).code, 'not_found'); + }); + }); +} diff --git a/app/test/ai/tools/habit_tools_test.dart b/app/test/ai/tools/habit_tools_test.dart new file mode 100644 index 0000000..f243b13 --- /dev/null +++ b/app/test/ai/tools/habit_tools_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:life_helper/ai/tools/habit_tools.dart'; +import 'package:life_helper/ai/tools/tool_envelope.dart'; +import 'package:life_helper/core/constants.dart'; +import 'package:life_helper/core/time.dart'; +import 'package:life_helper/data/db/daos/habit_dao.dart'; +import 'package:life_helper/domain/models/habit.dart'; + +import '_tool_test_helpers.dart'; + +void main() { + group('add_habit', () { + test('정상 build → ToolOk', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await addHabitTool.handler({ + 'protocol_id': 'morning_sunlight', + 'frame_level': 'L2', + 'framed_text': '아침에 햇빛 보기', + }, ctx.deps); + expect(r, isA(), reason: '$r'); + final data = (r as ToolOk).data; + expect(data['type'], 'build'); + expect(data['frame_level'], 'L2'); + expect(data['habit_id'], isNotEmpty); + }); + + test('정상 break → type=break', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await addHabitTool.handler({ + 'protocol_id': 'alcohol', + 'frame_level': 'L3', + 'framed_text': '맑은 정신을 즐긴다', + }, ctx.deps); + expect(r, isA(), reason: '$r'); + expect((r as ToolOk).data['type'], 'break'); + }); + + test('L0 프레임 → validation 거부', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await addHabitTool.handler({ + 'protocol_id': 'morning_sunlight', + 'frame_level': 'L0', + 'framed_text': '게으름 피우지 마', + }, ctx.deps); + expect(r, isA()); + expect((r as ToolErr).code, 'validation'); + }); + + test('미존재 protocol_id → not_found', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await addHabitTool.handler({ + 'protocol_id': 'no_such', + 'frame_level': 'L2', + 'framed_text': '뭐든', + }, ctx.deps); + expect(r, isA()); + expect((r as ToolErr).code, 'not_found'); + }); + + test('R7 회피 키워드 → r7_avoidance', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + // 시드 framePatterns 에 "술 끊기" avoidance keyword 존재. + final r = await addHabitTool.handler({ + 'protocol_id': 'alcohol', + 'frame_level': 'L2', + 'framed_text': '술 끊기 해야지', + }, ctx.deps); + expect(r, isA()); + expect((r as ToolErr).code, 'r7_avoidance'); + }); + + test('R3 quota (build 3개) 초과', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + // 3개 사전 삽입. + for (var i = 0; i < 3; i++) { + await ctx.deps.habitDao.insertWithVariants(HabitDraft( + userId: kLocalDefaultUserId, + type: HabitType.build, + title: 'pre_$i', + protocolId: 'morning_sunlight', + frameLevel: FrameLevel.l2, + frameFramedText: 'pre$i', + startedAt: dateOnly(nowKst()), + )); + } + final r = await addHabitTool.handler({ + 'protocol_id': 'morning_sunlight', + 'frame_level': 'L2', + 'framed_text': '4번째 시도', + }, ctx.deps); + expect(r, isA()); + expect((r as ToolErr).code, 'r3_quota'); + }); + }); + + group('list_active_habits', () { + test('0개일 때', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await listActiveHabitsTool.handler({}, ctx.deps); + expect(r, isA()); + final data = (r as ToolOk).data; + expect(data['count'], 0); + expect(data['build_quota_remaining'], 3); + expect(data['break_quota_remaining'], 1); + }); + + test('add_habit 후 1개', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + await addHabitTool.handler({ + 'protocol_id': 'morning_sunlight', + 'frame_level': 'L2', + 'framed_text': '햇빛', + }, ctx.deps); + final r = await listActiveHabitsTool.handler({}, ctx.deps); + expect(r, isA()); + final data = (r as ToolOk).data; + expect(data['count'], 1); + expect(data['build_count'], 1); + expect(data['build_quota_remaining'], 2); + }); + }); +} diff --git a/app/test/ai/tools/tool_dispatcher_test.dart b/app/test/ai/tools/tool_dispatcher_test.dart new file mode 100644 index 0000000..2301877 --- /dev/null +++ b/app/test/ai/tools/tool_dispatcher_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:life_helper/ai/tools/tool_definition.dart'; +import 'package:life_helper/ai/tools/tool_dispatcher.dart'; +import 'package:life_helper/ai/tools/tool_envelope.dart'; +import 'package:life_helper/ai/tools/tool_registry.dart'; + +import '_tool_test_helpers.dart'; + +void main() { + group('ToolDispatcher', () { + test('unknown tool 이름', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final dispatcher = + ToolDispatcher(registry: ToolRegistry.defaults()); + final r = await dispatcher.dispatch( + toolName: 'no_such', + rawArgs: const {}, + confirmContext: null, + deps: ctx.deps, + ); + expect(r, isA()); + expect((r as ToolErr).code, 'unknown_tool'); + }); + + test('validation: required 없음', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final dispatcher = + ToolDispatcher(registry: ToolRegistry.defaults()); + final r = await dispatcher.dispatch( + toolName: 'query_protocol', + rawArgs: const {}, + confirmContext: null, + deps: ctx.deps, + ); + expect(r, isA()); + expect((r as ToolErr).code, 'validation'); + }); + + test('validation: 타입 불일치', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final dispatcher = + ToolDispatcher(registry: ToolRegistry.defaults()); + final r = await dispatcher.dispatch( + toolName: 'query_protocol', + rawArgs: const {'id': 123}, + confirmContext: null, + deps: ctx.deps, + ); + expect(r, isA()); + expect((r as ToolErr).code, 'validation'); + }); + + test('destructive + null confirmContext → ToolCancelled', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final dispatcher = + ToolDispatcher(registry: ToolRegistry.defaults()); + final r = await dispatcher.dispatch( + toolName: 'add_habit', + rawArgs: const { + 'protocol_id': 'morning_sunlight', + 'frame_level': 'L2', + 'framed_text': '햇빛', + }, + confirmContext: null, + deps: ctx.deps, + ); + expect(r, isA()); + }); + + test('read-only normal 경로', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final dispatcher = + ToolDispatcher(registry: ToolRegistry.defaults()); + final r = await dispatcher.dispatch( + toolName: 'search_catalog', + rawArgs: const {}, + confirmContext: null, + deps: ctx.deps, + ); + expect(r, isA()); + }); + + test('handler 예외 → ToolErr(handler_error)', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final throwing = ToolDefinition( + name: 'always_throws', + description: 'test', + parametersSchema: const { + 'type': 'object', + 'properties': {}, + 'required': [], + }, + handler: (_, _) async => throw StateError('boom'), + ); + final dispatcher = ToolDispatcher( + registry: ToolRegistry([throwing]), + ); + final r = await dispatcher.dispatch( + toolName: 'always_throws', + rawArgs: const {}, + confirmContext: null, + deps: ctx.deps, + ); + expect(r, isA()); + expect((r as ToolErr).code, 'handler_error'); + }); + }); +} diff --git a/app/test/ai/tools/tool_envelope_test.dart b/app/test/ai/tools/tool_envelope_test.dart new file mode 100644 index 0000000..2d3ef7c --- /dev/null +++ b/app/test/ai/tools/tool_envelope_test.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:life_helper/ai/tools/tool_envelope.dart'; + +void main() { + group('ToolResult', () { + test('ToolOk JSON 형태', () { + const r = ToolOk({'a': 1}); + expect(r.toJson(), { + 'status': 'ok', + 'data': {'a': 1} + }); + }); + + test('ToolErr JSON 형태', () { + const r = ToolErr('validation', '잘못된 인자'); + expect(r.toJson(), { + 'status': 'error', + 'code': 'validation', + 'reason': '잘못된 인자', + }); + }); + + test('ToolCancelled JSON 형태', () { + const r = ToolCancelled(); + expect(r.toJson(), { + 'status': 'cancelled', + 'reason': 'user did not confirm', + }); + }); + }); + + group('encodeToolResult 2KB cap', () { + test('payload 작으면 그대로', () { + const r = ToolOk({'k': 'v'}); + final s = encodeToolResult(r); + expect(jsonDecode(s), { + 'status': 'ok', + 'data': {'k': 'v'} + }); + }); + + test('payload 2KB 초과 시 truncation hint 로 대체', () { + final big = ToolOk({'blob': 'x' * 5000}); + final s = encodeToolResult(big); + expect(s.length, lessThan(500)); + final decoded = jsonDecode(s) as Map; + expect(decoded['status'], 'ok'); + final data = decoded['data'] as Map; + expect(data['_truncated'], true); + expect(data['_hint'], contains('query_protocol')); + }); + + test('error/cancelled 는 작아서 그대로', () { + expect( + encodeToolResult(const ToolErr('e', 'r')).length, + lessThan(100), + ); + expect( + encodeToolResult(const ToolCancelled()).length, + lessThan(100), + ); + }); + }); +} diff --git a/app/test/ai/tools/tracker_tools_test.dart b/app/test/ai/tools/tracker_tools_test.dart new file mode 100644 index 0000000..4a50c5d --- /dev/null +++ b/app/test/ai/tools/tracker_tools_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:life_helper/ai/tools/habit_tools.dart'; +import 'package:life_helper/ai/tools/tracker_tools.dart'; +import 'package:life_helper/ai/tools/tool_envelope.dart'; + +import '_tool_test_helpers.dart'; + +void main() { + group('log_tracker_entry', () { + test('정상 done 기록', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final addRes = await addHabitTool.handler({ + 'protocol_id': 'morning_sunlight', + 'frame_level': 'L2', + 'framed_text': '햇빛', + }, ctx.deps); + final habitId = (addRes as ToolOk).data['habit_id'] as String; + + final r = await logTrackerEntryTool.handler({ + 'habit_id': habitId, + 'value': 'done', + 'date': '2026-06-15', + }, ctx.deps); + expect(r, isA(), reason: '$r'); + final data = (r as ToolOk).data; + expect(data['habit_id'], habitId); + expect(data['value'], 'done'); + }); + + test('value 유효성', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await logTrackerEntryTool.handler({ + 'habit_id': 'whatever', + 'value': 'maybe', + }, ctx.deps); + expect(r, isA()); + expect((r as ToolErr).code, 'validation'); + }); + + test('미존재 habit_id', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await logTrackerEntryTool.handler({ + 'habit_id': 'hb_no_such', + 'value': 'done', + }, ctx.deps); + expect(r, isA()); + expect((r as ToolErr).code, 'not_found'); + }); + + test('같은 (habit_id, date) 중복 → duplicate', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final addRes = await addHabitTool.handler({ + 'protocol_id': 'morning_sunlight', + 'frame_level': 'L2', + 'framed_text': '햇빛', + }, ctx.deps); + final habitId = (addRes as ToolOk).data['habit_id'] as String; + await logTrackerEntryTool.handler({ + 'habit_id': habitId, + 'value': 'done', + 'date': '2026-06-15', + }, ctx.deps); + final r2 = await logTrackerEntryTool.handler({ + 'habit_id': habitId, + 'value': 'blank', + 'date': '2026-06-15', + }, ctx.deps); + expect(r2, isA()); + expect((r2 as ToolErr).code, 'duplicate'); + }); + + test('date 형식 오류', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = await logTrackerEntryTool.handler({ + 'habit_id': 'h', + 'value': 'done', + 'date': '2026/06/15', + }, ctx.deps); + expect(r, isA()); + expect((r as ToolErr).code, 'validation'); + }); + }); + + group('get_streak', () { + test('습관 없음 → not_found', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final r = + await getStreakTool.handler({'habit_id': 'hb_no'}, ctx.deps); + expect(r, isA()); + expect((r as ToolErr).code, 'not_found'); + }); + + test('정상 — 기록 없을 때 0 streak', () async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + final addRes = await addHabitTool.handler({ + 'protocol_id': 'morning_sunlight', + 'frame_level': 'L2', + 'framed_text': '햇빛', + }, ctx.deps); + final habitId = (addRes as ToolOk).data['habit_id'] as String; + final r = + await getStreakTool.handler({'habit_id': habitId}, ctx.deps); + expect(r, isA(), reason: '$r'); + final data = (r as ToolOk).data; + expect(data['current_streak'], 0); + expect(data['tier'], isNotNull); + }); + }); +} diff --git a/app/test/state/chat_session_controller_test.dart b/app/test/state/chat_session_controller_test.dart new file mode 100644 index 0000000..e8f8124 --- /dev/null +++ b/app/test/state/chat_session_controller_test.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:life_helper/ai/tools/tool_dispatcher.dart'; +import 'package:life_helper/ai/tools/tool_envelope.dart'; +import 'package:life_helper/ai/tools/tool_registry.dart'; +import 'package:life_helper/data/ai/llm_service.dart'; +import 'package:life_helper/state/chat_providers.dart'; + +import '../ai/tools/_tool_test_helpers.dart'; + +class _Harness { + final ChatSessionController controller; + final MockLlmService mock; + final dynamic db; + _Harness(this.controller, this.mock, this.db); +} + +// ignore: library_private_types_in_public_api +Future<_Harness> makeHarness() async { + final ctx = await bootstrapToolDeps(); + final mock = MockLlmService(); + await mock.load(); + final controller = ChatSessionController( + llm: mock, + dispatcher: ToolDispatcher(registry: ToolRegistry.defaults()), + deps: ctx.deps, + tools: ToolRegistry.defaults().all.toList(), + ); + return _Harness(controller, mock, ctx.db); +} + +/// Pumps an empty Material harness and returns a live mounted BuildContext +/// for read-only tool dispatch. The context becomes unmounted when the +/// widget is pumped away (used in the destructive-cancel test). +Future mountContext(WidgetTester tester) async { + late BuildContext captured; + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (ctx) { + captured = ctx; + return const SizedBox.shrink(); + }), + )); + return captured; +} + +void main() { + testWidgets('자연어 응답만 — model 메시지로 종료', (tester) async { + final h = await makeHarness(); + addTearDown(() { + h.controller.dispose(); + h.db.close(); + }); + final ctx = await mountContext(tester); + h.mock.enqueueChatEvents([const LlmTextChunk('안녕!')]); + + await h.controller.userTurn('hi', ctx); + + expect(h.controller.state.isStreaming, false); + expect(h.controller.state.messages.length, 2); + expect(h.controller.state.messages.first, isA()); + expect(h.controller.state.messages.last, isA()); + expect( + (h.controller.state.messages.last as ModelChatMessage).text, + '안녕!', + ); + }); + + testWidgets('1 tool call + 응답 — 3 메시지', (tester) async { + final h = await makeHarness(); + addTearDown(() { + h.controller.dispose(); + h.db.close(); + }); + final ctx = await mountContext(tester); + h.mock.enqueueChatEvents([ + const LlmFunctionCall('search_catalog', {}), + ]); + h.mock.enqueueChatEvents([ + const LlmTextChunk('카탈로그 결과를 확인했어요.'), + ]); + + await h.controller.userTurn('카탈로그 보여줘', ctx); + + expect(h.controller.state.messages.length, 3); + expect(h.controller.state.messages[1], isA()); + expect( + (h.controller.state.messages[1] as ToolCallChatMessage).result, + isA(), + ); + expect(h.controller.state.error, isNull); + }); + + testWidgets('destructive + unmounted context → ToolCancelled', + (tester) async { + final h = await makeHarness(); + addTearDown(() { + h.controller.dispose(); + h.db.close(); + }); + final ctx = await mountContext(tester); + // 컨텍스트를 강제로 unmount. + await tester.pumpWidget(const SizedBox.shrink()); + expect(ctx.mounted, false); + + h.mock.enqueueChatEvents([ + const LlmFunctionCall('add_habit', { + 'protocol_id': 'morning_sunlight', + 'frame_level': 'L2', + 'framed_text': '햇빛', + }), + ]); + h.mock.enqueueChatEvents([const LlmTextChunk('취소했어요.')]); + + await h.controller.userTurn('습관 추가', ctx); + + final toolMsg = h.controller.state.messages + .whereType() + .single; + expect(toolMsg.result, isA()); + }); + + testWidgets('MAX_TURNS 초과 → error 세팅', (tester) async { + final h = await makeHarness(); + addTearDown(() { + h.controller.dispose(); + h.db.close(); + }); + final ctx = await mountContext(tester); + for (var i = 0; i < kChatMaxTurns + 1; i++) { + h.mock.enqueueChatEvents([ + const LlmFunctionCall('search_catalog', {}), + ]); + } + await h.controller.userTurn('무한루프', ctx); + expect(h.controller.state.error, contains('루프')); + expect(h.controller.state.isStreaming, false); + }); + + testWidgets('빈 입력 무시', (tester) async { + final h = await makeHarness(); + addTearDown(() { + h.controller.dispose(); + h.db.close(); + }); + final ctx = await mountContext(tester); + await h.controller.userTurn(' ', ctx); + expect(h.controller.state.messages, isEmpty); + expect(h.mock.chatStartCount, 0); + }); + + testWidgets('clear() 가 메시지 초기화', (tester) async { + final h = await makeHarness(); + addTearDown(() { + h.controller.dispose(); + h.db.close(); + }); + final ctx = await mountContext(tester); + h.mock.enqueueChatEvents([const LlmTextChunk('hi')]); + await h.controller.userTurn('x', ctx); + expect(h.controller.state.messages, isNotEmpty); + h.controller.clear(); + expect(h.controller.state.messages, isEmpty); + expect(h.controller.state.error, isNull); + }); + + testWidgets('tool result 가 다음 sendToolResult 로 전달', (tester) async { + final h = await makeHarness(); + addTearDown(() { + h.controller.dispose(); + h.db.close(); + }); + final ctx = await mountContext(tester); + h.mock.enqueueChatEvents([ + const LlmFunctionCall('list_active_habits', {}), + ]); + h.mock.enqueueChatEvents([ + const LlmTextChunk('현재 습관 0개.'), + ]); + await h.controller.userTurn('내 습관 알려줘', ctx); + + final chat = h.mock.lastChat!; + expect(chat.userInputs, ['내 습관 알려줘']); + expect(chat.toolResults.length, 1); + expect(chat.toolResults.first.$1, 'list_active_habits'); + final submitted = chat.toolResults.first.$2; + expect(submitted['status'], 'ok'); + }); +}