Files
life-helper/app/lib/data/ai/llm_service.dart
joungmin 5b4c05316a [03-Developer] #311 LLM warm-up + concurrent guard + quickCheck
ChatScreen 마운트 시 백그라운드 native init 으로 첫 send 시점에 native
load 지연을 안 보이게 한다. 12개 AC + UX-Reviewer 의 6개 권고 모두 코드
반영.

핵심 변경:
- `chat_warmup_provider.dart` — `ChatWarmupController` (Idle/Loading/Ready
  /Unavailable/Failed sealed state). fast path (`llm.isLoaded` → Ready),
  FileSystemException ↔ runtime kind 분기, _disposed race guard.
- `model_lifecycle.dart` — `quickCheck()`: 2.4GB SHA-256 hashing 없이
  meta_kv + 파일 존재만 보고 ready 추정 (R4 UX 권고).
- `gemma_llm_service.dart` + `llm_service.dart` — `_loadingFuture` 동시
  호출 가드. 두 caller 가 동시에 load() 해도 native init 은 1 회만.
- `chat_screen.dart` — initState postFrameCallback 에서 warmup.start().
  warmup 상태에 따라 hintText / spinner / 실패 banner 분기.

AC coverage (12개):
- AC1~AC8: ChatWarmupController unit (chat_warmup_test.dart 8 tests).
- AC9~AC12: UX-Reviewer 의 4개 권고 (입력 enabled / send auto-activate /
  fast path no-flicker / 명령형 메시지 금지) — controller 레벨에서 검증.

테스트: 167 passed (1 pre-existing skip). `flutter analyze` clean.

Refs #311

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 12:52:50 +09:00

225 lines
6.3 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;
/// #311 test helpers. Simulate cold-load latency / failure so the warm-up
/// controller can be exercised. Mirrors the Gemma path:
/// - `loadDelay > 0` → load completes after the delay
/// - `loadThrows` → load throws this error
/// - `loadCount` → observed by concurrent-load tests
Duration loadDelay = Duration.zero;
Object? loadThrows;
int loadCount = 0;
Future<void>? _loadingFuture;
/// 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;
/// #311 AC7: same concurrent-call guard as [GemmaLlmService]. Repeated
/// in-flight `load()` calls share a single Future, so test assertions on
/// `loadCount` reflect the number of native-init attempts (1), not the
/// number of callers.
@override
Future<void> load() {
if (_loaded) return Future.value();
final existing = _loadingFuture;
if (existing != null) return existing;
final future = _doLoad();
_loadingFuture = future;
return future.whenComplete(() {
_loadingFuture = null;
});
}
Future<void> _doLoad() async {
loadCount += 1;
if (loadDelay > Duration.zero) {
await Future<void>.delayed(loadDelay);
}
final err = loadThrows;
if (err != null) throw err;
_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);
}