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
+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),
),
);