Add LLM digest setup and share timestamp context

This commit is contained in:
Rijad Zuzo
2026-05-19 19:17:35 +02:00
parent 9d2da0c600
commit 5d80af375e
42 changed files with 2878 additions and 39 deletions
+118
View File
@@ -0,0 +1,118 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const String _llmDiagnosticsLogKey = 'llm_diagnostics_log_v1';
const int _maxLlmDiagnosticsEvents = 40;
const int _maxLlmDiagnosticsDetailChars = 8000;
class LlmDiagnosticsEvent {
const LlmDiagnosticsEvent({
required this.at,
required this.stage,
required this.message,
this.details,
});
factory LlmDiagnosticsEvent.fromJson(Map<String, dynamic> json) {
return LlmDiagnosticsEvent(
at: DateTime.parse(
json['at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
).toLocal(),
stage: json['stage'] as String? ?? 'unknown',
message: json['message'] as String? ?? '',
details: json['details'] as String?,
);
}
final DateTime at;
final String stage;
final String message;
final String? details;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'at': at.toUtc().toIso8601String(),
'stage': stage,
'message': message,
if (details != null) 'details': details,
};
}
}
class LlmDiagnosticsLog {
const LlmDiagnosticsLog();
Future<List<LlmDiagnosticsEvent>> read() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? raw = prefs.getString(_llmDiagnosticsLogKey);
if (raw == null || raw.isEmpty) {
return const <LlmDiagnosticsEvent>[];
}
try {
final List<dynamic> items = jsonDecode(raw) as List<dynamic>;
return items
.whereType<Map<String, dynamic>>()
.map(LlmDiagnosticsEvent.fromJson)
.toList(growable: false);
} on FormatException {
return const <LlmDiagnosticsEvent>[];
}
}
Future<void> record(String stage, String message, {String? details}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final List<LlmDiagnosticsEvent> current = await read();
final List<LlmDiagnosticsEvent> next = <LlmDiagnosticsEvent>[
LlmDiagnosticsEvent(
at: DateTime.now(),
stage: stage,
message: message,
details: _compactDetails(details),
),
...current,
].take(_maxLlmDiagnosticsEvents).toList(growable: false);
await prefs.setString(
_llmDiagnosticsLogKey,
jsonEncode(
next
.map((LlmDiagnosticsEvent event) => event.toJson())
.toList(growable: false),
),
);
}
Future<void> clear() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.remove(_llmDiagnosticsLogKey);
}
String exportText(List<LlmDiagnosticsEvent> events) {
if (events.isEmpty) {
return 'No LLM diagnostics recorded.';
}
return events
.map((LlmDiagnosticsEvent event) {
final String details = event.details == null
? ''
: '\n${event.details}';
return '[${event.at.toIso8601String()}] ${event.stage}: ${event.message}$details';
})
.join('\n\n');
}
}
final Provider<LlmDiagnosticsLog> llmDiagnosticsLogProvider =
Provider<LlmDiagnosticsLog>((Ref ref) => const LlmDiagnosticsLog());
String? _compactDetails(String? value) {
final String? trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) {
return null;
}
if (trimmed.length <= _maxLlmDiagnosticsDetailChars) {
return trimmed;
}
return '${trimmed.substring(0, _maxLlmDiagnosticsDetailChars)}\n[truncated]';
}
+223 -12
View File
@@ -4,6 +4,9 @@ import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/llm/captured_fact_draft_parser.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/core/llm/llm_diagnostics_log.dart';
import 'package:relationship_saver/core/llm/ollama/ollama_tool_chat_client.dart';
import 'package:relationship_saver/core/observability/sentry_init.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
@@ -100,6 +103,7 @@ Generate 3-5 helpful relationship signals for these people.''';
Future<String> completeText({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
final config = await _config;
final String? apiKey = await _ref
@@ -117,6 +121,7 @@ Generate 3-5 helpful relationship signals for these people.''';
model: config.model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
enableWebSearch: enableWebSearch,
);
}
@@ -147,6 +152,9 @@ Be conservative. If the content is ambiguous, prefer "note". Return JSON only.''
Sender: ${payload.sourceDisplayName ?? "unknown"}
Platform: ${payload.platform}
URL: ${payload.url ?? "none"}
shared_message_datetime: ${payload.sharedMessageDateTime?.toIso8601String() ?? "null"}
Import received at: ${payload.receivedAt.toIso8601String()}
If shared_message_datetime is present, use it as the point-in-time reference for interpreting the message. Old status or health updates should usually become historical context or durable preferences, not current check-in prompts.
Raw text:
${payload.rawText}''';
@@ -230,14 +238,30 @@ ${payload.rawText}''';
required String model,
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
final Dio dio = _createDio();
final Dio dio = provider == LlmProvider.ollama
? _createDio(receiveTimeout: const Duration(minutes: 5))
: _createDio();
final String resolvedBaseUrl = _normalizeBaseUrl(baseUrl);
await _recordDiagnostics(
'request_start',
'Starting ${provider.label} LLM request.',
details: _diagnosticsDetails(
provider: provider,
baseUrl: resolvedBaseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
),
);
try {
late final String content;
switch (provider) {
case LlmProvider.openai:
return _callOpenAI(
content = await _callOpenAI(
dio,
resolvedBaseUrl,
apiKey,
@@ -246,7 +270,7 @@ ${payload.rawText}''';
userPrompt,
);
case LlmProvider.anthropic:
return _callAnthropic(
content = await _callAnthropic(
dio,
resolvedBaseUrl,
apiKey,
@@ -255,24 +279,26 @@ ${payload.rawText}''';
userPrompt,
);
case LlmProvider.google:
return _callGoogleAI(
content = await _callGoogleAI(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
enableWebSearch: enableWebSearch,
);
case LlmProvider.ollama:
return _callOllama(
content = await _callOllama(
dio,
resolvedBaseUrl,
model,
systemPrompt,
userPrompt,
enableWebSearch: enableWebSearch,
);
case LlmProvider.openaiCompatible:
return _callOpenAI(
content = await _callOpenAI(
dio,
resolvedBaseUrl,
apiKey,
@@ -281,11 +307,88 @@ ${payload.rawText}''';
userPrompt,
);
}
await _recordDiagnostics(
'request_success',
'${provider.label} returned a response.',
details: _diagnosticsDetails(
provider: provider,
baseUrl: resolvedBaseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
responseLength: content.length,
),
);
return content;
} on DioException catch (error) {
await _recordProviderFailure(
provider: provider,
baseUrl: resolvedBaseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
error: error,
);
throw _mapProviderError(provider, error);
}
}
Future<void> _recordDiagnostics(
String stage,
String message, {
String? details,
}) async {
try {
await _ref
.read(llmDiagnosticsLogProvider)
.record(stage, message, details: details);
} catch (_) {
// Diagnostics must never break the user-facing LLM flow.
}
}
Future<void> _recordProviderFailure({
required LlmProvider provider,
required String baseUrl,
required String model,
required bool enableWebSearch,
required String systemPrompt,
required String userPrompt,
required DioException error,
}) {
final int? statusCode = error.response?.statusCode;
final String status = statusCode == null
? 'without HTTP status'
: '$statusCode';
final Map<String, dynamic> context = _diagnosticsMap(
provider: provider,
baseUrl: baseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
dioType: error.type.name,
statusCode: statusCode,
providerMessage: _extractProviderMessage(error.response?.data),
responseBody: _safeJson(error.response?.data),
);
_ref
.read(sentryInitProvider)
.captureException(
error,
stackTrace: error.stackTrace,
area: 'llm_provider',
context: context,
);
return _recordDiagnostics(
'provider_error',
'${provider.label} request failed with $status.',
details: const JsonEncoder.withIndent(' ').convert(context),
);
}
Future<String> _callOpenAI(
Dio dio,
String baseUrl,
@@ -364,8 +467,9 @@ ${payload.rawText}''';
String? apiKey,
String model,
String systemPrompt,
String userPrompt,
) async {
String userPrompt, {
bool enableWebSearch = false,
}) async {
final String modelPath = model.startsWith('models/')
? model
: 'models/$model';
@@ -387,6 +491,10 @@ ${payload.rawText}''';
],
},
],
if (enableWebSearch)
'tools': [
{'google_search': <String, dynamic>{}},
],
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
}),
);
@@ -409,8 +517,20 @@ ${payload.rawText}''';
String baseUrl,
String model,
String systemPrompt,
String userPrompt,
) async {
String userPrompt, {
bool enableWebSearch = false,
}) async {
if (enableWebSearch) {
return _ref
.read(ollamaToolChatClientProvider)
.complete(
baseUrl: baseUrl,
model: model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
}
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'$baseUrl/api/chat',
@@ -609,6 +729,97 @@ LlmProviderException _mapProviderError(
);
}
String _diagnosticsDetails({
required LlmProvider provider,
required String baseUrl,
required String model,
required bool enableWebSearch,
required String systemPrompt,
required String userPrompt,
int? responseLength,
String? dioType,
int? statusCode,
String? providerMessage,
String? responseBody,
}) {
return const JsonEncoder.withIndent(' ').convert(
_diagnosticsMap(
provider: provider,
baseUrl: baseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
responseLength: responseLength,
dioType: dioType,
statusCode: statusCode,
providerMessage: providerMessage,
responseBody: responseBody,
),
);
}
Map<String, dynamic> _diagnosticsMap({
required LlmProvider provider,
required String baseUrl,
required String model,
required bool enableWebSearch,
required String systemPrompt,
required String userPrompt,
int? responseLength,
String? dioType,
int? statusCode,
String? providerMessage,
String? responseBody,
}) {
final Map<String, dynamic> diagnostics = <String, dynamic>{
'provider': provider.name,
'baseUrl': baseUrl,
'model': model,
'webSearchRequested': enableWebSearch,
'systemPromptChars': systemPrompt.length,
'userPromptChars': userPrompt.length,
};
if (responseLength != null) {
diagnostics['responseChars'] = responseLength;
}
if (dioType != null) {
diagnostics['dioType'] = dioType;
}
if (statusCode != null) {
diagnostics['statusCode'] = statusCode;
}
if (providerMessage != null) {
diagnostics['providerMessage'] = providerMessage;
}
if (responseBody != null) {
diagnostics['responseBody'] = responseBody;
}
return diagnostics;
}
String? _safeJson(Object? data) {
if (data == null) {
return null;
}
try {
final String encoded = data is String
? data
: const JsonEncoder.withIndent(' ').convert(data);
return _compactDiagnosticValue(encoded);
} catch (_) {
return _compactDiagnosticValue('$data');
}
}
String _compactDiagnosticValue(String value) {
final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' ');
if (compacted.length <= 3000) {
return compacted;
}
return '${compacted.substring(0, 2997)}...';
}
String _normalizeBaseUrl(String baseUrl) {
String normalized = baseUrl.trim();
while (normalized.endsWith('/')) {
@@ -617,11 +828,11 @@ String _normalizeBaseUrl(String baseUrl) {
return normalized;
}
Dio _createDio() {
Dio _createDio({Duration receiveTimeout = const Duration(seconds: 20)}) {
return Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 20),
receiveTimeout: receiveTimeout,
sendTimeout: const Duration(seconds: 20),
),
);
@@ -0,0 +1,261 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/llm/ollama/ollama_web_search_client.dart';
class OllamaToolChatClient {
OllamaToolChatClient({
required OllamaWebSearchClient webSearchClient,
Dio? dio,
int maxToolRounds = 3,
}) : _webSearchClient = webSearchClient,
_dio = dio ?? _createDio(),
_maxToolRounds = maxToolRounds;
final OllamaWebSearchClient _webSearchClient;
final Dio _dio;
final int _maxToolRounds;
Future<String> complete({
required String baseUrl,
required String model,
required String systemPrompt,
required String userPrompt,
}) async {
final List<Map<String, dynamic>> messages = <Map<String, dynamic>>[
{'role': 'system', 'content': _toolSystemPrompt(systemPrompt)},
{'role': 'user', 'content': userPrompt},
];
String latestContent = '';
for (int round = 0; round <= _maxToolRounds; round += 1) {
final Map<String, dynamic> message = await _chat(
baseUrl: baseUrl,
model: model,
messages: messages,
);
latestContent = _stringValue(message['content']);
final List<Map<String, dynamic>> toolCalls = _toolCalls(message);
if (toolCalls.isEmpty) {
return latestContent;
}
messages.add(<String, dynamic>{
'role': 'assistant',
'content': latestContent,
'tool_calls': toolCalls,
});
for (final Map<String, dynamic> toolCall in toolCalls) {
final String toolName = _toolName(toolCall);
final Map<String, dynamic> arguments = _toolArguments(toolCall);
final String toolResult = await _executeTool(toolName, arguments);
messages.add(<String, dynamic>{
'role': 'tool',
'tool_name': toolName,
'content': toolResult,
});
}
}
if (latestContent.trim().isNotEmpty) {
return latestContent;
}
throw StateError(
'Ollama did not return a final response after tool calls.',
);
}
Future<Map<String, dynamic>> _chat({
required String baseUrl,
required String model,
required List<Map<String, dynamic>> messages,
}) async {
final Response<Map<String, dynamic>> response = await _dio
.post<Map<String, dynamic>>(
'$baseUrl/api/chat',
options: Options(headers: {'Content-Type': 'application/json'}),
data: jsonEncode(<String, dynamic>{
'model': model,
'stream': false,
'messages': messages,
'tools': _tools,
}),
);
final Map<String, dynamic> data = response.data ?? <String, dynamic>{};
final Object? message = data['message'];
if (message is Map<String, dynamic>) {
return message;
}
return <String, dynamic>{};
}
Future<String> _executeTool(
String toolName,
Map<String, dynamic> arguments,
) async {
try {
switch (toolName) {
case 'search_web':
final String query = _stringValue(arguments['query']);
final int maxResults =
(arguments['maxResults'] as num?)?.toInt() ?? 5;
final List<OllamaWebSearchResult> results = await _webSearchClient
.search(query, maxResults: maxResults);
return jsonEncode(<String, dynamic>{
'query': query,
'results': results
.map((OllamaWebSearchResult result) => result.toJson())
.toList(growable: false),
});
case 'fetch_web_page':
final String url = _stringValue(arguments['url']);
final String text = await _webSearchClient.fetchPageText(url);
return jsonEncode(<String, String>{'url': url, 'text': text});
default:
return jsonEncode(<String, String>{
'error': 'Unknown tool: $toolName',
});
}
} catch (error) {
return jsonEncode(<String, String>{
'error': 'Tool $toolName failed: $error',
});
}
}
}
String _toolSystemPrompt(String systemPrompt) {
return '''
$systemPrompt
When you need current web information, call search_web first. Use fetch_web_page
only for URLs returned by search_web or URLs present in the user input. After
tool results are available, produce the final answer in the exact JSON schema
requested above. Do not expose tool-call internals in the final answer.
''';
}
List<Map<String, dynamic>> _toolCalls(Map<String, dynamic> message) {
final Object? toolCalls = message['tool_calls'];
if (toolCalls is! List<dynamic>) {
return const <Map<String, dynamic>>[];
}
return toolCalls
.whereType<Map<dynamic, dynamic>>()
.map(
(Map<dynamic, dynamic> item) =>
item.map((dynamic key, dynamic value) => MapEntry('$key', value)),
)
.toList(growable: false);
}
String _toolName(Map<String, dynamic> toolCall) {
final Object? function = toolCall['function'];
if (function is Map<String, dynamic>) {
return _stringValue(function['name']);
}
return _stringValue(toolCall['name']);
}
Map<String, dynamic> _toolArguments(Map<String, dynamic> toolCall) {
final Object? function = toolCall['function'];
final Object? rawArguments = function is Map<String, dynamic>
? function['arguments']
: toolCall['arguments'];
if (rawArguments is Map<String, dynamic>) {
return rawArguments;
}
if (rawArguments is Map<dynamic, dynamic>) {
return rawArguments.map(
(dynamic key, dynamic value) => MapEntry('$key', value),
);
}
if (rawArguments is String && rawArguments.trim().isNotEmpty) {
final Object? decoded = jsonDecode(rawArguments);
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map<dynamic, dynamic>) {
return decoded.map(
(dynamic key, dynamic value) => MapEntry('$key', value),
);
}
}
return const <String, dynamic>{};
}
String _stringValue(Object? value) {
if (value is String) {
return value.trim();
}
return '';
}
List<Map<String, dynamic>> get _tools {
return <Map<String, dynamic>>[
<String, dynamic>{
'type': 'function',
'function': <String, dynamic>{
'name': 'search_web',
'description':
'Search the public web for current event, venue, date, price, '
'availability, and source information.',
'parameters': <String, dynamic>{
'type': 'object',
'properties': <String, dynamic>{
'query': <String, dynamic>{
'type': 'string',
'description': 'Specific search query.',
},
'maxResults': <String, dynamic>{
'type': 'integer',
'description': 'Maximum number of search results to return.',
'minimum': 1,
'maximum': 10,
},
},
'required': <String>['query'],
},
},
},
<String, dynamic>{
'type': 'function',
'function': <String, dynamic>{
'name': 'fetch_web_page',
'description':
'Fetch readable text from a specific web page when the search '
'snippet is not enough to verify event details.',
'parameters': <String, dynamic>{
'type': 'object',
'properties': <String, dynamic>{
'url': <String, dynamic>{
'type': 'string',
'description': 'HTTP or HTTPS URL to fetch.',
},
},
'required': <String>['url'],
},
},
},
];
}
Dio _createDio() {
return Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(minutes: 5),
sendTimeout: const Duration(seconds: 20),
),
);
}
final Provider<OllamaToolChatClient> ollamaToolChatClientProvider =
Provider<OllamaToolChatClient>((Ref ref) {
return OllamaToolChatClient(
webSearchClient: ref.watch(ollamaWebSearchClientProvider),
);
});
@@ -0,0 +1,173 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class OllamaWebSearchResult {
const OllamaWebSearchResult({
required this.title,
required this.url,
required this.snippet,
});
final String title;
final String url;
final String snippet;
Map<String, String> toJson() {
return <String, String>{'title': title, 'url': url, 'snippet': snippet};
}
}
abstract interface class OllamaWebSearchClient {
Future<List<OllamaWebSearchResult>> search(
String query, {
int maxResults = 5,
});
Future<String> fetchPageText(String url);
}
class DuckDuckGoOllamaWebSearchClient implements OllamaWebSearchClient {
DuckDuckGoOllamaWebSearchClient({Dio? dio}) : _dio = dio ?? _createDio();
final Dio _dio;
@override
Future<List<OllamaWebSearchResult>> search(
String query, {
int maxResults = 5,
}) async {
final String normalizedQuery = query.trim();
if (normalizedQuery.isEmpty) {
return const <OllamaWebSearchResult>[];
}
final Response<Map<String, dynamic>> response = await _dio
.get<Map<String, dynamic>>(
'https://api.duckduckgo.com/',
queryParameters: <String, Object>{
'q': normalizedQuery,
'format': 'json',
'no_html': 1,
'skip_disambig': 1,
},
);
final Map<String, dynamic> data = response.data ?? <String, dynamic>{};
final List<OllamaWebSearchResult> results = <OllamaWebSearchResult>[];
final String abstractUrl = _stringValue(data['AbstractURL']);
final String abstractText = _stringValue(data['AbstractText']);
final String heading = _stringValue(data['Heading']);
if (abstractUrl.isNotEmpty && abstractText.isNotEmpty) {
results.add(
OllamaWebSearchResult(
title: heading.isEmpty ? abstractUrl : heading,
url: abstractUrl,
snippet: abstractText,
),
);
}
_collectRelatedTopics(data['Results'], results);
_collectRelatedTopics(data['RelatedTopics'], results);
final Set<String> seenUrls = <String>{};
return results
.where((OllamaWebSearchResult result) => seenUrls.add(result.url))
.take(maxResults.clamp(1, 10))
.toList(growable: false);
}
@override
Future<String> fetchPageText(String url) async {
final Uri? uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https')) {
return jsonEncode(<String, String>{'error': 'Invalid URL.'});
}
final Response<String> response = await _dio.get<String>(
uri.toString(),
options: Options(responseType: ResponseType.plain),
);
return _compactText(_htmlToText(response.data ?? ''));
}
static void _collectRelatedTopics(
Object? value,
List<OllamaWebSearchResult> results,
) {
if (value is! List<dynamic>) {
return;
}
for (final Object? item in value) {
if (item is! Map<String, dynamic>) {
continue;
}
final Object? nestedTopics = item['Topics'];
if (nestedTopics is List<dynamic>) {
_collectRelatedTopics(nestedTopics, results);
continue;
}
final String firstUrl = _stringValue(item['FirstURL']);
final String text = _stringValue(item['Text']);
if (firstUrl.isEmpty || text.isEmpty) {
continue;
}
results.add(
OllamaWebSearchResult(
title: text.split(' - ').first.trim(),
url: firstUrl,
snippet: text,
),
);
}
}
}
String _stringValue(Object? value) {
if (value is String) {
return value.trim();
}
return '';
}
String _htmlToText(String html) {
return html
.replaceAll(
RegExp(r'<script[\s\S]*?</script>', caseSensitive: false),
' ',
)
.replaceAll(RegExp(r'<style[\s\S]*?</style>', caseSensitive: false), ' ')
.replaceAll(RegExp(r'<[^>]+>'), ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
}
String _compactText(String value) {
final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' ');
if (compacted.length <= 4000) {
return compacted;
}
return '${compacted.substring(0, 3997)}...';
}
Dio _createDio() {
return Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 20),
sendTimeout: const Duration(seconds: 20),
),
);
}
final Provider<OllamaWebSearchClient> ollamaWebSearchClientProvider =
Provider<OllamaWebSearchClient>((Ref ref) {
return DuckDuckGoOllamaWebSearchClient();
});