Files
life-helper/app/lib/data/ai/llm_service.dart
joungmin b1bed4d5ca [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
2026-06-15 10:42:43 +09:00

194 lines
5.2 KiB
Dart

import '../../ai/tools/tool_definition.dart';
/// Abstract LLM backend.
///
/// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests).
/// Contract:
/// - After [load], [isLoaded] is true.
/// - [generateStructured] throws [StateError] when not loaded.
/// - [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;
Future<void> load();
Future<void> unload();
/// Calls the model with a function-calling [schema] and returns the parsed
/// JSON arguments map. Caller is responsible for applying `.timeout(...)`.
Future<Map<String, dynamic>> generateStructured(
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].
class MockLlmService implements LlmService {
final List<_Response> _queue = [];
bool _loaded = false;
int callCount = 0;
String? lastPrompt;
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;
@override
Future<void> load() async {
_loaded = true;
}
@override
Future<void> unload() async {
_loaded = false;
}
void enqueueResponse(Map<String, dynamic> response) {
_queue.add(_Response.value(response));
}
void enqueueError(Object 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
Future<Map<String, dynamic>> generateStructured(
String prompt,
Map<String, dynamic> schema,
) async {
callCount += 1;
lastPrompt = prompt;
lastSchema = schema;
if (!_loaded) {
throw StateError('LlmService not loaded');
}
if (responseDelay > Duration.zero) {
await Future<void>.delayed(responseDelay);
}
if (_queue.isEmpty) {
throw StateError('MockLlmService: no queued response');
}
final r = _queue.removeAt(0);
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 {
final Map<String, dynamic>? value;
final Object? error;
const _Response._(this.value, this.error);
factory _Response.value(Map<String, dynamic> v) => _Response._(v, null);
factory _Response.error(Object e) => _Response._(null, e);
}