Add LLM digest setup and share timestamp context

This commit is contained in:
Rijad Zuzo
2026-05-19 19:17:35 +02:00
parent 9d2da0c600
commit 5d80af375e
42 changed files with 2878 additions and 39 deletions
@@ -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;
+30 -1
View File
@@ -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;
+118
View File
@@ -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
View File
@@ -4,6 +4,9 @@ import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/llm/captured_fact_draft_parser.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/core/llm/llm_diagnostics_log.dart';
import 'package:relationship_saver/core/llm/ollama/ollama_tool_chat_client.dart';
import 'package:relationship_saver/core/observability/sentry_init.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
@@ -100,6 +103,7 @@ Generate 3-5 helpful relationship signals for these people.''';
Future<String> completeText({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
final config = await _config;
final String? apiKey = await _ref
@@ -117,6 +121,7 @@ Generate 3-5 helpful relationship signals for these people.''';
model: config.model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
enableWebSearch: enableWebSearch,
);
}
@@ -147,6 +152,9 @@ Be conservative. If the content is ambiguous, prefer "note". Return JSON only.''
Sender: ${payload.sourceDisplayName ?? "unknown"}
Platform: ${payload.platform}
URL: ${payload.url ?? "none"}
shared_message_datetime: ${payload.sharedMessageDateTime?.toIso8601String() ?? "null"}
Import received at: ${payload.receivedAt.toIso8601String()}
If shared_message_datetime is present, use it as the point-in-time reference for interpreting the message. Old status or health updates should usually become historical context or durable preferences, not current check-in prompts.
Raw text:
${payload.rawText}''';
@@ -230,14 +238,30 @@ ${payload.rawText}''';
required String model,
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
final Dio dio = _createDio();
final Dio dio = provider == LlmProvider.ollama
? _createDio(receiveTimeout: const Duration(minutes: 5))
: _createDio();
final String resolvedBaseUrl = _normalizeBaseUrl(baseUrl);
await _recordDiagnostics(
'request_start',
'Starting ${provider.label} LLM request.',
details: _diagnosticsDetails(
provider: provider,
baseUrl: resolvedBaseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
),
);
try {
late final String content;
switch (provider) {
case LlmProvider.openai:
return _callOpenAI(
content = await _callOpenAI(
dio,
resolvedBaseUrl,
apiKey,
@@ -246,7 +270,7 @@ ${payload.rawText}''';
userPrompt,
);
case LlmProvider.anthropic:
return _callAnthropic(
content = await _callAnthropic(
dio,
resolvedBaseUrl,
apiKey,
@@ -255,24 +279,26 @@ ${payload.rawText}''';
userPrompt,
);
case LlmProvider.google:
return _callGoogleAI(
content = await _callGoogleAI(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
enableWebSearch: enableWebSearch,
);
case LlmProvider.ollama:
return _callOllama(
content = await _callOllama(
dio,
resolvedBaseUrl,
model,
systemPrompt,
userPrompt,
enableWebSearch: enableWebSearch,
);
case LlmProvider.openaiCompatible:
return _callOpenAI(
content = await _callOpenAI(
dio,
resolvedBaseUrl,
apiKey,
@@ -281,11 +307,88 @@ ${payload.rawText}''';
userPrompt,
);
}
await _recordDiagnostics(
'request_success',
'${provider.label} returned a response.',
details: _diagnosticsDetails(
provider: provider,
baseUrl: resolvedBaseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
responseLength: content.length,
),
);
return content;
} on DioException catch (error) {
await _recordProviderFailure(
provider: provider,
baseUrl: resolvedBaseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
error: error,
);
throw _mapProviderError(provider, error);
}
}
Future<void> _recordDiagnostics(
String stage,
String message, {
String? details,
}) async {
try {
await _ref
.read(llmDiagnosticsLogProvider)
.record(stage, message, details: details);
} catch (_) {
// Diagnostics must never break the user-facing LLM flow.
}
}
Future<void> _recordProviderFailure({
required LlmProvider provider,
required String baseUrl,
required String model,
required bool enableWebSearch,
required String systemPrompt,
required String userPrompt,
required DioException error,
}) {
final int? statusCode = error.response?.statusCode;
final String status = statusCode == null
? 'without HTTP status'
: '$statusCode';
final Map<String, dynamic> context = _diagnosticsMap(
provider: provider,
baseUrl: baseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
dioType: error.type.name,
statusCode: statusCode,
providerMessage: _extractProviderMessage(error.response?.data),
responseBody: _safeJson(error.response?.data),
);
_ref
.read(sentryInitProvider)
.captureException(
error,
stackTrace: error.stackTrace,
area: 'llm_provider',
context: context,
);
return _recordDiagnostics(
'provider_error',
'${provider.label} request failed with $status.',
details: const JsonEncoder.withIndent(' ').convert(context),
);
}
Future<String> _callOpenAI(
Dio dio,
String baseUrl,
@@ -364,8 +467,9 @@ ${payload.rawText}''';
String? apiKey,
String model,
String systemPrompt,
String userPrompt,
) async {
String userPrompt, {
bool enableWebSearch = false,
}) async {
final String modelPath = model.startsWith('models/')
? model
: 'models/$model';
@@ -387,6 +491,10 @@ ${payload.rawText}''';
],
},
],
if (enableWebSearch)
'tools': [
{'google_search': <String, dynamic>{}},
],
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
}),
);
@@ -409,8 +517,20 @@ ${payload.rawText}''';
String baseUrl,
String model,
String systemPrompt,
String userPrompt,
) async {
String userPrompt, {
bool enableWebSearch = false,
}) async {
if (enableWebSearch) {
return _ref
.read(ollamaToolChatClientProvider)
.complete(
baseUrl: baseUrl,
model: model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
}
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'$baseUrl/api/chat',
@@ -609,6 +729,97 @@ LlmProviderException _mapProviderError(
);
}
String _diagnosticsDetails({
required LlmProvider provider,
required String baseUrl,
required String model,
required bool enableWebSearch,
required String systemPrompt,
required String userPrompt,
int? responseLength,
String? dioType,
int? statusCode,
String? providerMessage,
String? responseBody,
}) {
return const JsonEncoder.withIndent(' ').convert(
_diagnosticsMap(
provider: provider,
baseUrl: baseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
responseLength: responseLength,
dioType: dioType,
statusCode: statusCode,
providerMessage: providerMessage,
responseBody: responseBody,
),
);
}
Map<String, dynamic> _diagnosticsMap({
required LlmProvider provider,
required String baseUrl,
required String model,
required bool enableWebSearch,
required String systemPrompt,
required String userPrompt,
int? responseLength,
String? dioType,
int? statusCode,
String? providerMessage,
String? responseBody,
}) {
final Map<String, dynamic> diagnostics = <String, dynamic>{
'provider': provider.name,
'baseUrl': baseUrl,
'model': model,
'webSearchRequested': enableWebSearch,
'systemPromptChars': systemPrompt.length,
'userPromptChars': userPrompt.length,
};
if (responseLength != null) {
diagnostics['responseChars'] = responseLength;
}
if (dioType != null) {
diagnostics['dioType'] = dioType;
}
if (statusCode != null) {
diagnostics['statusCode'] = statusCode;
}
if (providerMessage != null) {
diagnostics['providerMessage'] = providerMessage;
}
if (responseBody != null) {
diagnostics['responseBody'] = responseBody;
}
return diagnostics;
}
String? _safeJson(Object? data) {
if (data == null) {
return null;
}
try {
final String encoded = data is String
? data
: const JsonEncoder.withIndent(' ').convert(data);
return _compactDiagnosticValue(encoded);
} catch (_) {
return _compactDiagnosticValue('$data');
}
}
String _compactDiagnosticValue(String value) {
final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' ');
if (compacted.length <= 3000) {
return compacted;
}
return '${compacted.substring(0, 2997)}...';
}
String _normalizeBaseUrl(String baseUrl) {
String normalized = baseUrl.trim();
while (normalized.endsWith('/')) {
@@ -617,11 +828,11 @@ String _normalizeBaseUrl(String baseUrl) {
return normalized;
}
Dio _createDio() {
Dio _createDio({Duration receiveTimeout = const Duration(seconds: 20)}) {
return Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 20),
receiveTimeout: receiveTimeout,
sendTimeout: const Duration(seconds: 20),
),
);
@@ -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('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
}
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();
});
+73 -8
View File
@@ -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;
}
}