[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/foundation.dart';
|
||||||
import 'package:flutter_gemma/flutter_gemma.dart';
|
import 'package:flutter_gemma/flutter_gemma.dart';
|
||||||
|
|
||||||
|
import '../../ai/tools/tool_definition.dart' as tools;
|
||||||
import 'llm_service.dart';
|
import 'llm_service.dart';
|
||||||
|
|
||||||
/// HuggingFace access token injected at build time via
|
/// 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
|
/// Extracts the first `FunctionCallResponse(name == expectedName)` from
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import '../../ai/tools/tool_definition.dart';
|
||||||
|
|
||||||
/// Abstract LLM backend.
|
/// Abstract LLM backend.
|
||||||
///
|
///
|
||||||
/// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests).
|
/// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests).
|
||||||
@@ -7,6 +9,7 @@
|
|||||||
/// - [generateStructured] returns a parsed JSON map matching the schema.
|
/// - [generateStructured] returns a parsed JSON map matching the schema.
|
||||||
/// On schema/parse failure throws [FormatException].
|
/// On schema/parse failure throws [FormatException].
|
||||||
/// - [unload] is idempotent.
|
/// - [unload] is idempotent.
|
||||||
|
/// - [startChat] opens a multi-turn chat session for tool calling (#260).
|
||||||
abstract class LlmService {
|
abstract class LlmService {
|
||||||
bool get isLoaded;
|
bool get isLoaded;
|
||||||
|
|
||||||
@@ -20,6 +23,45 @@ abstract class LlmService {
|
|||||||
String prompt,
|
String prompt,
|
||||||
Map<String, dynamic> schema,
|
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].
|
/// Programmable stub for tests. Use [enqueueResponse] / [enqueueError].
|
||||||
@@ -31,6 +73,12 @@ class MockLlmService implements LlmService {
|
|||||||
Map<String, dynamic>? lastSchema;
|
Map<String, dynamic>? lastSchema;
|
||||||
Duration responseDelay = Duration.zero;
|
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
|
@override
|
||||||
bool get isLoaded => _loaded;
|
bool get isLoaded => _loaded;
|
||||||
|
|
||||||
@@ -52,6 +100,12 @@ class MockLlmService implements LlmService {
|
|||||||
_queue.add(_Response.error(error));
|
_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
|
@override
|
||||||
Future<Map<String, dynamic>> generateStructured(
|
Future<Map<String, dynamic>> generateStructured(
|
||||||
String prompt,
|
String prompt,
|
||||||
@@ -73,6 +127,61 @@ class MockLlmService implements LlmService {
|
|||||||
if (r.error != null) throw r.error!;
|
if (r.error != null) throw r.error!;
|
||||||
return r.value!;
|
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 {
|
class _Response {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/gemma_llm_service.dart';
|
||||||
import 'data/ai/llm_service.dart';
|
import 'data/ai/llm_service.dart';
|
||||||
import 'data/ai/model_lifecycle.dart';
|
import 'data/ai/model_lifecycle.dart';
|
||||||
@@ -77,6 +78,12 @@ class _LazyLlmService implements LlmService {
|
|||||||
Map<String, dynamic> schema,
|
Map<String, dynamic> schema,
|
||||||
) async =>
|
) async =>
|
||||||
(await _resolve()).generateStructured(prompt, schema);
|
(await _resolve()).generateStructured(prompt, schema);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LlmChatSession> startChat({
|
||||||
|
required List<tools.ToolDefinition> tools,
|
||||||
|
}) async =>
|
||||||
|
(await _resolve()).startChat(tools: tools);
|
||||||
}
|
}
|
||||||
|
|
||||||
class LifeHelperApp extends StatelessWidget {
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../state/ai_providers.dart';
|
||||||
import '../../state/providers.dart';
|
import '../../state/providers.dart';
|
||||||
|
import 'chat_screen.dart';
|
||||||
import 'check_in_screen.dart';
|
import 'check_in_screen.dart';
|
||||||
import 'habit_create_screen.dart';
|
import 'habit_create_screen.dart';
|
||||||
import 'protocol_gallery_screen.dart';
|
import 'protocol_gallery_screen.dart';
|
||||||
@@ -15,11 +17,22 @@ class HabitListScreen extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final boot = ref.watch(bootstrapProvider);
|
final boot = ref.watch(bootstrapProvider);
|
||||||
final habitsAsync = ref.watch(activeHabitsProvider);
|
final habitsAsync = ref.watch(activeHabitsProvider);
|
||||||
|
final aiOptIn = ref.watch(aiSettingsProvider).valueOrNull ?? false;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('습관'),
|
title: const Text('습관'),
|
||||||
actions: [
|
actions: [
|
||||||
|
if (aiOptIn)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.smart_toy_outlined),
|
||||||
|
tooltip: 'AI 코치',
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).push(MaterialPageRoute(
|
||||||
|
builder: (_) => const ChatScreen(),
|
||||||
|
));
|
||||||
|
},
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
tooltip: '카탈로그 탐색',
|
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