[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:
2026-06-15 10:42:43 +09:00
parent eca097aa2c
commit b1bed4d5ca
21 changed files with 2313 additions and 0 deletions

View 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,
};
}
}

View 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();
}
}
}

View 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;
}

View 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,
});
}

View 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
}
}

View 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);
}

View 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;
}

View 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;
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_gemma/flutter_gemma.dart';
import '../../ai/tools/tool_definition.dart' as tools;
import 'llm_service.dart';
/// HuggingFace access token injected at build time via
@@ -114,6 +115,93 @@ class GemmaLlmService implements LlmService {
}
}
}
@override
Future<LlmChatSession> startChat({
required List<tools.ToolDefinition> tools,
}) async {
if (!_loaded || _model == null) {
throw StateError('LlmService not loaded');
}
final gemmaTools = tools
.map((t) => Tool(
name: t.name,
description: t.description,
parameters: Map<String, dynamic>.from(t.parametersSchema),
))
.toList();
final chat = await _model!.createChat(
modelType: ModelType.gemma4,
supportsFunctionCalls: true,
// ToolChoice.auto = 모델이 자율 결정 (multi-tool + reply-only 모두 지원).
toolChoice: ToolChoice.auto,
tools: gemmaTools,
);
return _GemmaChatSession(chat);
}
}
class _GemmaChatSession implements LlmChatSession {
final dynamic _chat;
bool _closed = false;
_GemmaChatSession(this._chat);
@override
Stream<LlmChatEvent> sendUser(String text) {
if (_closed) {
throw StateError('LlmChatSession is closed');
}
return _run(Message.text(text: text, isUser: true));
}
@override
Stream<LlmChatEvent> sendToolResult({
required String toolName,
required Map<String, dynamic> result,
}) {
if (_closed) {
throw StateError('LlmChatSession is closed');
}
return _run(Message.toolResponse(toolName: toolName, response: result));
}
Stream<LlmChatEvent> _run(Message msg) async* {
await _chat.addQueryChunk(msg);
final Stream<ModelResponse> stream = _chat.generateChatResponseAsync();
await for (final event in stream) {
if (event is TextResponse) {
yield LlmTextChunk(event.token);
} else if (event is FunctionCallResponse) {
yield LlmFunctionCall(
event.name,
Map<String, dynamic>.from(event.args),
);
return; // model hands control back to caller for tool exec
} else if (event is ParallelFunctionCallResponse &&
event.calls.isNotEmpty) {
// ADR-0005: parallel calls collapsed to first — sequential dispatch.
final first = event.calls.first;
yield LlmFunctionCall(
first.name,
Map<String, dynamic>.from(first.args),
);
return;
}
// ThinkingResponse / other: skip.
}
}
@override
Future<void> close() async {
if (_closed) return;
_closed = true;
try {
await _chat.close();
} catch (_) {
// Best-effort cleanup.
}
}
}
/// Extracts the first `FunctionCallResponse(name == expectedName)` from

View File

