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