[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,
|
||||
};
|
||||
}
|
||||
}
|
||||
70
app/lib/ai/tools/confirm_gate.dart
Normal file
70
app/lib/ai/tools/confirm_gate.dart
Normal file
@@ -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<bool> show(
|
||||
BuildContext context,
|
||||
ToolDefinition tool,
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
if (!context.mounted) return false;
|
||||
final summary = tool.summarize?.call(args) ?? _fallbackSummary(args);
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
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<String, dynamic> args) {
|
||||
try {
|
||||
return const JsonEncoder.withIndent(' ').convert(args);
|
||||
} catch (_) {
|
||||
return args.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
210
app/lib/ai/tools/habit_tools.dart
Normal file
210
app/lib/ai/tools/habit_tools.dart
Normal file
@@ -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<ToolResult> _addHabitHandler(
|
||||
Map<String, dynamic> 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>[]
|
||||
: [
|
||||
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<ToolResult> _listActiveHabitsHandler(
|
||||
Map<String, dynamic> 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;
|
||||
}
|
||||
54
app/lib/ai/tools/tool_definition.dart
Normal file
54
app/lib/ai/tools/tool_definition.dart
Normal file
@@ -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<FramePatternModel> framePatterns;
|
||||
final String userId;
|
||||
|
||||
const ToolDeps({
|
||||
required this.habitDao,
|
||||
required this.trackerDao,
|
||||
required this.catalog,
|
||||
required this.framePatterns,
|
||||
required this.userId,
|
||||
});
|
||||
}
|
||||
|
||||
typedef ToolHandler =
|
||||
Future<ToolResult> Function(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> args)? summarize;
|
||||
|
||||
const ToolDefinition({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.parametersSchema,
|
||||
required this.handler,
|
||||
this.isDestructive = false,
|
||||
this.summarize,
|
||||
});
|
||||
}
|
||||
104
app/lib/ai/tools/tool_dispatcher.dart
Normal file
104
app/lib/ai/tools/tool_dispatcher.dart
Normal file
@@ -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<ToolResult> dispatch({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> 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<String, dynamic> schema, Map<String, dynamic> 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
|
||||
}
|
||||
}
|
||||
63
app/lib/ai/tools/tool_envelope.dart
Normal file
63
app/lib/ai/tools/tool_envelope.dart
Normal file
@@ -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<String, dynamic> toJson();
|
||||
}
|
||||
|
||||
final class ToolOk extends ToolResult {
|
||||
final Map<String, dynamic> data;
|
||||
const ToolOk(this.data);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'status': 'ok', 'data': data};
|
||||
}
|
||||
|
||||
final class ToolErr extends ToolResult {
|
||||
final String code;
|
||||
final String reason;
|
||||
const ToolErr(this.code, this.reason);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() =>
|
||||
{'status': 'error', 'code': code, 'reason': reason};
|
||||
}
|
||||
|
||||
final class ToolCancelled extends ToolResult {
|
||||
const ToolCancelled();
|
||||
|
||||
@override
|
||||
Map<String, dynamic> 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);
|
||||
}
|
||||
33
app/lib/ai/tools/tool_registry.dart
Normal file
33
app/lib/ai/tools/tool_registry.dart
Normal file
@@ -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<ToolDefinition> kAllTools = [
|
||||
// read-only
|
||||
searchCatalogTool,
|
||||
queryProtocolTool,
|
||||
listActiveHabitsTool,
|
||||
getStreakTool,
|
||||
// destructive (confirm gate)
|
||||
addHabitTool,
|
||||
logTrackerEntryTool,
|
||||
];
|
||||
|
||||
class ToolRegistry {
|
||||
final Map<String, ToolDefinition> _byName;
|
||||
|
||||
ToolRegistry(List<ToolDefinition> tools)
|
||||
: _byName = {for (final t in tools) t.name: t};
|
||||
|
||||
factory ToolRegistry.defaults() => ToolRegistry(kAllTools);
|
||||
|
||||
ToolDefinition? byName(String name) => _byName[name];
|
||||
|
||||
Iterable<ToolDefinition> get all => _byName.values;
|
||||
}
|
||||
153
app/lib/ai/tools/tracker_tools.dart
Normal file
153
app/lib/ai/tools/tracker_tools.dart
Normal file
@@ -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<ToolResult> _logTrackerEntryHandler(
|
||||
Map<String, dynamic> 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<ToolResult> _getStreakHandler(
|
||||
Map<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<LlmChatSession> startChat({
|
||||
required List<tools.ToolDefinition> 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<String, dynamic>.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<LlmChatEvent> sendUser(String text) {
|
||||
if (_closed) {
|
||||
throw StateError('LlmChatSession is closed');
|
||||
}
|
||||
return _run(Message.text(text: text, isUser: true));
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<LlmChatEvent> sendToolResult({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> result,
|
||||
}) {
|
||||
if (_closed) {
|
||||
throw StateError('LlmChatSession is closed');
|
||||
}
|
||||
return _run(Message.toolResponse(toolName: toolName, response: result));
|
||||
}
|
||||
|
||||
Stream<LlmChatEvent> _run(Message msg) async* {
|
||||
await _chat.addQueryChunk(msg);
|
||||
final Stream<ModelResponse> 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<String, dynamic>.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<String, dynamic>.from(first.args),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// ThinkingResponse / other: skip.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
try {
|
||||
await _chat.close();
|
||||
} catch (_) {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the first `FunctionCallResponse(name == expectedName)` from
|
||||
|
||||
@@ -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<String, dynamic> schema,
|
||||
);
|
||||
|
||||
/// Opens a chat session that supports multi-turn user input + tool result
|
||||
/// submission with the supplied [tools]. See ADR-0005.
|
||||
Future<LlmChatSession> startChat({
|
||||
required List<ToolDefinition> 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<LlmChatEvent> sendUser(String text);
|
||||
|
||||
Stream<LlmChatEvent> sendToolResult({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> result,
|
||||
});
|
||||
|
||||
Future<void> 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<String, dynamic> args;
|
||||
const LlmFunctionCall(this.name, this.args);
|
||||
}
|
||||
|
||||
/// Programmable stub for tests. Use [enqueueResponse] / [enqueueError].
|
||||
@@ -31,6 +73,12 @@ class MockLlmService implements LlmService {
|
||||
Map<String, dynamic>? 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<List<LlmChatEvent>> 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<LlmChatEvent> events) {
|
||||
chatScript.add(events);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> generateStructured(
|
||||
String prompt,
|
||||
@@ -73,6 +127,61 @@ class MockLlmService implements LlmService {
|
||||
if (r.error != null) throw r.error!;
|
||||
return r.value!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LlmChatSession> startChat({
|
||||
required List<ToolDefinition> 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<List<LlmChatEvent>> _script;
|
||||
int sendCount = 0;
|
||||
final List<String> userInputs = [];
|
||||
final List<(String, Map<String, dynamic>)> toolResults = [];
|
||||
bool closed = false;
|
||||
|
||||
MockLlmChatSession(this._script);
|
||||
|
||||
Stream<LlmChatEvent> _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<LlmChatEvent> sendUser(String text) {
|
||||
userInputs.add(text);
|
||||
return _emitNext();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<LlmChatEvent> sendToolResult({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> result,
|
||||
}) {
|
||||
toolResults.add((toolName, result));
|
||||
return _emitNext();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class _Response {
|
||||
|
||||
@@ -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<String, dynamic> schema,
|
||||
) async =>
|
||||
(await _resolve()).generateStructured(prompt, schema);
|
||||
|
||||
@override
|
||||
Future<LlmChatSession> startChat({
|
||||
required List<tools.ToolDefinition> tools,
|
||||
}) async =>
|
||||
(await _resolve()).startChat(tools: tools);
|
||||
}
|
||||
|
||||
class LifeHelperApp extends StatelessWidget {
|
||||
|
||||
251
app/lib/state/chat_providers.dart
Normal file
251
app/lib/state/chat_providers.dart
Normal file
@@ -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<String, dynamic> 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<ChatMessage> 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<ChatMessage>? 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<ToolRegistry>((ref) {
|
||||
return ToolRegistry.defaults();
|
||||
});
|
||||
|
||||
final toolDepsProvider = FutureProvider<ToolDeps>((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<ToolDispatcher>((ref) {
|
||||
return ToolDispatcher(registry: ref.watch(toolRegistryProvider));
|
||||
});
|
||||
|
||||
class ChatSessionController extends StateNotifier<ChatSessionState> {
|
||||
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<ToolDefinition> tools;
|
||||
|
||||
LlmChatSession? _session;
|
||||
|
||||
Future<void> 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<String, dynamic>? pendingToolResult;
|
||||
Stream<LlmChatEvent> 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<UserChatMessage>()
|
||||
.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,
|
||||
);
|
||||
});
|
||||
243
app/lib/ui/screens/chat_screen.dart
Normal file
243
app/lib/ui/screens/chat_screen.dart
Normal file
@@ -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<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
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<void> _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 '취소됨';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: '카탈로그 탐색',
|
||||
|
||||
48
app/test/ai/tools/_tool_test_helpers.dart
Normal file
48
app/test/ai/tools/_tool_test_helpers.dart
Normal file
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
82
app/test/ai/tools/catalog_tools_test.dart
Normal file
82
app/test/ai/tools/catalog_tools_test.dart
Normal file
@@ -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<ToolOk>());
|
||||
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<ToolOk>());
|
||||
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<ToolErr>());
|
||||
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<ToolErr>());
|
||||
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<ToolOk>());
|
||||
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<ToolOk>());
|
||||
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<ToolErr>());
|
||||
expect((r as ToolErr).code, 'not_found');
|
||||
});
|
||||
});
|
||||
}
|
||||
130
app/test/ai/tools/habit_tools_test.dart
Normal file
130
app/test/ai/tools/habit_tools_test.dart
Normal file
@@ -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<ToolOk>(), 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<ToolOk>(), 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<ToolErr>());
|
||||
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<ToolErr>());
|
||||
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<ToolErr>());
|
||||
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<ToolErr>());
|
||||
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<ToolOk>());
|
||||
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<ToolOk>());
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['count'], 1);
|
||||
expect(data['build_count'], 1);
|
||||
expect(data['build_quota_remaining'], 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
114
app/test/ai/tools/tool_dispatcher_test.dart
Normal file
114
app/test/ai/tools/tool_dispatcher_test.dart
Normal file
@@ -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<ToolErr>());
|
||||
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<ToolErr>());
|
||||
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<ToolErr>());
|
||||
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<ToolCancelled>());
|
||||
});
|
||||
|
||||
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<ToolOk>());
|
||||
});
|
||||
|
||||
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<ToolErr>());
|
||||
expect((r as ToolErr).code, 'handler_error');
|
||||
});
|
||||
});
|
||||
}
|
||||
66
app/test/ai/tools/tool_envelope_test.dart
Normal file
66
app/test/ai/tools/tool_envelope_test.dart
Normal file
@@ -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<String, dynamic>;
|
||||
expect(decoded['status'], 'ok');
|
||||
final data = decoded['data'] as Map<String, dynamic>;
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
116
app/test/ai/tools/tracker_tools_test.dart
Normal file
116
app/test/ai/tools/tracker_tools_test.dart
Normal file
@@ -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<ToolOk>(), 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<ToolErr>());
|
||||
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<ToolErr>());
|
||||
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<ToolErr>());
|
||||
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<ToolErr>());
|
||||
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<ToolErr>());
|
||||
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<ToolOk>(), reason: '$r');
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['current_streak'], 0);
|
||||
expect(data['tier'], isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
188
app/test/state/chat_session_controller_test.dart
Normal file
188
app/test/state/chat_session_controller_test.dart
Normal file
@@ -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<BuildContext> 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<UserChatMessage>());
|
||||
expect(h.controller.state.messages.last, isA<ModelChatMessage>());
|
||||
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<ToolCallChatMessage>());
|
||||
expect(
|
||||
(h.controller.state.messages[1] as ToolCallChatMessage).result,
|
||||
isA<ToolOk>(),
|
||||
);
|
||||
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<ToolCallChatMessage>()
|
||||
.single;
|
||||
expect(toolMsg.result, isA<ToolCancelled>());
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user