@@ -1,3 +1,5 @@
import '../../ai/tools/tool_definition.dart';
/// Abstract LLM backend.
///
/// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests).
@@ -7,6 +9,7 @@
/// - [generateStructured] returns a parsed JSON map matching the schema.
/// On schema/parse failure throws [FormatException].
/// - [unload] is idempotent.
/// - [startChat] opens a multi-turn chat session for tool calling (#260).
abstract class LlmService {
bool get isLoaded;
@@ -20,6 +23,45 @@ abstract class LlmService {
String prompt,
Map<String, dynamic> schema,
);
/// Opens a chat session that supports multi-turn user input + tool result
/// submission with the supplied [tools]. See ADR-0005.
Future<LlmChatSession> startChat({
required List<ToolDefinition> tools,
});
}
/// Streaming chat session for the tool-calling loop.
///
/// Lifecycle: created by [LlmService.startChat], lives for a single chat
/// screen, must be [close]d when the user dismisses the screen. Each
/// `send*` call returns a stream of [LlmChatEvent]s until the model yields
/// control (text done or a function call requested).
abstract class LlmChatSession {
Stream<LlmChatEvent> sendUser(String text);
Stream<LlmChatEvent> sendToolResult({
required String toolName,
required Map<String, dynamic> result,
});
Future<void> close();
}
/// Events emitted by [LlmChatSession]. See ADR-0005 §C.
sealed class LlmChatEvent {
const LlmChatEvent();
}
final class LlmTextChunk extends LlmChatEvent {
final String text;
const LlmTextChunk(this.text);
}
final class LlmFunctionCall extends LlmChatEvent {
final String name;
final Map<String, dynamic> args;
const LlmFunctionCall(this.name, this.args);
}
/// Programmable stub for tests. Use [enqueueResponse] / [enqueueError].
@@ -31,6 +73,12 @@ class MockLlmService implements LlmService {
Map<String, dynamic>? lastSchema;
Duration responseDelay = Duration.zero;
/// Queues consumed by [startChat] in order. Each entry is the event list
/// returned for a single `send*` call.
final List<List<LlmChatEvent>> chatScript = [];
int chatStartCount = 0;
MockLlmChatSession? lastChat;
@override
bool get isLoaded => _loaded;
@@ -52,6 +100,12 @@ class MockLlmService implements LlmService {
_queue.add(_Response.error(error));
}
/// Enqueue one batch of events that will be emitted on the next
/// `sendUser` or `sendToolResult` call. Items are streamed in order.
void enqueueChatEvents(List<LlmChatEvent> events) {
chatScript.add(events);
}
@override
Future<Map<String, dynamic>> generateStructured(
String prompt,
@@ -73,6 +127,61 @@ class MockLlmService implements LlmService {
if (r.error != null) throw r.error!;
return r.value!;
}
@override
Future<LlmChatSession> startChat({
required List<ToolDefinition> tools,
}) async {
if (!_loaded) {
throw StateError('LlmService not loaded');
}
chatStartCount += 1;
final session = MockLlmChatSession(chatScript);
lastChat = session;
return session;
}
}
/// Mock chat session that replays pre-queued events from [MockLlmService].
class MockLlmChatSession implements LlmChatSession {
final List<List<LlmChatEvent>> _script;
int sendCount = 0;
final List<String> userInputs = [];
final List<(String, Map<String, dynamic>)> toolResults = [];
bool closed = false;
MockLlmChatSession(this._script);
Stream<LlmChatEvent> _emitNext() async* {
sendCount += 1;
if (_script.isEmpty) {
throw StateError('MockLlmChatSession: no queued events');
}
final batch = _script.removeAt(0);
for (final ev in batch) {
yield ev;
}
}
@override
Stream<LlmChatEvent> sendUser(String text) {
userInputs.add(text);
return _emitNext();
}
@override
Stream<LlmChatEvent> sendToolResult({
required String toolName,
required Map<String, dynamic> result,
}) {
toolResults.add((toolName, result));
return _emitNext();
}
@override
Future<void> close() async {
closed = true;
}
}
class _Response {

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'ai/tools/tool_definition.dart' as tools;
import 'data/ai/gemma_llm_service.dart';
import 'data/ai/llm_service.dart';
import 'data/ai/model_lifecycle.dart';
@@ -77,6 +78,12 @@ class _LazyLlmService implements LlmService {
Map<String, dynamic> schema,
) async =>
(await _resolve()).generateStructured(prompt, schema);
@override
Future<LlmChatSession> startChat({
required List<tools.ToolDefinition> tools,
}) async =>
(await _resolve()).startChat(tools: tools);
}
class LifeHelperApp extends StatelessWidget {

View 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,
);
});

View 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 '취소됨';
}
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../state/ai_providers.dart';
import '../../state/providers.dart';
import 'chat_screen.dart';
import 'check_in_screen.dart';
import 'habit_create_screen.dart';
import 'protocol_gallery_screen.dart';
@@ -15,11 +17,22 @@ class HabitListScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final boot = ref.watch(bootstrapProvider);
final habitsAsync = ref.watch(activeHabitsProvider);
final aiOptIn = ref.watch(aiSettingsProvider).valueOrNull ?? false;
return Scaffold(
appBar: AppBar(
title: const Text('습관'),
actions: [
if (aiOptIn)
IconButton(
icon: const Icon(Icons.smart_toy_outlined),
tooltip: 'AI 코치',
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const ChatScreen(),
));
},
),
IconButton(
icon: const Icon(Icons.search),
tooltip: '카탈로그 탐색',

View 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,
),
);
}

View 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');
});
});
}

View 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);
});
});
}

View 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');
});
});
}

View 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),
);
});
});
}

View 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);
});
});
}

View 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');
});
}