Add LLM digest setup and share timestamp context
This commit is contained in:
+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),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user