Add LLM digest setup and share timestamp context
This commit is contained in:
@@ -66,7 +66,7 @@ extension LocalRepositoryAiDigestOperations on LocalRepository {
|
||||
? IdeaType.gift
|
||||
: IdeaType.event,
|
||||
title: draft.title,
|
||||
details: _aiDraftDetails(draft),
|
||||
details: _aiIdeaDetails(draft),
|
||||
createdAt: now,
|
||||
);
|
||||
await _setState(
|
||||
@@ -156,6 +156,18 @@ String _aiDraftDetails(AiSuggestionDraft draft) {
|
||||
return '${draft.details.trim()}$reason'.trim();
|
||||
}
|
||||
|
||||
String _aiIdeaDetails(AiSuggestionDraft draft) {
|
||||
final List<String> parts = <String>[
|
||||
_aiDraftDetails(draft),
|
||||
if (draft.eventLocation != null && draft.eventLocation!.trim().isNotEmpty)
|
||||
'Location: ${draft.eventLocation!.trim()}',
|
||||
if (draft.eventUrl != null && draft.eventUrl!.trim().isNotEmpty)
|
||||
'Event: ${draft.eventUrl!.trim()}',
|
||||
if (draft.sourceUrls.isNotEmpty) 'Sources: ${draft.sourceUrls.join(', ')}',
|
||||
].where((String value) => value.trim().isNotEmpty).toList(growable: false);
|
||||
return parts.join('\n\n').trim();
|
||||
}
|
||||
|
||||
extension<T> on Iterable<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ extension LocalRepositoryShareOperations on LocalRepository {
|
||||
rawText: input.messageText.trim(),
|
||||
createdAt: input.sharedAt ?? now,
|
||||
receivedAt: now,
|
||||
sharedMessageDateTime: input.sharedAt,
|
||||
platform: 'legacy',
|
||||
sourceDisplayName: _trimToNull(input.sourceDisplayName),
|
||||
sourceUserId: _trimToNull(input.sourceUserId),
|
||||
@@ -390,7 +391,8 @@ extension LocalRepositoryShareOperations on LocalRepository {
|
||||
String? consumedInboxEntryId,
|
||||
}) async {
|
||||
final String sourceApp = payload.sourceApp;
|
||||
final DateTime sharedAt = payload.createdAt;
|
||||
final DateTime sharedAt =
|
||||
payload.sharedMessageDateTime ?? payload.createdAt;
|
||||
final String? sourceUserId = payload.sourceUserId;
|
||||
final String? sourceThreadId = payload.sourceThreadId;
|
||||
final bool createdSourceLink = matchedLink == null;
|
||||
|
||||
@@ -11,6 +11,17 @@ class AppConfig {
|
||||
|
||||
static const String _defaultSentryDsn = String.fromEnvironment(
|
||||
'SENTRY_DSN',
|
||||
defaultValue:
|
||||
'https://97fb0a56bc35440fba0ba139dc3b1ccc@bugs.zuzo.ch/2',
|
||||
);
|
||||
|
||||
static const String _defaultSentryEnvironment = String.fromEnvironment(
|
||||
'SENTRY_ENVIRONMENT',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
static const String _defaultSentryRelease = String.fromEnvironment(
|
||||
'SENTRY_RELEASE',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
@@ -53,9 +64,27 @@ class AppConfig {
|
||||
defaultValue: 180,
|
||||
);
|
||||
|
||||
/// Optional Sentry DSN for release crash/error reporting.
|
||||
/// Sentry-compatible DSN for release crash/error reporting.
|
||||
static String get sentryDsn => _defaultSentryDsn.trim();
|
||||
|
||||
/// Sentry environment label.
|
||||
static String get sentryEnvironment {
|
||||
final String configured = _defaultSentryEnvironment.trim();
|
||||
if (configured.isNotEmpty) {
|
||||
return configured;
|
||||
}
|
||||
return useFakeBackend ? 'development' : 'production';
|
||||
}
|
||||
|
||||
/// Optional Sentry release identifier.
|
||||
static String? get sentryRelease {
|
||||
final String configured = _defaultSentryRelease.trim();
|
||||
if (configured.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
/// Runtime override for backend URL (e.g. local settings screen).
|
||||
static void overrideBackendBaseUrl(String? baseUrl) {
|
||||
_baseUrlOverride = baseUrl;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -28,7 +28,7 @@ class SentryInit {
|
||||
Future<void> _initializeInternal({
|
||||
FutureOr<void> Function()? appRunner,
|
||||
}) async {
|
||||
if (!kReleaseMode || AppConfig.sentryDsn.isEmpty) {
|
||||
if (AppConfig.sentryDsn.isEmpty) {
|
||||
if (appRunner != null) {
|
||||
await Future<void>.sync(appRunner);
|
||||
}
|
||||
@@ -37,25 +37,90 @@ class SentryInit {
|
||||
|
||||
await SentryFlutter.init((options) {
|
||||
options.dsn = AppConfig.sentryDsn;
|
||||
options.environment = AppConfig.useFakeBackend
|
||||
? 'development'
|
||||
: 'production';
|
||||
options.environment = AppConfig.sentryEnvironment;
|
||||
options.release = AppConfig.sentryRelease;
|
||||
options.sendDefaultPii = false;
|
||||
options.tracesSampleRate = 0.1;
|
||||
}, appRunner: appRunner);
|
||||
await Sentry.configureScope((scope) async {
|
||||
await scope.setTag('service', 'relationship_saver');
|
||||
await scope.setTag('build_mode', kReleaseMode ? 'release' : 'debug');
|
||||
});
|
||||
_installGlobalHandlers();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
void captureException(Object error, {StackTrace? stackTrace}) {
|
||||
void captureException(
|
||||
Object error, {
|
||||
StackTrace? stackTrace,
|
||||
String? area,
|
||||
Map<String, dynamic>? context,
|
||||
}) {
|
||||
if (_initialized) {
|
||||
Sentry.captureException(error, stackTrace: stackTrace);
|
||||
unawaited(
|
||||
Sentry.captureException(
|
||||
error,
|
||||
stackTrace: stackTrace,
|
||||
withScope: (scope) async {
|
||||
if (area != null) {
|
||||
await scope.setTag('area', area);
|
||||
}
|
||||
if (context != null) {
|
||||
await scope.setContexts('diagnostics', context);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void captureMessage(String message, {SentryLevel level = SentryLevel.info}) {
|
||||
void captureMessage(
|
||||
String message, {
|
||||
SentryLevel level = SentryLevel.info,
|
||||
String? area,
|
||||
Map<String, dynamic>? context,
|
||||
}) {
|
||||
if (_initialized) {
|
||||
Sentry.captureMessage(message, level: level);
|
||||
unawaited(
|
||||
Sentry.captureMessage(
|
||||
message,
|
||||
level: level,
|
||||
withScope: (scope) async {
|
||||
if (area != null) {
|
||||
await scope.setTag('area', area);
|
||||
}
|
||||
if (context != null) {
|
||||
await scope.setContexts('diagnostics', context);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _installGlobalHandlers() {
|
||||
final FlutterExceptionHandler? previousFlutterHandler =
|
||||
FlutterError.onError;
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
previousFlutterHandler?.call(details);
|
||||
captureException(
|
||||
details.exception,
|
||||
stackTrace: details.stack,
|
||||
area: 'flutter_framework',
|
||||
context: <String, dynamic>{
|
||||
'library': details.library,
|
||||
'context': details.context?.toDescription(),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
final ErrorCallback? previousPlatformHandler =
|
||||
PlatformDispatcher.instance.onError;
|
||||
PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
|
||||
captureException(error, stackTrace: stack, area: 'platform_dispatcher');
|
||||
return previousPlatformHandler?.call(error, stack) ?? false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final sentryInitProvider = Provider<SentryInit>((Ref ref) {
|
||||
|
||||
@@ -69,6 +69,16 @@ class AiDigestResponseParser {
|
||||
suggestedFor:
|
||||
_dateOrNull(item['suggestedFor']) ??
|
||||
_dateOrNull(item['suggestedTiming']),
|
||||
eventEndsAt:
|
||||
_dateOrNull(item['eventEndsAt']) ??
|
||||
_dateOrNull(item['endsAt']) ??
|
||||
_dateOrNull(item['endTime']),
|
||||
eventLocation: _optionalTrimAndCap(
|
||||
_string(item['eventLocation']) ?? _string(item['location']),
|
||||
160,
|
||||
),
|
||||
eventUrl: _urlOrNull(item['eventUrl']) ?? _urlOrNull(item['url']),
|
||||
sourceUrls: _sourceUrls(item['sourceUrls'] ?? item['sources']),
|
||||
confidence: _confidence(item['confidence']),
|
||||
status: AiSuggestionStatus.pending,
|
||||
sourceRunId: sourceRunId,
|
||||
@@ -158,6 +168,40 @@ class AiDigestResponseParser {
|
||||
return DateTime.tryParse(value)?.toLocal();
|
||||
}
|
||||
|
||||
String? _urlOrNull(Object? raw) {
|
||||
final String? value = _string(raw);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
final Uri? uri = Uri.tryParse(value);
|
||||
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
List<String> _sourceUrls(Object? raw) {
|
||||
final List<Object?> values = raw is List<dynamic> ? raw : <Object?>[raw];
|
||||
final Set<String> urls = <String>{};
|
||||
for (final Object? value in values) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
final String? url =
|
||||
_urlOrNull(value['url']) ??
|
||||
_urlOrNull(value['uri']) ??
|
||||
_urlOrNull(value['link']);
|
||||
if (url != null) {
|
||||
urls.add(url);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final String? url = _urlOrNull(value);
|
||||
if (url != null) {
|
||||
urls.add(url);
|
||||
}
|
||||
}
|
||||
return urls.take(5).toList(growable: false);
|
||||
}
|
||||
|
||||
double _confidence(Object? raw) {
|
||||
if (raw is num) {
|
||||
return raw.toDouble().clamp(0.0, 1.0).toDouble();
|
||||
@@ -172,4 +216,12 @@ class AiDigestResponseParser {
|
||||
}
|
||||
return trimmed.substring(0, maxChars).trim();
|
||||
}
|
||||
|
||||
String? _optionalTrimAndCap(String? value, int maxChars) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
final String trimmed = _trimAndCap(value, maxChars);
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:math' as math;
|
||||
import 'package:relationship_saver/app/state/local_data_state.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||
import 'package:relationship_saver/features/people/domain/person_models.dart';
|
||||
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
|
||||
|
||||
class AnonymizedLlmDigestContext {
|
||||
const AnonymizedLlmDigestContext({
|
||||
@@ -23,12 +24,14 @@ class AnonymizedLlmContextBuilder {
|
||||
const AnonymizedLlmContextBuilder({
|
||||
this.maxPeople = 20,
|
||||
this.maxSignalsPerPerson = 8,
|
||||
this.maxFactsPerPerson = 8,
|
||||
this.maxPriorSuggestionsPerPerson = 6,
|
||||
DateTime Function()? now,
|
||||
}) : _now = now ?? DateTime.now;
|
||||
|
||||
final int maxPeople;
|
||||
final int maxSignalsPerPerson;
|
||||
final int maxFactsPerPerson;
|
||||
final int maxPriorSuggestionsPerPerson;
|
||||
final DateTime Function() _now;
|
||||
|
||||
@@ -69,7 +72,11 @@ class AnonymizedLlmContextBuilder {
|
||||
'Do not infer or ask for names.',
|
||||
'Return JSON only.',
|
||||
'Prefer practical suggestions that can be reviewed before saving.',
|
||||
'Use web search for current event recommendations and include source URLs.',
|
||||
'Only suggest real upcoming events with verified date and venue details.',
|
||||
'Do not repeat priorSuggestions for the same personToken.',
|
||||
'When shared_message_datetime is present, treat it as the point-in-time reference for that evidence.',
|
||||
'Do not turn old health/status evidence into a current check-in; preserve durable preferences instead.',
|
||||
],
|
||||
'limits': <String, dynamic>{
|
||||
'maxItems': 10,
|
||||
@@ -188,10 +195,81 @@ class AnonymizedLlmContextBuilder {
|
||||
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
|
||||
.toList(growable: false),
|
||||
).take(maxSignalsPerPerson).toList(growable: false),
|
||||
'preferenceSignals': _preferenceSignals(signals, now),
|
||||
'capturedFacts': _capturedFacts(state, person, now),
|
||||
'priorSuggestions': _priorSuggestions(state, person),
|
||||
};
|
||||
}
|
||||
|
||||
List<Map<String, String>> _capturedFacts(
|
||||
LocalDataState state,
|
||||
PersonProfile person,
|
||||
DateTime now,
|
||||
) {
|
||||
final List<String> identityTerms = _identityTerms(state);
|
||||
final Map<String, DateTime> sharedMessageTimes = _sharedMessageTimes(state);
|
||||
return state.personFacts
|
||||
.where(
|
||||
(PersonFact fact) =>
|
||||
fact.personId == person.id &&
|
||||
!fact.isSensitive &&
|
||||
!fact.needsReview &&
|
||||
fact.text.trim().isNotEmpty,
|
||||
)
|
||||
.take(maxFactsPerPerson)
|
||||
.map((PersonFact fact) {
|
||||
final DateTime? sharedMessageDateTime = fact.sharedMessageId == null
|
||||
? null
|
||||
: sharedMessageTimes[fact.sharedMessageId];
|
||||
return <String, String>{
|
||||
'type': _factTypeLabel(fact.type),
|
||||
if (_safeContextText(fact.label, identityTerms).isNotEmpty)
|
||||
'label': _safeContextText(fact.label, identityTerms),
|
||||
'summary': _safeContextText(fact.text, identityTerms),
|
||||
if (sharedMessageDateTime != null) ...<String, String>{
|
||||
'shared_message_datetime': sharedMessageDateTime
|
||||
.toUtc()
|
||||
.toIso8601String(),
|
||||
'sourceAge': _ageLabel(sharedMessageDateTime, now),
|
||||
},
|
||||
};
|
||||
})
|
||||
.where(
|
||||
(Map<String, String> fact) =>
|
||||
fact['summary'] != null && fact['summary']!.isNotEmpty,
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<Map<String, String>> _preferenceSignals(
|
||||
List<PersonPreferenceSignal> signals,
|
||||
DateTime now,
|
||||
) {
|
||||
return signals
|
||||
.map(
|
||||
(PersonPreferenceSignal signal) => <String, String>{
|
||||
'label': _safeCategory(signal.label),
|
||||
'category': _safeCategory(signal.category),
|
||||
'polarity': signal.polarity == PreferenceSignalPolarity.dislike
|
||||
? 'dislike'
|
||||
: 'like',
|
||||
'lastObserved': signal.lastSeenAt.toUtc().toIso8601String(),
|
||||
'sourceAge': _ageLabel(signal.lastSeenAt, now),
|
||||
},
|
||||
)
|
||||
.where((Map<String, String> signal) => signal['label']!.isNotEmpty)
|
||||
.take(maxSignalsPerPerson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Map<String, DateTime> _sharedMessageTimes(LocalDataState state) {
|
||||
return <String, DateTime>{
|
||||
for (final SharedMessageEntry entry in state.sharedMessages)
|
||||
if (entry.sharedMessageDateTime != null)
|
||||
entry.id: entry.sharedMessageDateTime!,
|
||||
};
|
||||
}
|
||||
|
||||
List<Map<String, String>> _priorSuggestions(
|
||||
LocalDataState state,
|
||||
PersonProfile person,
|
||||
@@ -272,6 +350,18 @@ class AnonymizedLlmContextBuilder {
|
||||
return 'last contact about $days days ago';
|
||||
}
|
||||
|
||||
String _ageLabel(DateTime value, DateTime now) {
|
||||
final int days = now.difference(value).inDays;
|
||||
if (days <= 1) {
|
||||
return 'current or recent';
|
||||
}
|
||||
if (days < 45) {
|
||||
return 'about $days days ago';
|
||||
}
|
||||
final int months = (days / 30).round().clamp(2, 24).toInt();
|
||||
return 'about $months months ago';
|
||||
}
|
||||
|
||||
List<String> _safeList(Iterable<String> values) {
|
||||
final List<String> out = <String>[];
|
||||
final Set<String> seen = <String>{};
|
||||
@@ -297,6 +387,58 @@ class AnonymizedLlmContextBuilder {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
String _safeContextText(String? value, List<String> identityTerms) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
String safe = value.trim().replaceAll(RegExp(r'https?://\S+'), '');
|
||||
for (final String term in identityTerms) {
|
||||
safe = safe.replaceAll(
|
||||
RegExp(RegExp.escape(term), caseSensitive: false),
|
||||
'this person',
|
||||
);
|
||||
}
|
||||
return safe
|
||||
.replaceAll(RegExp(r'[\r\n\t]+'), ' ')
|
||||
.replaceAll(RegExp(r'[^a-zA-Z0-9 .,;:!?+&%$#@()\-/]+'), '')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
List<String> _identityTerms(LocalDataState state) {
|
||||
final Set<String> terms = <String>{};
|
||||
for (final PersonProfile person in state.people) {
|
||||
for (final String raw in <String>[person.name, ...person.aliases]) {
|
||||
final String term = raw.trim();
|
||||
if (term.length >= 3) {
|
||||
terms.add(term);
|
||||
}
|
||||
for (final String part in term.split(RegExp(r'\s+'))) {
|
||||
if (part.length >= 3) {
|
||||
terms.add(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
final List<String> sorted = terms.toList(growable: false)
|
||||
..sort((String a, String b) => b.length.compareTo(a.length));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
String _factTypeLabel(CapturedFactType type) {
|
||||
return switch (type) {
|
||||
CapturedFactType.note => 'note',
|
||||
CapturedFactType.like => 'like',
|
||||
CapturedFactType.dislike => 'dislike',
|
||||
CapturedFactType.importantDate => 'important date',
|
||||
CapturedFactType.giftIdea => 'gift idea',
|
||||
CapturedFactType.placeIdea => 'place idea',
|
||||
CapturedFactType.activityIdea => 'activity idea',
|
||||
CapturedFactType.misc => 'misc',
|
||||
};
|
||||
}
|
||||
|
||||
int _daysUntil(DateTime date, DateTime now) {
|
||||
return date.difference(DateTime(now.year, now.month, now.day)).inDays;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class CalendarEventLauncher {
|
||||
const CalendarEventLauncher();
|
||||
|
||||
Future<bool> addSuggestionToCalendar(AiSuggestionDraft draft) async {
|
||||
final Uri? uri = calendarUriForSuggestion(draft);
|
||||
if (uri == null) {
|
||||
return false;
|
||||
}
|
||||
if (await launchUrl(uri, mode: LaunchMode.externalApplication)) {
|
||||
return true;
|
||||
}
|
||||
return launchUrl(uri);
|
||||
}
|
||||
}
|
||||
|
||||
Uri? calendarUriForSuggestion(AiSuggestionDraft draft) {
|
||||
final DateTime? startsAt = draft.suggestedFor;
|
||||
if (draft.kind != AiSuggestionKind.eventIdea || startsAt == null) {
|
||||
return null;
|
||||
}
|
||||
final DateTime endsAt =
|
||||
draft.eventEndsAt ?? startsAt.add(const Duration(hours: 2));
|
||||
final String details = <String>[
|
||||
draft.details,
|
||||
if (draft.reason != null && draft.reason!.trim().isNotEmpty)
|
||||
'Why: ${draft.reason!.trim()}',
|
||||
if (draft.eventUrl != null && draft.eventUrl!.trim().isNotEmpty)
|
||||
draft.eventUrl!.trim(),
|
||||
if (draft.sourceUrls.isNotEmpty) 'Sources: ${draft.sourceUrls.join(', ')}',
|
||||
].where((String value) => value.trim().isNotEmpty).join('\n\n');
|
||||
|
||||
return Uri.https('calendar.google.com', '/calendar/render', <String, String>{
|
||||
'action': 'TEMPLATE',
|
||||
'text': draft.title,
|
||||
'dates': '${_calendarTimestamp(startsAt)}/${_calendarTimestamp(endsAt)}',
|
||||
if (details.isNotEmpty) 'details': details,
|
||||
if (draft.eventLocation != null && draft.eventLocation!.trim().isNotEmpty)
|
||||
'location': draft.eventLocation!.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
String _calendarTimestamp(DateTime value) {
|
||||
final DateTime utc = value.toUtc();
|
||||
return '${utc.year.toString().padLeft(4, '0')}'
|
||||
'${utc.month.toString().padLeft(2, '0')}'
|
||||
'${utc.day.toString().padLeft(2, '0')}T'
|
||||
'${utc.hour.toString().padLeft(2, '0')}'
|
||||
'${utc.minute.toString().padLeft(2, '0')}'
|
||||
'${utc.second.toString().padLeft(2, '0')}Z';
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:relationship_saver/app/data/relationship_repository.dart';
|
||||
import 'package:relationship_saver/app/state/local_data_state.dart';
|
||||
import 'package:relationship_saver/core/llm/llm_config.dart';
|
||||
import 'package:relationship_saver/core/llm/llm_service.dart';
|
||||
import 'package:relationship_saver/core/observability/sentry_init.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/ai_digest_response_parser.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart';
|
||||
@@ -11,6 +12,7 @@ import 'package:relationship_saver/features/ai_digest/application/llm_digest_tex
|
||||
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/data/llm_digest_run_store.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
const Duration _scheduledCooldown = Duration(days: 7);
|
||||
@@ -107,7 +109,8 @@ class LlmDigestOrchestrator {
|
||||
);
|
||||
}
|
||||
|
||||
if (!await _ref.read(llmDigestEnvironmentProvider).canRun(config)) {
|
||||
if (!bypassCooldown &&
|
||||
!await _ref.read(llmDigestEnvironmentProvider).canRun(config)) {
|
||||
return const LlmDigestRunResult(
|
||||
started: false,
|
||||
completed: false,
|
||||
@@ -143,12 +146,11 @@ class LlmDigestOrchestrator {
|
||||
}
|
||||
|
||||
final String sourceRunId = 'digest-${_uuid.v4()}';
|
||||
final String response = await _ref
|
||||
.read(llmDigestTextClientProvider)
|
||||
.complete(
|
||||
systemPrompt: _systemPrompt,
|
||||
userPrompt: context.toPromptJson(),
|
||||
);
|
||||
final String response = await _completeDigestPrompt(
|
||||
userPrompt: context.toPromptJson(),
|
||||
enableWebSearch: config.enableWebSearch,
|
||||
sourceRunId: sourceRunId,
|
||||
);
|
||||
final AiDigestParseResult parsed = const AiDigestResponseParser().parse(
|
||||
response: response,
|
||||
tokenToPersonId: context.tokenToPersonId,
|
||||
@@ -157,6 +159,18 @@ class LlmDigestOrchestrator {
|
||||
);
|
||||
|
||||
if (!parsed.success) {
|
||||
_ref
|
||||
.read(sentryInitProvider)
|
||||
.captureMessage(
|
||||
parsed.error ?? 'Invalid digest response.',
|
||||
level: SentryLevel.warning,
|
||||
area: 'llm_digest_parse',
|
||||
context: <String, dynamic>{
|
||||
'sourceRunId': sourceRunId,
|
||||
'responseChars': response.length,
|
||||
'eligiblePeople': context.tokenToPersonId.length,
|
||||
},
|
||||
);
|
||||
await _markFailure(parsed.error ?? 'Invalid digest response.');
|
||||
return LlmDigestRunResult(
|
||||
started: true,
|
||||
@@ -185,8 +199,19 @@ class LlmDigestOrchestrator {
|
||||
completed: true,
|
||||
createdDraftCount: parsed.drafts.length,
|
||||
);
|
||||
} catch (error) {
|
||||
} catch (error, stackTrace) {
|
||||
final String reason = _digestFailureReason(error);
|
||||
_ref
|
||||
.read(sentryInitProvider)
|
||||
.captureException(
|
||||
error,
|
||||
stackTrace: stackTrace,
|
||||
area: 'llm_digest',
|
||||
context: <String, dynamic>{
|
||||
'bypassCooldown': bypassCooldown,
|
||||
'notify': notify,
|
||||
},
|
||||
);
|
||||
await _markFailure(reason);
|
||||
return LlmDigestRunResult(
|
||||
started: true,
|
||||
@@ -196,6 +221,45 @@ class LlmDigestOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _completeDigestPrompt({
|
||||
required String userPrompt,
|
||||
required bool enableWebSearch,
|
||||
required String sourceRunId,
|
||||
}) async {
|
||||
try {
|
||||
return await _ref
|
||||
.read(llmDigestTextClientProvider)
|
||||
.complete(
|
||||
systemPrompt: _systemPrompt(enableWebSearch: enableWebSearch),
|
||||
userPrompt: userPrompt,
|
||||
enableWebSearch: enableWebSearch,
|
||||
);
|
||||
} on LlmProviderException catch (error) {
|
||||
if (!enableWebSearch || error.statusCode != 429) {
|
||||
rethrow;
|
||||
}
|
||||
_ref
|
||||
.read(sentryInitProvider)
|
||||
.captureMessage(
|
||||
'Grounded digest request hit provider quota; retrying without web search.',
|
||||
level: SentryLevel.warning,
|
||||
area: 'llm_digest_grounding_fallback',
|
||||
context: <String, dynamic>{
|
||||
'sourceRunId': sourceRunId,
|
||||
'provider': error.provider.name,
|
||||
'statusCode': error.statusCode,
|
||||
},
|
||||
);
|
||||
return _ref
|
||||
.read(llmDigestTextClientProvider)
|
||||
.complete(
|
||||
systemPrompt: _systemPrompt(enableWebSearch: false),
|
||||
userPrompt: userPrompt,
|
||||
enableWebSearch: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markFailure(String reason) async {
|
||||
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
|
||||
final LlmDigestRunState current = await store.read();
|
||||
@@ -241,7 +305,21 @@ String _digestFailureReason(Object error) {
|
||||
return '$error';
|
||||
}
|
||||
|
||||
const String _systemPrompt = '''
|
||||
String _systemPrompt({required bool enableWebSearch}) {
|
||||
final String groundingRules = enableWebSearch
|
||||
? '''
|
||||
Use web search for current event, venue, availability, price, and timing claims.
|
||||
Prefer official event or venue pages over aggregators. Only classify an item as
|
||||
eventIdea when there is a real upcoming event with a concrete date in the next
|
||||
60 days. Include sourceUrls for grounded suggestions and do not fabricate URLs.
|
||||
'''
|
||||
: '''
|
||||
Web search grounding is disabled or unavailable for this run. Do not make
|
||||
current availability, price, or venue claims. Avoid eventIdea suggestions unless
|
||||
the input already contains enough concrete event details and leave sourceUrls
|
||||
empty when no source URL was provided.
|
||||
''';
|
||||
return '''
|
||||
You create private relationship-maintenance digest suggestions.
|
||||
The input contains pseudonymous people only. Never ask for or invent names.
|
||||
Return exactly one JSON object:
|
||||
@@ -252,14 +330,24 @@ Return exactly one JSON object:
|
||||
"kind": "giftIdea" | "eventIdea" | "checkIn" | "reminder",
|
||||
"title": "short action title",
|
||||
"details": "practical details for the user to review",
|
||||
"suggestedTiming": "optional ISO-8601 date or null",
|
||||
"suggestedTiming": "optional ISO-8601 start date/time or null",
|
||||
"eventEndsAt": "optional ISO-8601 end date/time for eventIdea only, otherwise null",
|
||||
"eventLocation": "optional venue or city for eventIdea only, otherwise null",
|
||||
"eventUrl": "optional official event URL for eventIdea only, otherwise null",
|
||||
"sourceUrls": ["0-3 source URLs used to verify the suggestion"],
|
||||
"confidence": 0.0,
|
||||
"reason": "brief non-sensitive rationale"
|
||||
}
|
||||
]
|
||||
}
|
||||
Return at most 10 suggestions. Use only personToken values present in input.
|
||||
If an input fact includes shared_message_datetime, use that timestamp as the
|
||||
evidence date. A message from months ago about temporary health, travel, mood,
|
||||
or availability should not produce an immediate checkIn unless other recent
|
||||
evidence supports it; keep durable preferences, constraints, and gift ideas.
|
||||
$groundingRules
|
||||
''';
|
||||
}
|
||||
|
||||
final Provider<LlmDigestOrchestrator> llmDigestOrchestratorProvider =
|
||||
Provider<LlmDigestOrchestrator>((Ref ref) => LlmDigestOrchestrator(ref));
|
||||
|
||||
@@ -5,6 +5,7 @@ abstract interface class LlmDigestTextClient {
|
||||
Future<String> complete({
|
||||
required String systemPrompt,
|
||||
required String userPrompt,
|
||||
bool enableWebSearch = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,10 +18,12 @@ class ConfiguredLlmDigestTextClient implements LlmDigestTextClient {
|
||||
Future<String> complete({
|
||||
required String systemPrompt,
|
||||
required String userPrompt,
|
||||
bool enableWebSearch = false,
|
||||
}) {
|
||||
return _service.completeText(
|
||||
systemPrompt: systemPrompt,
|
||||
userPrompt: userPrompt,
|
||||
enableWebSearch: enableWebSearch,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const String _preferredWeekdayKey = 'llm_digest_preferred_weekday';
|
||||
const String _preferredHourKey = 'llm_digest_preferred_hour';
|
||||
const String _requireWifiKey = 'llm_digest_require_wifi';
|
||||
const String _requireChargingKey = 'llm_digest_require_charging';
|
||||
const String _enableWebSearchKey = 'llm_digest_enable_web_search';
|
||||
|
||||
class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
|
||||
Future<void>? _initializeFuture;
|
||||
@@ -29,6 +30,7 @@ class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
|
||||
preferredHour: prefs.getInt(_preferredHourKey) ?? 21,
|
||||
requireWifi: prefs.getBool(_requireWifiKey) ?? true,
|
||||
requireCharging: prefs.getBool(_requireChargingKey) ?? true,
|
||||
enableWebSearch: prefs.getBool(_enableWebSearchKey) ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +70,12 @@ class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
|
||||
requireCharging: requireCharging,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setWebSearchEnabled(bool enabled) async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_enableWebSearchKey, enabled);
|
||||
state = state.copyWith(enableWebSearch: enabled);
|
||||
}
|
||||
}
|
||||
|
||||
final NotifierProvider<LlmDigestConfigNotifier, LlmDigestConfigState>
|
||||
|
||||
@@ -21,6 +21,10 @@ class AiSuggestionDraft {
|
||||
required this.status,
|
||||
required this.sourceRunId,
|
||||
this.suggestedFor,
|
||||
this.eventEndsAt,
|
||||
this.eventLocation,
|
||||
this.eventUrl,
|
||||
this.sourceUrls = const <String>[],
|
||||
this.reason,
|
||||
});
|
||||
|
||||
@@ -31,6 +35,10 @@ class AiSuggestionDraft {
|
||||
final String details;
|
||||
final DateTime suggestedAt;
|
||||
final DateTime? suggestedFor;
|
||||
final DateTime? eventEndsAt;
|
||||
final String? eventLocation;
|
||||
final String? eventUrl;
|
||||
final List<String> sourceUrls;
|
||||
final double confidence;
|
||||
final AiSuggestionStatus status;
|
||||
final String sourceRunId;
|
||||
@@ -44,6 +52,10 @@ class AiSuggestionDraft {
|
||||
String? details,
|
||||
DateTime? suggestedAt,
|
||||
DateTime? suggestedFor,
|
||||
DateTime? eventEndsAt,
|
||||
String? eventLocation,
|
||||
String? eventUrl,
|
||||
List<String>? sourceUrls,
|
||||
double? confidence,
|
||||
AiSuggestionStatus? status,
|
||||
String? sourceRunId,
|
||||
@@ -57,6 +69,10 @@ class AiSuggestionDraft {
|
||||
details: details ?? this.details,
|
||||
suggestedAt: suggestedAt ?? this.suggestedAt,
|
||||
suggestedFor: suggestedFor ?? this.suggestedFor,
|
||||
eventEndsAt: eventEndsAt ?? this.eventEndsAt,
|
||||
eventLocation: eventLocation ?? this.eventLocation,
|
||||
eventUrl: eventUrl ?? this.eventUrl,
|
||||
sourceUrls: sourceUrls ?? this.sourceUrls,
|
||||
confidence: confidence ?? this.confidence,
|
||||
status: status ?? this.status,
|
||||
sourceRunId: sourceRunId ?? this.sourceRunId,
|
||||
@@ -73,6 +89,10 @@ class AiSuggestionDraft {
|
||||
'details': details,
|
||||
'suggestedAt': suggestedAt.toUtc().toIso8601String(),
|
||||
'suggestedFor': suggestedFor?.toUtc().toIso8601String(),
|
||||
'eventEndsAt': eventEndsAt?.toUtc().toIso8601String(),
|
||||
'eventLocation': eventLocation,
|
||||
'eventUrl': eventUrl,
|
||||
'sourceUrls': sourceUrls,
|
||||
'confidence': confidence,
|
||||
'status': status.name,
|
||||
'sourceRunId': sourceRunId,
|
||||
@@ -101,6 +121,14 @@ class AiSuggestionDraft {
|
||||
suggestedFor: json['suggestedFor'] == null
|
||||
? null
|
||||
: DateTime.parse(json['suggestedFor'] as String).toLocal(),
|
||||
eventEndsAt: json['eventEndsAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['eventEndsAt'] as String).toLocal(),
|
||||
eventLocation: json['eventLocation'] as String?,
|
||||
eventUrl: json['eventUrl'] as String?,
|
||||
sourceUrls: (json['sourceUrls'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map((dynamic value) => '$value')
|
||||
.toList(growable: false),
|
||||
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
|
||||
status: AiSuggestionStatus.values.firstWhere(
|
||||
(AiSuggestionStatus value) => value.name == statusName,
|
||||
@@ -178,6 +206,7 @@ class LlmDigestConfigState {
|
||||
this.preferredHour = 21,
|
||||
this.requireWifi = true,
|
||||
this.requireCharging = true,
|
||||
this.enableWebSearch = true,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
@@ -186,6 +215,7 @@ class LlmDigestConfigState {
|
||||
final int preferredHour;
|
||||
final bool requireWifi;
|
||||
final bool requireCharging;
|
||||
final bool enableWebSearch;
|
||||
|
||||
LlmDigestConfigState copyWith({
|
||||
bool? enabled,
|
||||
@@ -194,6 +224,7 @@ class LlmDigestConfigState {
|
||||
int? preferredHour,
|
||||
bool? requireWifi,
|
||||
bool? requireCharging,
|
||||
bool? enableWebSearch,
|
||||
}) {
|
||||
return LlmDigestConfigState(
|
||||
enabled: enabled ?? this.enabled,
|
||||
@@ -202,6 +233,7 @@ class LlmDigestConfigState {
|
||||
preferredHour: preferredHour ?? this.preferredHour,
|
||||
requireWifi: requireWifi ?? this.requireWifi,
|
||||
requireCharging: requireCharging ?? this.requireCharging,
|
||||
enableWebSearch: enableWebSearch ?? this.enableWebSearch,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/app/data/relationship_repository.dart';
|
||||
import 'package:relationship_saver/app/state/local_data_state.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/calendar_event_launcher.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||
import 'package:relationship_saver/features/people/domain/person_models.dart';
|
||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||
@@ -165,6 +166,13 @@ class _AiSuggestionCard extends StatelessWidget {
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
label: const Text('Accept'),
|
||||
),
|
||||
if (draft.kind == AiSuggestionKind.eventIdea &&
|
||||
draft.suggestedFor != null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _addToCalendar(context),
|
||||
icon: const Icon(Icons.calendar_month_rounded),
|
||||
label: const Text('Calendar'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onDismiss,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
@@ -177,6 +185,17 @@ class _AiSuggestionCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addToCalendar(BuildContext context) async {
|
||||
final bool launched = await const CalendarEventLauncher()
|
||||
.addSuggestionToCalendar(draft);
|
||||
if (!context.mounted || launched) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Could not open calendar.')));
|
||||
}
|
||||
|
||||
IconData _iconForKind(AiSuggestionKind kind) {
|
||||
return switch (kind) {
|
||||
AiSuggestionKind.giftIdea => Icons.card_giftcard_rounded,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/calendar_event_launcher.dart';
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||
@@ -22,6 +23,12 @@ class DashboardView extends ConsumerWidget {
|
||||
data: (LocalDataState data) {
|
||||
final DashboardSummary summary = data.summary();
|
||||
final List<DashboardTask> tasks = data.tasks;
|
||||
final DateTime now = DateTime.now();
|
||||
final List<AiSuggestionDraft> upcomingEventSuggestions =
|
||||
_upcomingEventSuggestions(data.aiSuggestionDrafts, now);
|
||||
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
|
||||
for (final PersonProfile person in data.people) person.id: person,
|
||||
};
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
@@ -42,12 +49,19 @@ class DashboardView extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'See the relationship graph first, then execute the highest-leverage next moves.',
|
||||
'Review incoming context, then act on time-sensitive opportunities.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_HomeQueueCard(
|
||||
unresolvedShares: data.sharedInbox,
|
||||
upcomingEvents: upcomingEventSuggestions,
|
||||
peopleById: peopleById,
|
||||
compact: compact,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_RelationshipGraphCard(people: data.people, compact: compact),
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
@@ -130,6 +144,293 @@ class DashboardView extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeQueueCard extends StatelessWidget {
|
||||
const _HomeQueueCard({
|
||||
required this.unresolvedShares,
|
||||
required this.upcomingEvents,
|
||||
required this.peopleById,
|
||||
required this.compact,
|
||||
});
|
||||
|
||||
final List<SharedInboxEntry> unresolvedShares;
|
||||
final List<AiSuggestionDraft> upcomingEvents;
|
||||
final Map<String, PersonProfile> peopleById;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<Widget> sections = <Widget>[
|
||||
_UnresolvedSharesPanel(entries: unresolvedShares),
|
||||
_UpcomingEventsPanel(events: upcomingEvents, peopleById: peopleById),
|
||||
];
|
||||
return compact
|
||||
? Column(
|
||||
children: sections
|
||||
.map(
|
||||
(Widget child) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: child,
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
)
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(child: sections[0]),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: sections[1]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnresolvedSharesPanel extends StatelessWidget {
|
||||
const _UnresolvedSharesPanel({required this.entries});
|
||||
|
||||
final List<SharedInboxEntry> entries;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<SharedInboxEntry> visibleEntries = entries
|
||||
.take(3)
|
||||
.toList(growable: false);
|
||||
return FrostedCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(Icons.inbox_rounded, color: AppTheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Unresolved Shares',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
_CountPill(count: entries.length),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (entries.isEmpty)
|
||||
Text(
|
||||
'Inbox is clear.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||
)
|
||||
else
|
||||
...visibleEntries.map(
|
||||
(SharedInboxEntry entry) => _ShareInboxPreview(entry: entry),
|
||||
),
|
||||
if (entries.length > visibleEntries.length)
|
||||
Text(
|
||||
'+${entries.length - visibleEntries.length} more in inbox',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareInboxPreview extends StatelessWidget {
|
||||
const _ShareInboxPreview({required this.entry});
|
||||
|
||||
final SharedInboxEntry entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String sender = entry.sourceDisplayName?.trim().isNotEmpty == true
|
||||
? entry.sourceDisplayName!.trim()
|
||||
: entry.sourceApp;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Icon(Icons.circle, size: 8, color: Color(0xFFFFA447)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
sender,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_compactShareText(entry.payload.rawText),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UpcomingEventsPanel extends StatelessWidget {
|
||||
const _UpcomingEventsPanel({required this.events, required this.peopleById});
|
||||
|
||||
final List<AiSuggestionDraft> events;
|
||||
final Map<String, PersonProfile> peopleById;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FrostedCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
Icons.event_available_rounded,
|
||||
color: Color(0xFF2B7FFF),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Upcoming Events',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
_CountPill(count: events.length),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (events.isEmpty)
|
||||
Text(
|
||||
'No grounded event suggestions in the next 30 days.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||
)
|
||||
else
|
||||
...events.map(
|
||||
(AiSuggestionDraft event) => _UpcomingEventPreview(
|
||||
draft: event,
|
||||
person: peopleById[event.personId],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UpcomingEventPreview extends StatelessWidget {
|
||||
const _UpcomingEventPreview({required this.draft, required this.person});
|
||||
|
||||
final AiSuggestionDraft draft;
|
||||
final PersonProfile? person;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5FAFB),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
draft.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${_shortDate(draft.suggestedFor!)} · ${person?.name ?? 'Unknown person'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
if (draft.eventLocation != null &&
|
||||
draft.eventLocation!.trim().isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
draft.eventLocation!.trim(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Add to calendar',
|
||||
onPressed: () => _addToCalendar(context),
|
||||
icon: const Icon(Icons.calendar_month_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addToCalendar(BuildContext context) async {
|
||||
final bool launched = await const CalendarEventLauncher()
|
||||
.addSuggestionToCalendar(draft);
|
||||
if (!context.mounted || launched) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Could not open calendar.')));
|
||||
}
|
||||
}
|
||||
|
||||
class _CountPill extends StatelessWidget {
|
||||
const _CountPill({required this.count});
|
||||
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEAF7FB),
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelLarge?.copyWith(color: AppTheme.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RelationshipGraphCard extends StatelessWidget {
|
||||
const _RelationshipGraphCard({required this.people, required this.compact});
|
||||
|
||||
@@ -2566,6 +2867,38 @@ String _shortName(String name) {
|
||||
return parts.first;
|
||||
}
|
||||
|
||||
List<AiSuggestionDraft> _upcomingEventSuggestions(
|
||||
List<AiSuggestionDraft> drafts,
|
||||
DateTime now,
|
||||
) {
|
||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||
final DateTime windowEnd = today.add(const Duration(days: 30));
|
||||
final List<AiSuggestionDraft> events =
|
||||
drafts
|
||||
.where((AiSuggestionDraft draft) {
|
||||
final DateTime? startsAt = draft.suggestedFor;
|
||||
return draft.kind == AiSuggestionKind.eventIdea &&
|
||||
draft.status == AiSuggestionStatus.pending &&
|
||||
startsAt != null &&
|
||||
!startsAt.isBefore(today) &&
|
||||
startsAt.isBefore(windowEnd);
|
||||
})
|
||||
.toList(growable: false)
|
||||
..sort(
|
||||
(AiSuggestionDraft a, AiSuggestionDraft b) =>
|
||||
a.suggestedFor!.compareTo(b.suggestedFor!),
|
||||
);
|
||||
return events.take(5).toList(growable: false);
|
||||
}
|
||||
|
||||
String _compactShareText(String value) {
|
||||
final String compact = value.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
if (compact.length <= 120) {
|
||||
return compact;
|
||||
}
|
||||
return '${compact.substring(0, 117).trim()}...';
|
||||
}
|
||||
|
||||
String _shortDate(DateTime value) {
|
||||
const List<String> weekdays = <String>[
|
||||
'Mon',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_config.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.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/llm_service.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.dart';
|
||||
@@ -114,7 +116,15 @@ class SettingsView extends ConsumerWidget {
|
||||
const SizedBox(height: 12),
|
||||
_SettingRow(
|
||||
compact: compact,
|
||||
label: 'AI network',
|
||||
label: 'AI grounding',
|
||||
value: digestConfig.enableWebSearch
|
||||
? 'Google Search enabled'
|
||||
: 'Model only',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SettingRow(
|
||||
compact: compact,
|
||||
label: 'Scheduled AI network',
|
||||
value:
|
||||
'${digestConfig.requireWifi ? 'Wi-Fi' : 'Any network'} + ${digestConfig.requireCharging ? 'charging' : 'battery allowed'}',
|
||||
),
|
||||
@@ -150,6 +160,19 @@ class SettingsView extends ConsumerWidget {
|
||||
: 'Enable Weekly AI Digest',
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _toggleWebSearch(context, ref),
|
||||
icon: Icon(
|
||||
digestConfig.enableWebSearch
|
||||
? Icons.public_off_rounded
|
||||
: Icons.public_rounded,
|
||||
),
|
||||
label: Text(
|
||||
digestConfig.enableWebSearch
|
||||
? 'Disable Web Grounding'
|
||||
: 'Enable Web Grounding',
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: ref.watch(llmConfigProvider).isConfigured
|
||||
? () => _runPrivateDigest(context, ref)
|
||||
@@ -319,6 +342,26 @@ class SettingsView extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleWebSearch(BuildContext context, WidgetRef ref) async {
|
||||
final LlmDigestConfigState current = ref.read(llmDigestConfigProvider);
|
||||
final bool nextEnabled = !current.enableWebSearch;
|
||||
await ref
|
||||
.read(llmDigestConfigProvider.notifier)
|
||||
.setWebSearchEnabled(nextEnabled);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
nextEnabled
|
||||
? 'Web grounding enabled for private digest.'
|
||||
: 'Web grounding disabled for private digest.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showLlmDebug(BuildContext context) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
@@ -891,8 +934,10 @@ class _LlmDebugDialog extends ConsumerStatefulWidget {
|
||||
class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
|
||||
late final TextEditingController _promptController;
|
||||
bool _running = false;
|
||||
bool _loadingEvents = true;
|
||||
String? _response;
|
||||
String? _error;
|
||||
List<LlmDiagnosticsEvent> _events = const <LlmDiagnosticsEvent>[];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -900,6 +945,7 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
|
||||
_promptController = TextEditingController(
|
||||
text: 'Reply with one short sentence confirming the connection works.',
|
||||
);
|
||||
_loadDiagnostics();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -958,6 +1004,44 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Recent diagnostics',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _events.isEmpty ? null : _copyDiagnostics,
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
label: const Text('Copy'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _events.isEmpty ? null : _clearDiagnostics,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
label: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_loadingEvents)
|
||||
const LinearProgressIndicator()
|
||||
else if (_events.isEmpty)
|
||||
Text(
|
||||
'No LLM diagnostics recorded yet.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
)
|
||||
else
|
||||
..._events
|
||||
.take(8)
|
||||
.map(
|
||||
(LlmDiagnosticsEvent event) =>
|
||||
_LlmDiagnosticsEventTile(event: event),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -976,6 +1060,40 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadDiagnostics() async {
|
||||
final List<LlmDiagnosticsEvent> events = await ref
|
||||
.read(llmDiagnosticsLogProvider)
|
||||
.read();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_events = events;
|
||||
_loadingEvents = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _copyDiagnostics() async {
|
||||
final LlmDiagnosticsLog log = ref.read(llmDiagnosticsLogProvider);
|
||||
await Clipboard.setData(ClipboardData(text: log.exportText(_events)));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('LLM diagnostics copied.')));
|
||||
}
|
||||
|
||||
Future<void> _clearDiagnostics() async {
|
||||
await ref.read(llmDiagnosticsLogProvider).clear();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_events = const <LlmDiagnosticsEvent>[];
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runDebugPrompt() async {
|
||||
setState(() {
|
||||
_running = true;
|
||||
@@ -996,6 +1114,7 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
|
||||
setState(() {
|
||||
_response = response;
|
||||
});
|
||||
await _loadDiagnostics();
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
@@ -1003,6 +1122,7 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
|
||||
setState(() {
|
||||
_error = error.toString();
|
||||
});
|
||||
await _loadDiagnostics();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -1013,6 +1133,46 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
class _LlmDiagnosticsEventTile extends StatelessWidget {
|
||||
const _LlmDiagnosticsEventTile({required this.event});
|
||||
|
||||
final LlmDiagnosticsEvent event;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'${event.stage} · ${event.at.toLocal().toIso8601String()}',
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(event.message),
|
||||
if (event.details != null &&
|
||||
event.details!.trim().isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 6),
|
||||
SelectableText(
|
||||
event.details!,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LlmDebugConnectionSummary extends StatelessWidget {
|
||||
const _LlmDebugConnectionSummary({required this.config});
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class SharedPayload {
|
||||
required this.createdAt,
|
||||
required this.receivedAt,
|
||||
required this.platform,
|
||||
this.sharedMessageDateTime,
|
||||
this.url,
|
||||
this.sourceDisplayName,
|
||||
this.sourceUserId,
|
||||
@@ -53,6 +54,7 @@ class SharedPayload {
|
||||
final String? url;
|
||||
final DateTime createdAt;
|
||||
final DateTime receivedAt;
|
||||
final DateTime? sharedMessageDateTime;
|
||||
final String platform;
|
||||
final String? sourceDisplayName;
|
||||
final String? sourceUserId;
|
||||
@@ -68,6 +70,7 @@ class SharedPayload {
|
||||
String? url,
|
||||
DateTime? createdAt,
|
||||
DateTime? receivedAt,
|
||||
DateTime? sharedMessageDateTime,
|
||||
String? platform,
|
||||
String? sourceDisplayName,
|
||||
String? sourceUserId,
|
||||
@@ -83,6 +86,8 @@ class SharedPayload {
|
||||
url: url ?? this.url,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
receivedAt: receivedAt ?? this.receivedAt,
|
||||
sharedMessageDateTime:
|
||||
sharedMessageDateTime ?? this.sharedMessageDateTime,
|
||||
platform: platform ?? this.platform,
|
||||
sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName,
|
||||
sourceUserId: sourceUserId ?? this.sourceUserId,
|
||||
@@ -101,6 +106,7 @@ class SharedPayload {
|
||||
'url': url,
|
||||
'createdAt': createdAt.toUtc().toIso8601String(),
|
||||
'receivedAt': receivedAt.toUtc().toIso8601String(),
|
||||
'sharedMessageDateTime': sharedMessageDateTime?.toUtc().toIso8601String(),
|
||||
'platform': platform,
|
||||
'sourceDisplayName': sourceDisplayName,
|
||||
'sourceUserId': sourceUserId,
|
||||
@@ -130,6 +136,9 @@ class SharedPayload {
|
||||
json['receivedAt'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
).toLocal(),
|
||||
sharedMessageDateTime: json['sharedMessageDateTime'] == null
|
||||
? null
|
||||
: DateTime.parse(json['sharedMessageDateTime'] as String).toLocal(),
|
||||
platform: json['platform'] as String? ?? 'unknown',
|
||||
sourceDisplayName: json['sourceDisplayName'] as String?,
|
||||
sourceUserId: json['sourceUserId'] as String?,
|
||||
@@ -244,7 +253,8 @@ class SharedMessageEntry {
|
||||
|
||||
String get sourceApp => payload.sourceApp;
|
||||
String get messageText => payload.rawText;
|
||||
DateTime get sharedAt => payload.createdAt;
|
||||
DateTime get sharedAt => payload.sharedMessageDateTime ?? payload.createdAt;
|
||||
DateTime? get sharedMessageDateTime => payload.sharedMessageDateTime;
|
||||
SharedPayloadType get payloadType => payload.payloadType;
|
||||
String? get url => payload.url;
|
||||
String? get sourceDisplayName => payload.sourceDisplayName;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/share_intake/domain/shared_message_datetime_extractor.dart';
|
||||
import 'package:relationship_saver/features/share_intake/domain/signal_share_parser.dart';
|
||||
import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart';
|
||||
|
||||
@@ -20,6 +21,8 @@ class SharePayloadParser {
|
||||
final DateTime now = DateTime.now();
|
||||
final String normalizedSourceApp = _normalizeSourceApp(sourceApp);
|
||||
final String trimmed = rawText.trim();
|
||||
final DateTime? sharedMessageDateTime =
|
||||
SharedMessageDateTimeExtractor.extract(trimmed, now: receivedAt ?? now);
|
||||
final _ParsedSourceMetadata? sourceMetadata = _parseSourceMetadata(
|
||||
sourceApp: normalizedSourceApp,
|
||||
rawText: trimmed,
|
||||
@@ -48,6 +51,7 @@ class SharePayloadParser {
|
||||
url: url,
|
||||
createdAt: createdAt ?? now,
|
||||
receivedAt: receivedAt ?? now,
|
||||
sharedMessageDateTime: sharedMessageDateTime,
|
||||
platform: platform,
|
||||
sourceDisplayName: sourceMetadata?.sourceDisplayName,
|
||||
sourceUserId: sourceMetadata?.sourceUserId,
|
||||
@@ -56,11 +60,14 @@ class SharePayloadParser {
|
||||
if (normalizedSourceApp != 'share_sheet')
|
||||
'source_app_$normalizedSourceApp',
|
||||
if (url != null) 'contains_url',
|
||||
if (sharedMessageDateTime != null) 'shared_message_datetime_detected',
|
||||
if (sourceMetadata?.sourceDisplayName case final String _)
|
||||
'sender_detected',
|
||||
],
|
||||
metadata: <String, String>{
|
||||
if (url case final String value) 'primaryUrl': value,
|
||||
if (sharedMessageDateTime case final DateTime value)
|
||||
'shared_message_datetime': value.toUtc().toIso8601String(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
/// Best-effort extraction of the original message timestamp from shared text.
|
||||
///
|
||||
/// This intentionally prefers source-message metadata shapes (chat export
|
||||
/// prefixes, bracketed timestamps, and explicit sent/received labels). It avoids
|
||||
/// treating arbitrary content dates like "birthday is May 20" as the time the
|
||||
/// message was received.
|
||||
class SharedMessageDateTimeExtractor {
|
||||
const SharedMessageDateTimeExtractor._();
|
||||
|
||||
static final RegExp _isoDateTimePattern = RegExp(
|
||||
r'(\d{4}-\d{1,2}-\d{1,2})(?:[tT\s]+(\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?)(?:\s*(Z|[+-]\d{2}:?\d{2}))?)?',
|
||||
);
|
||||
|
||||
static final RegExp _numericDatePattern = RegExp(
|
||||
r'(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})(?:[,\s]+(?:at\s+)?(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*(AM|PM|am|pm)?)?',
|
||||
);
|
||||
|
||||
static final RegExp _monthNameDatePattern = RegExp(
|
||||
r'(?:(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-zÀ-ž.]+)\s*,?\s*(\d{2,4})|([A-Za-zÀ-ž.]+)\s+(\d{1,2})(?:st|nd|rd|th)?[,]?\s*(\d{2,4}))(?:[,\s]+(?:at\s+)?(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*(AM|PM|am|pm)?)?',
|
||||
);
|
||||
|
||||
static final RegExp _sourcePrefixPattern = RegExp(
|
||||
r'^\s*(?:\[([^\]]{3,80})\]|([^-–—\n]{3,80}))\s*(?:[-–—]\s*)?([^:\n]{2,80}):\s+.+$',
|
||||
dotAll: true,
|
||||
);
|
||||
|
||||
static final RegExp _labelledLinePattern = RegExp(
|
||||
r'^\s*(?:sent|sent at|received|received at|date|datetime|timestamp|message date|message time)\s*[:=-]\s*(.+)$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static final RegExp _relativeLabelPattern = RegExp(
|
||||
r'\b(today|yesterday)\b(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))(?::(\d{2}))?\s*(AM|PM|am|pm)?',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static final Map<String, int> _months = <String, int>{
|
||||
'jan': 1,
|
||||
'january': 1,
|
||||
'januar': 1,
|
||||
'januari': 1,
|
||||
'janvier': 1,
|
||||
'feb': 2,
|
||||
'february': 2,
|
||||
'februar': 2,
|
||||
'februari': 2,
|
||||
'fevrier': 2,
|
||||
'février': 2,
|
||||
'mar': 3,
|
||||
'march': 3,
|
||||
'märz': 3,
|
||||
'marz': 3,
|
||||
'maerz': 3,
|
||||
'mars': 3,
|
||||
'apr': 4,
|
||||
'april': 4,
|
||||
'avr': 4,
|
||||
'avril': 4,
|
||||
'may': 5,
|
||||
'mai': 5,
|
||||
'maj': 5,
|
||||
'jun': 6,
|
||||
'june': 6,
|
||||
'juni': 6,
|
||||
'juin': 6,
|
||||
'jul': 7,
|
||||
'july': 7,
|
||||
'juli': 7,
|
||||
'juillet': 7,
|
||||
'aug': 8,
|
||||
'august': 8,
|
||||
'aout': 8,
|
||||
'août': 8,
|
||||
'sep': 9,
|
||||
'sept': 9,
|
||||
'september': 9,
|
||||
'septembre': 9,
|
||||
'okt': 10,
|
||||
'oct': 10,
|
||||
'october': 10,
|
||||
'oktober': 10,
|
||||
'octobre': 10,
|
||||
'nov': 11,
|
||||
'november': 11,
|
||||
'novembre': 11,
|
||||
'dec': 12,
|
||||
'dez': 12,
|
||||
'december': 12,
|
||||
'dezember': 12,
|
||||
'decembre': 12,
|
||||
'décembre': 12,
|
||||
};
|
||||
|
||||
static DateTime? extract(String rawText, {DateTime? now}) {
|
||||
try {
|
||||
return _extract(rawText, now: now ?? DateTime.now());
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static DateTime? _extract(String rawText, {required DateTime now}) {
|
||||
final String input = rawText.trim();
|
||||
if (input.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (final String candidate in _candidateFragments(input)) {
|
||||
final DateTime? parsed = _parseDateTime(candidate, now: now);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
final RegExpMatch? sourcePrefix = _sourcePrefixPattern.firstMatch(input);
|
||||
if (sourcePrefix != null) {
|
||||
for (final String? group in <String?>[
|
||||
sourcePrefix.group(1),
|
||||
sourcePrefix.group(2),
|
||||
]) {
|
||||
if (group == null) {
|
||||
continue;
|
||||
}
|
||||
final DateTime? parsed = _parseDateTime(group, now: now);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<String> _candidateFragments(String input) {
|
||||
final List<String> candidates = <String>[];
|
||||
final List<String> lines = input
|
||||
.split(RegExp(r'\r?\n'))
|
||||
.map((String line) => line.trim())
|
||||
.where((String line) => line.isNotEmpty)
|
||||
.take(6)
|
||||
.toList(growable: false);
|
||||
|
||||
for (final String line in lines) {
|
||||
final RegExpMatch? labelled = _labelledLinePattern.firstMatch(line);
|
||||
if (labelled != null) {
|
||||
candidates.add(labelled.group(1)!.trim());
|
||||
}
|
||||
|
||||
if (line.startsWith('[') && line.contains(']')) {
|
||||
final int end = line.indexOf(']');
|
||||
candidates.add(line.substring(1, end).trim());
|
||||
}
|
||||
|
||||
final int dashIndex = _firstSeparatorIndex(line);
|
||||
final int colonIndex = line.indexOf(':');
|
||||
if (dashIndex > 0 && dashIndex <= 80) {
|
||||
candidates.add(line.substring(0, dashIndex).trim());
|
||||
} else if (colonIndex > 0 && colonIndex <= 80) {
|
||||
candidates.add(line.substring(0, colonIndex).trim());
|
||||
}
|
||||
|
||||
candidates.add(line);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
static int _firstSeparatorIndex(String value) {
|
||||
final List<int> indexes = <int>[
|
||||
value.indexOf(' - '),
|
||||
value.indexOf(' – '),
|
||||
value.indexOf(' — '),
|
||||
].where((int index) => index >= 0).toList(growable: false);
|
||||
if (indexes.isEmpty) {
|
||||
return -1;
|
||||
}
|
||||
indexes.sort();
|
||||
return indexes.first;
|
||||
}
|
||||
|
||||
static DateTime? _parseDateTime(String raw, {required DateTime now}) {
|
||||
String value = raw
|
||||
.trim()
|
||||
.replaceFirst(RegExp(r'^[A-Za-z]{3,9},\s+'), '')
|
||||
.replaceAll(RegExp(r'\s+'), ' ');
|
||||
value = value.replaceAll(' um ', ' at ');
|
||||
value = value.replaceAll(' à ', ' at ');
|
||||
|
||||
final RegExpMatch? relative = _relativeLabelPattern.firstMatch(value);
|
||||
if (relative != null) {
|
||||
final DateTime date = relative.group(1)!.toLowerCase() == 'yesterday'
|
||||
? now.subtract(const Duration(days: 1))
|
||||
: now;
|
||||
return _dateTimeOrNull(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
_hour(relative.group(2), relative.group(5)),
|
||||
_intOrNull(relative.group(3)) ?? 0,
|
||||
_intOrNull(relative.group(4)) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
final RegExpMatch? iso = _isoDateTimePattern.firstMatch(value);
|
||||
if (iso != null && _looksLikeMetadataFragment(value, iso.group(0)!)) {
|
||||
final String date = iso.group(1)!;
|
||||
final String? time = iso.group(2);
|
||||
final String? zone = iso.group(3);
|
||||
final String isoValue = time == null
|
||||
? date
|
||||
: '${date}T$time${zone == null ? '' : _normalizeZone(zone)}';
|
||||
final DateTime? parsed = DateTime.tryParse(isoValue);
|
||||
if (parsed != null) {
|
||||
return parsed.isUtc ? parsed.toLocal() : parsed;
|
||||
}
|
||||
}
|
||||
|
||||
final RegExpMatch? numeric = _numericDatePattern.firstMatch(value);
|
||||
if (numeric != null &&
|
||||
_looksLikeMetadataFragment(value, numeric.group(0)!)) {
|
||||
final int first = int.parse(numeric.group(1)!);
|
||||
final int second = int.parse(numeric.group(2)!);
|
||||
final int year = _expandYear(int.parse(numeric.group(3)!));
|
||||
final bool preferMonthFirst = _preferMonthFirst(value);
|
||||
final int day;
|
||||
final int month;
|
||||
if (first > 12) {
|
||||
day = first;
|
||||
month = second;
|
||||
} else if (second > 12) {
|
||||
day = second;
|
||||
month = first;
|
||||
} else if (preferMonthFirst) {
|
||||
day = second;
|
||||
month = first;
|
||||
} else {
|
||||
day = first;
|
||||
month = second;
|
||||
}
|
||||
return _dateTimeOrNull(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
_hour(numeric.group(4), numeric.group(7)),
|
||||
_intOrNull(numeric.group(5)) ?? 0,
|
||||
_intOrNull(numeric.group(6)) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
final RegExpMatch? named = _monthNameDatePattern.firstMatch(value);
|
||||
if (named != null && _looksLikeMetadataFragment(value, named.group(0)!)) {
|
||||
final bool dayFirst = named.group(1) != null;
|
||||
final int day = int.parse(dayFirst ? named.group(1)! : named.group(5)!);
|
||||
final int? month = _monthNumber(
|
||||
dayFirst ? named.group(2)! : named.group(4)!,
|
||||
);
|
||||
if (month == null) {
|
||||
return null;
|
||||
}
|
||||
final int year = _expandYear(
|
||||
int.parse(dayFirst ? named.group(3)! : named.group(6)!),
|
||||
);
|
||||
return _dateTimeOrNull(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
_hour(named.group(7), named.group(10)),
|
||||
_intOrNull(named.group(8)) ?? 0,
|
||||
_intOrNull(named.group(9)) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _looksLikeMetadataFragment(String fragment, String match) {
|
||||
final String value = fragment.trim();
|
||||
if (value == match.trim()) {
|
||||
return true;
|
||||
}
|
||||
if (value.startsWith(match)) {
|
||||
final String tail = value.substring(match.length).trimLeft();
|
||||
return tail.isEmpty ||
|
||||
tail.startsWith('-') ||
|
||||
tail.startsWith('–') ||
|
||||
tail.startsWith('—') ||
|
||||
tail.startsWith(']') ||
|
||||
tail.startsWith(',') ||
|
||||
tail.startsWith(':') ||
|
||||
tail.toLowerCase().startsWith('at ');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static String _normalizeZone(String zone) {
|
||||
if (zone == 'Z') {
|
||||
return zone;
|
||||
}
|
||||
if (RegExp(r'^[+-]\d{4}$').hasMatch(zone)) {
|
||||
return '${zone.substring(0, 3)}:${zone.substring(3)}';
|
||||
}
|
||||
return zone;
|
||||
}
|
||||
|
||||
static bool _preferMonthFirst(String value) {
|
||||
final String lower = value.toLowerCase();
|
||||
return lower.contains('am') || lower.contains('pm');
|
||||
}
|
||||
|
||||
static int _expandYear(int year) {
|
||||
if (year >= 100) {
|
||||
return year;
|
||||
}
|
||||
return year >= 70 ? 1900 + year : 2000 + year;
|
||||
}
|
||||
|
||||
static int? _monthNumber(String raw) {
|
||||
final String key = raw
|
||||
.toLowerCase()
|
||||
.replaceAll('.', '')
|
||||
.replaceAll('ä', 'a')
|
||||
.replaceAll('é', 'e')
|
||||
.replaceAll('è', 'e')
|
||||
.replaceAll('û', 'u')
|
||||
.trim();
|
||||
return _months[key] ?? _months[raw.toLowerCase().replaceAll('.', '')];
|
||||
}
|
||||
|
||||
static int _hour(String? rawHour, String? meridiem) {
|
||||
if (rawHour == null) {
|
||||
return 0;
|
||||
}
|
||||
int hour = int.parse(rawHour);
|
||||
final String? marker = meridiem?.toLowerCase();
|
||||
if (marker == 'pm' && hour < 12) {
|
||||
hour += 12;
|
||||
}
|
||||
if (marker == 'am' && hour == 12) {
|
||||
hour = 0;
|
||||
}
|
||||
return hour;
|
||||
}
|
||||
|
||||
static int? _intOrNull(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return int.tryParse(value);
|
||||
}
|
||||
|
||||
static DateTime? _dateTimeOrNull(
|
||||
int year,
|
||||
int month,
|
||||
int day,
|
||||
int hour,
|
||||
int minute,
|
||||
int second,
|
||||
) {
|
||||
if (month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31 ||
|
||||
hour < 0 ||
|
||||
hour > 23 ||
|
||||
minute < 0 ||
|
||||
minute > 59 ||
|
||||
second < 0 ||
|
||||
second > 59) {
|
||||
return null;
|
||||
}
|
||||
final DateTime value = DateTime(year, month, day, hour, minute, second);
|
||||
if (value.year != year || value.month != month || value.day != day) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user