/// 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. abstract class LlmService { bool get isLoaded; Future load(); Future unload(); /// Calls the model with a function-calling [schema] and returns the parsed /// JSON arguments map. Caller is responsible for applying `.timeout(...)`. Future> generateStructured( String prompt, Map schema, ); } /// Programmable stub for tests. Use [enqueueResponse] / [enqueueError]. class MockLlmService implements LlmService { final List<_Response> _queue = []; bool _loaded = false; int callCount = 0; String? lastPrompt; Map? lastSchema; Duration responseDelay = Duration.zero; @override bool get isLoaded => _loaded; @override Future load() async { _loaded = true; } @override Future unload() async { _loaded = false; } void enqueueResponse(Map response) { _queue.add(_Response.value(response)); } void enqueueError(Object error) { _queue.add(_Response.error(error)); } @override Future> generateStructured( String prompt, Map schema, ) async { callCount += 1; lastPrompt = prompt; lastSchema = schema; if (!_loaded) { throw StateError('LlmService not loaded'); } if (responseDelay > Duration.zero) { await Future.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!; } } class _Response { final Map? value; final Object? error; const _Response._(this.value, this.error); factory _Response.value(Map v) => _Response._(v, null); factory _Response.error(Object e) => _Response._(null, e); }