Add LLM digest setup and share timestamp context
This commit is contained in:
@@ -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
@@ -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(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user