Files
rely/lib/core/llm/llm_service.dart
2026-05-19 19:17:35 +02:00

901 lines
27 KiB
Dart

import 'dart:convert';
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';
class LlmProviderException implements Exception {
const LlmProviderException({
required this.message,
required this.provider,
this.statusCode,
});
final String message;
final LlmProvider provider;
final int? statusCode;
@override
String toString() => message;
}
class LlmModelInfo {
const LlmModelInfo({required this.id, String? label}) : label = label ?? id;
final String id;
final String label;
}
class LlmService {
LlmService(this._ref);
final Ref _ref;
Future<LlmConfigState> get _config async {
final state = _ref.read(llmConfigProvider);
if (!state.isConfigured) {
throw Exception('LLM not configured');
}
return state;
}
Future<List<GeneratedSignal>> generateSignals(
List<PersonProfile> people,
) async {
final config = await _config;
final apiKey = await _ref.read(llmConfigProvider.notifier).getApiKey();
if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) {
throw Exception('No API key available');
}
final systemPrompt =
'''You are a relationship assistant that helps users maintain meaningful connections with their friends and family.
Based on the person's profile data, generate helpful signals such as:
- Check-in reminders
- Gift ideas
- Follow-up suggestions
- Relationship maintenance tips
Return your response as a JSON array of signals, each with:
- title: short actionable title
- description: brief explanation
- type: "recommendation", "gift", "followup", or "reminder"
- personId: the person's ID (use a valid ID from the input if applicable)
Only return the JSON array, no other text.''';
final peopleContext = people
.map(
(p) =>
'- ${p.name} (${p.relationship}): tags=${p.tags.join(", ")}, notes=${p.notes.isEmpty ? "none" : p.notes}, affinity=${p.affinityScore}',
)
.join('\n');
final userPrompt =
'''Here are the people in my life:
$peopleContext
Generate 3-5 helpful relationship signals for these people.''';
try {
final response = await _callLlm(
provider: config.provider,
apiKey: apiKey,
baseUrl: config.baseUrl,
model: config.model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
return _parseSignalsResponse(response, people);
} catch (e) {
throw Exception('Failed to generate signals: $e');
}
}
Future<String> completeText({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
final config = await _config;
final String? apiKey = await _ref
.read(llmConfigProvider.notifier)
.getApiKey();
if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) {
throw Exception('No API key available');
}
return _callLlm(
provider: config.provider,
apiKey: apiKey,
baseUrl: config.baseUrl,
model: config.model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
enableWebSearch: enableWebSearch,
);
}
Future<CapturedFactDraft?> suggestCaptureDraft(SharedPayload payload) async {
final config = await _config;
final String? apiKey = await _ref
.read(llmConfigProvider.notifier)
.getApiKey();
if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) {
throw Exception('No API key available');
}
final String systemPrompt =
'''You analyze shared messages and links for a relationship-tracking app.
Return exactly one JSON object with:
- type: one of "note", "like", "dislike", "importantDate", "giftIdea", "placeIdea", "activityIdea", "misc"
- text: cleaned user-facing summary
- label: short optional label
- dateValue: ISO-8601 date if the content clearly describes an important date, otherwise null
- confidence: number from 0 to 1
- isSensitive: true only if the content looks private or sensitive
Be conservative. If the content is ambiguous, prefer "note". Return JSON only.''';
final String userPrompt =
'''Source app: ${payload.sourceApp}
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}''';
try {
final String response = await _callLlm(
provider: config.provider,
apiKey: apiKey,
baseUrl: config.baseUrl,
model: config.model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
return parseCapturedFactDraftSuggestion(
response,
fallbackPayload: payload,
);
} catch (e) {
throw Exception('Failed to suggest share capture draft: $e');
}
}
Future<List<LlmModelInfo>> listAvailableModels({
required LlmProvider provider,
String? apiKey,
String? baseUrl,
}) async {
final String? resolvedApiKey = apiKey != null && apiKey.trim().isNotEmpty
? apiKey.trim()
: await _ref
.read(llmConfigProvider.notifier)
.getApiKey(provider: provider);
if (provider.requiresApiKey &&
(resolvedApiKey == null || resolvedApiKey.isEmpty)) {
throw LlmProviderException(
provider: provider,
message: '${provider.label} needs an API key before models can load.',
);
}
final Dio dio = _createDio();
final String resolvedBaseUrl = _normalizeBaseUrl(
baseUrl?.trim().isNotEmpty == true
? baseUrl!.trim()
: provider.defaultBaseUrl,
);
try {
return switch (provider) {
LlmProvider.openai => _listOpenAIModels(
dio,
resolvedBaseUrl,
resolvedApiKey,
),
LlmProvider.anthropic => _listAnthropicModels(
dio,
resolvedBaseUrl,
resolvedApiKey,
),
LlmProvider.google => _listGoogleModels(
dio,
resolvedBaseUrl,
resolvedApiKey,
),
LlmProvider.ollama => _listOllamaModels(dio, resolvedBaseUrl),
LlmProvider.openaiCompatible => _listOpenAIModels(
dio,
resolvedBaseUrl,
resolvedApiKey,
),
};
} on DioException catch (error) {
throw _mapProviderError(provider, error);
}
}
Future<String> _callLlm({
required LlmProvider provider,
required String? apiKey,
required String baseUrl,
required String model,
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
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:
content = await _callOpenAI(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
);
case LlmProvider.anthropic:
content = await _callAnthropic(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
);
case LlmProvider.google:
content = await _callGoogleAI(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
enableWebSearch: enableWebSearch,
);
case LlmProvider.ollama:
content = await _callOllama(
dio,
resolvedBaseUrl,
model,
systemPrompt,
userPrompt,
enableWebSearch: enableWebSearch,
);
case LlmProvider.openaiCompatible:
content = await _callOpenAI(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
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,
String? apiKey,
String model,
String systemPrompt,
String userPrompt,
) async {
final Map<String, String> headers = <String, String>{
'Content-Type': 'application/json',
if (apiKey != null && apiKey.isNotEmpty)
'Authorization': 'Bearer $apiKey',
};
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'$baseUrl/chat/completions',
options: Options(headers: headers),
data: jsonEncode({
'model': model,
'messages': [
{'role': 'system', 'content': systemPrompt},
{'role': 'user', 'content': userPrompt},
],
'max_tokens': 1000,
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> choices = responseData['choices'] as List<dynamic>;
final Map<String, dynamic> firstChoice = choices[0] as Map<String, dynamic>;
final Map<String, dynamic> message =
firstChoice['message'] as Map<String, dynamic>;
final String content = message['content'] as String;
return content;
}
Future<String> _callAnthropic(
Dio dio,
String baseUrl,
String? apiKey,
String model,
String systemPrompt,
String userPrompt,
) async {
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'$baseUrl/messages',
options: Options(
headers: {
'x-api-key': apiKey ?? '',
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
),
data: jsonEncode({
'model': model,
'system': systemPrompt,
'messages': [
{'role': 'user', 'content': userPrompt},
],
'max_tokens': 1000,
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> contentList = responseData['content'] as List<dynamic>;
final Map<String, dynamic> firstContent =
contentList[0] as Map<String, dynamic>;
final String content = firstContent['text'] as String;
return content;
}
Future<String> _callGoogleAI(
Dio dio,
String baseUrl,
String? apiKey,
String model,
String systemPrompt,
String userPrompt, {
bool enableWebSearch = false,
}) async {
final String modelPath = model.startsWith('models/')
? model
: 'models/$model';
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'$baseUrl/$modelPath:generateContent',
options: Options(headers: {'Content-Type': 'application/json'}),
queryParameters: {'key': apiKey ?? ''},
data: jsonEncode({
'systemInstruction': {
'parts': [
{'text': systemPrompt},
],
},
'contents': [
{
'parts': [
{'text': userPrompt},
],
},
],
if (enableWebSearch)
'tools': [
{'google_search': <String, dynamic>{}},
],
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> candidates =
responseData['candidates'] as List<dynamic>;
final Map<String, dynamic> firstCandidate =
candidates[0] as Map<String, dynamic>;
final Map<String, dynamic> content =
firstCandidate['content'] as Map<String, dynamic>;
final List<dynamic> parts = content['parts'] as List<dynamic>;
final Map<String, dynamic> firstPart = parts[0] as Map<String, dynamic>;
final String contentText = firstPart['text'] as String;
return contentText;
}
Future<String> _callOllama(
Dio dio,
String baseUrl,
String model,
String systemPrompt,
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',
options: Options(headers: {'Content-Type': 'application/json'}),
data: jsonEncode({
'model': model,
'stream': false,
'messages': [
{'role': 'system', 'content': systemPrompt},
{'role': 'user', 'content': userPrompt},
],
}),
);
final Map<String, dynamic> responseData = response.data!;
final Map<String, dynamic> message =
responseData['message'] as Map<String, dynamic>;
return message['content'] as String;
}
Future<List<LlmModelInfo>> _listOpenAIModels(
Dio dio,
String baseUrl,
String? apiKey,
) async {
final Response<Map<String, dynamic>> response = await dio
.get<Map<String, dynamic>>(
'$baseUrl/models',
options: Options(
headers: <String, String>{
if (apiKey != null && apiKey.isNotEmpty)
'Authorization': 'Bearer $apiKey',
},
),
);
final List<dynamic> data = response.data!['data'] as List<dynamic>;
return _sortModels(
data
.map((dynamic item) => item as Map<String, dynamic>)
.map((Map<String, dynamic> item) => item['id'] as String)
.map((String id) => LlmModelInfo(id: id)),
);
}
Future<List<LlmModelInfo>> _listAnthropicModels(
Dio dio,
String baseUrl,
String? apiKey,
) async {
final Response<Map<String, dynamic>> response = await dio
.get<Map<String, dynamic>>(
'$baseUrl/models',
options: Options(
headers: <String, String>{
'x-api-key': apiKey ?? '',
'anthropic-version': '2023-06-01',
},
),
);
final List<dynamic> data = response.data!['data'] as List<dynamic>;
return _sortModels(
data.map((dynamic item) {
final Map<String, dynamic> model = item as Map<String, dynamic>;
final String id = model['id'] as String;
return LlmModelInfo(id: id, label: model['display_name'] as String?);
}),
);
}
Future<List<LlmModelInfo>> _listGoogleModels(
Dio dio,
String baseUrl,
String? apiKey,
) async {
final Response<Map<String, dynamic>> response = await dio
.get<Map<String, dynamic>>(
'$baseUrl/models',
queryParameters: <String, String>{'key': apiKey ?? ''},
);
final List<dynamic> data = response.data!['models'] as List<dynamic>;
return _sortModels(
data
.map((dynamic item) => item as Map<String, dynamic>)
.where((Map<String, dynamic> item) {
final Object? methods = item['supportedGenerationMethods'];
return methods is List<dynamic> &&
methods.whereType<String>().contains('generateContent');
})
.map((Map<String, dynamic> item) {
final String name = item['name'] as String;
final String id = name.startsWith('models/')
? name.substring('models/'.length)
: name;
return LlmModelInfo(
id: id,
label: item['displayName'] as String? ?? id,
);
}),
);
}
Future<List<LlmModelInfo>> _listOllamaModels(Dio dio, String baseUrl) async {
final Response<Map<String, dynamic>> response = await dio
.get<Map<String, dynamic>>('$baseUrl/api/tags');
final List<dynamic> data = response.data!['models'] as List<dynamic>;
return _sortModels(
data.map((dynamic item) => item as Map<String, dynamic>).map((
Map<String, dynamic> item,
) {
final Object? name = item['name'] ?? item['model'];
return LlmModelInfo(id: name as String);
}),
);
}
List<GeneratedSignal> _parseSignalsResponse(
String response,
List<PersonProfile> people,
) {
try {
final jsonStart = response.indexOf('[');
final jsonEnd = response.lastIndexOf(']');
if (jsonStart == -1 || jsonEnd == -1) {
return [];
}
final jsonStr = response.substring(jsonStart, jsonEnd + 1);
final List<dynamic> items = jsonDecode(jsonStr) as List<dynamic>;
return items.map((item) {
final Map<String, dynamic> itemMap = item as Map<String, dynamic>;
final String? personId = itemMap['personId'] as String?;
final validPersonId =
personId != null && people.any((p) => p.id == personId)
? personId
: (people.isNotEmpty ? people.first.id : null);
return GeneratedSignal(
title: itemMap['title'] as String? ?? 'Check in',
description: itemMap['description'] as String? ?? '',
type: itemMap['type'] as String? ?? 'recommendation',
personId: validPersonId,
);
}).toList();
} catch (e) {
return [];
}
}
}
LlmProviderException _mapProviderError(
LlmProvider provider,
DioException error,
) {
final int? statusCode = error.response?.statusCode;
final String providerName = _providerLabel(provider);
final String? providerMessage = _extractProviderMessage(error.response?.data);
if (statusCode == 429) {
final String detail = providerMessage == null ? '' : ' $providerMessage';
return LlmProviderException(
provider: provider,
statusCode: statusCode,
message:
'$providerName rejected the digest request with 429 rate limiting or quota pressure.$detail Check that the API key has available billing/quota, wait for the provider limit to reset, or switch to another configured provider.',
);
}
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return LlmProviderException(
provider: provider,
statusCode: statusCode,
message: '$providerName timed out while running the digest request.',
);
}
if (error.type == DioExceptionType.connectionError) {
return LlmProviderException(
provider: provider,
statusCode: statusCode,
message:
'Could not reach $providerName. Check the network connection and provider endpoint availability.',
);
}
final String statusText = statusCode == null ? '' : ' HTTP $statusCode.';
final String detail = providerMessage == null ? '' : ' $providerMessage';
return LlmProviderException(
provider: provider,
statusCode: statusCode,
message:
'$providerName could not complete the digest request.$statusText$detail',
);
}
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('/')) {
normalized = normalized.substring(0, normalized.length - 1);
}
return normalized;
}
Dio _createDio({Duration receiveTimeout = const Duration(seconds: 20)}) {
return Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: receiveTimeout,
sendTimeout: const Duration(seconds: 20),
),
);
}
List<LlmModelInfo> _sortModels(Iterable<LlmModelInfo> models) {
final List<LlmModelInfo> sorted = models.toList();
sorted.sort(
(LlmModelInfo a, LlmModelInfo b) =>
a.label.toLowerCase().compareTo(b.label.toLowerCase()),
);
return sorted;
}
String _providerLabel(LlmProvider provider) {
return provider.label;
}
String? _extractProviderMessage(Object? data) {
if (data is Map<String, dynamic>) {
final Object? direct = data['message'];
if (direct is String && direct.trim().isNotEmpty) {
return _compactProviderMessage(direct);
}
final Object? error = data['error'];
if (error is String && error.trim().isNotEmpty) {
return _compactProviderMessage(error);
}
if (error is Map<String, dynamic>) {
final Object? nested = error['message'];
if (nested is String && nested.trim().isNotEmpty) {
return _compactProviderMessage(nested);
}
}
}
return null;
}
String _compactProviderMessage(String value) {
final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' ');
if (compacted.length <= 240) {
return compacted;
}
return '${compacted.substring(0, 237)}...';
}
class GeneratedSignal {
const GeneratedSignal({
required this.title,
required this.description,
required this.type,
this.personId,
});
final String title;
final String description;
final String type;
final String? personId;
}
final llmServiceProvider = Provider<LlmService>((Ref ref) {
return LlmService(ref);
});