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
211 lines
6.9 KiB
Dart
211 lines
6.9 KiB
Dart
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;
|
|
}
|