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
+11
View File
@@ -52,6 +52,9 @@ Notable build-time flags live in `lib/core/config/app_config.dart`:
- `BACKGROUND_SYNC_INTERVAL_SECONDS`
- `ENABLE_LOCAL_NOTIFICATIONS`
- `ENABLE_WHATSAPP_SHARE_INTAKE`
- `SENTRY_DSN` defaults to the Bugsink project DSN
- `SENTRY_ENVIRONMENT`
- `SENTRY_RELEASE`
## Run
@@ -75,6 +78,14 @@ flutter run \
--dart-define=BACKEND_BASE_URL=https://api.example.com
```
Build a release with explicit Bugsink/Sentry metadata:
```bash
flutter build apk --release \
--dart-define=SENTRY_ENVIRONMENT=production \
--dart-define=SENTRY_RELEASE=relationship_saver@1.0.0+1
```
Run tests:
```bash
+3 -1
View File
@@ -1,9 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="relationship_saver"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
+27
View File
@@ -14,6 +14,33 @@ Runtime override (for future settings screens):
AppConfig.overrideBackendBaseUrl('https://staging.example.com');
```
## Bugsink Error Tracking
Release builds initialize `sentry_flutter` with the project Bugsink DSN by
default. Bugsink is Sentry API compatible, so the app uses the normal Sentry
Flutter SDK.
The default DSN is:
```text
https://97fb0a56bc35440fba0ba139dc3b1ccc@bugs.zuzo.ch/2
```
Override metadata at build time when needed:
```bash
flutter build apk --release \
--dart-define=SENTRY_ENVIRONMENT=production \
--dart-define=SENTRY_RELEASE=relationship_saver@1.0.0+1
```
To point a build at a different Sentry-compatible project:
```bash
flutter build apk --release \
--dart-define=SENTRY_DSN=https://public-key@example.com/1
```
## Run With Fake Gateway
Use fake gateway for local/offline development:
+6
View File
@@ -21,6 +21,8 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- workmanager_apple (0.0.1):
- Flutter
@@ -34,6 +36,7 @@ DEPENDENCIES:
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
@@ -59,6 +62,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sentry_flutter/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
@@ -73,6 +78,7 @@ SPEC CHECKSUMS:
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 1959d098c91d8a792531a723c4a9d7e9f6a01e38
+7
View File
@@ -43,6 +43,13 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Relationship Saver can connect to your local Ollama server for private AI suggestions.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Relationship Saver can import shared media and links you send into the app.</string>
<key>UIApplicationSceneManifest</key>
@@ -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,24 +37,89 @@ 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;
};
}
}
@@ -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,11 +146,10 @@ class LlmDigestOrchestrator {
}
final String sourceRunId = 'digest-${_uuid.v4()}';
final String response = await _ref
.read(llmDigestTextClientProvider)
.complete(
systemPrompt: _systemPrompt,
final String response = await _completeDigestPrompt(
userPrompt: context.toPromptJson(),
enableWebSearch: config.enableWebSearch,
sourceRunId: sourceRunId,
);
final AiDigestParseResult parsed = const AiDigestResponseParser().parse(
response: response,
@@ -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;
}
}
@@ -8,6 +8,7 @@
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <sentry_flutter/sentry_flutter_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
@@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) sentry_flutter_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "SentryFlutterPlugin");
sentry_flutter_plugin_register_with_registrar(sentry_flutter_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}
+1
View File
@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
sentry_flutter
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -12,6 +12,7 @@ import flutter_secure_storage_darwin
import package_info_plus
import sentry_flutter
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin"))
@@ -21,4 +22,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
+51
View File
@@ -1,22 +1,73 @@
PODS:
- battery_plus (0.0.1):
- FlutterMacOS
- connectivity_plus (0.0.1):
- FlutterMacOS
- flutter_local_notifications (0.0.1):
- FlutterMacOS
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
- FlutterMacOS (1.0.0)
- package_info_plus (0.0.1):
- FlutterMacOS
- Sentry/HybridSDK (8.46.0)
- sentry_flutter (8.14.2):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.46.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES:
- battery_plus (from `Flutter/ephemeral/.symlinks/plugins/battery_plus/macos`)
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- FlutterMacOS (from `Flutter/ephemeral`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- sentry_flutter (from `Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
SPEC REPOS:
trunk:
- Sentry
EXTERNAL SOURCES:
battery_plus:
:path: Flutter/ephemeral/.symlinks/plugins/battery_plus/macos
connectivity_plus:
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
flutter_local_notifications:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
flutter_secure_storage_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin
FlutterMacOS:
:path: Flutter/ephemeral
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
sentry_flutter:
:path: Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS:
battery_plus: f51ad29136e025b714b96f7d096f44f604615da7
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
+64
View File
@@ -1029,6 +1029,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: "direct main"
description:
+1
View File
@@ -50,6 +50,7 @@ dependencies:
flutter_local_notifications: ^20.1.0
workmanager: ^0.9.0+3
sentry_flutter: ^8.14.2
url_launcher: ^6.3.2
dev_dependencies:
flutter_test:
@@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/llm/llm_diagnostics_log.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
});
test('records and exports LLM diagnostics', () async {
const LlmDiagnosticsLog log = LlmDiagnosticsLog();
await log.record(
'provider_error',
'Google AI request failed with 429.',
details: '{"statusCode":429,"responseBody":"quota"}',
);
final List<LlmDiagnosticsEvent> events = await log.read();
expect(events, hasLength(1));
expect(events.single.stage, 'provider_error');
expect(events.single.details, contains('statusCode'));
expect(log.exportText(events), contains('Google AI request failed'));
});
test('trims very large diagnostics before storing', () async {
const LlmDiagnosticsLog log = LlmDiagnosticsLog();
await log.record(
'provider_error',
'large',
details: List<String>.filled(9000, 'x').join(),
);
final List<LlmDiagnosticsEvent> events = await log.read();
expect(events.single.details, endsWith('[truncated]'));
expect(events.single.details!.length, lessThan(9000));
});
}
@@ -0,0 +1,125 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/llm/ollama/ollama_tool_chat_client.dart';
import 'package:relationship_saver/core/llm/ollama/ollama_web_search_client.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import '../../helpers/queue_http_client_adapter.dart';
void main() {
test(
'runs Ollama tool calls and feeds search results back to model',
() async {
final List<Map<String, dynamic>> requestBodies = <Map<String, dynamic>>[];
final QueueHttpClientAdapter adapter = QueueHttpClientAdapter(
<AdapterHandler>[
(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
requestBodies.add(
jsonDecode(options.data as String) as Map<String, dynamic>,
);
return jsonResponse(200, <String, dynamic>{
'message': <String, dynamic>{
'role': 'assistant',
'content': '',
'tool_calls': <Map<String, dynamic>>[
<String, dynamic>{
'function': <String, dynamic>{
'name': 'search_web',
'arguments': <String, dynamic>{
'query': 'Zurich jazz events May 2026',
'maxResults': 3,
},
},
},
],
},
});
},
(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
requestBodies.add(
jsonDecode(options.data as String) as Map<String, dynamic>,
);
return jsonResponse(200, <String, dynamic>{
'message': <String, dynamic>{
'role': 'assistant',
'content': '''
{
"suggestions": [
{
"personToken": "person_001",
"kind": "eventIdea",
"title": "Jazz night",
"details": "A grounded event suggestion.",
"confidence": 0.8
}
]
}
''',
},
});
},
],
);
final Dio dio = Dio();
dio.httpClientAdapter = adapter;
final OllamaToolChatClient client = OllamaToolChatClient(
dio: dio,
webSearchClient: const FakeWebSearchClient(),
);
final String response = await client.complete(
baseUrl: 'http://macbook.local:11434',
model: 'qwen3:8b',
systemPrompt: 'Return digest JSON.',
userPrompt: '{"people":[]}',
);
expect(response, contains(AiSuggestionKind.eventIdea.name));
expect(adapter.fetchCount, 2);
expect(requestBodies.first['tools'], isA<List<dynamic>>());
final List<dynamic> secondMessages =
requestBodies.last['messages'] as List<dynamic>;
final Map<String, dynamic> toolMessage = secondMessages
.whereType<Map<String, dynamic>>()
.singleWhere(
(Map<String, dynamic> message) => message['role'] == 'tool',
);
expect(toolMessage['tool_name'], 'search_web');
expect(toolMessage['content'], contains('moods.ch'));
},
);
}
class FakeWebSearchClient implements OllamaWebSearchClient {
const FakeWebSearchClient();
@override
Future<String> fetchPageText(String url) async {
return 'Fetched text for $url';
}
@override
Future<List<OllamaWebSearchResult>> search(
String query, {
int maxResults = 5,
}) async {
return const <OllamaWebSearchResult>[
OllamaWebSearchResult(
title: 'Moods Jazz Club',
url: 'https://www.moods.ch/',
snippet: 'Upcoming jazz concerts in Zurich.',
),
];
}
}
@@ -10,10 +10,14 @@ void main() {
"suggestions": [
{
"personToken": "person_001",
"kind": "giftIdea",
"kind": "eventIdea",
"title": "Coffee tasting set",
"details": "Pick a small local sampler.",
"suggestedTiming": "2026-05-20T18:00:00Z",
"eventEndsAt": "2026-05-20T20:00:00Z",
"eventLocation": "Local roaster hall",
"eventUrl": "https://example.com/events/coffee",
"sourceUrls": ["https://example.com/events/coffee"],
"confidence": 0.82,
"reason": "Matches known interests."
}
@@ -28,8 +32,13 @@ void main() {
expect(result.success, isTrue);
expect(result.drafts, hasLength(1));
expect(result.drafts.single.personId, 'p-1');
expect(result.drafts.single.kind, AiSuggestionKind.giftIdea);
expect(result.drafts.single.kind, AiSuggestionKind.eventIdea);
expect(result.drafts.single.status, AiSuggestionStatus.pending);
expect(result.drafts.single.eventLocation, 'Local roaster hall');
expect(result.drafts.single.eventUrl, 'https://example.com/events/coffee');
expect(result.drafts.single.sourceUrls, <String>[
'https://example.com/events/coffee',
]);
});
test('rejects unknown person tokens and malformed items', () {
@@ -53,6 +53,7 @@ void main() {
rawText: 'Raw private chat text',
createdAt: DateTime(2026, 5, 1),
receivedAt: DateTime(2026, 5, 1),
sharedMessageDateTime: DateTime.utc(2026, 2, 1, 10),
platform: 'ios',
sourceDisplayName: 'Alice Secret',
sourceUserId: 'wa:alice',
@@ -73,6 +74,27 @@ void main() {
updatedAt: DateTime(2026, 5, 1),
isSensitive: true,
),
PersonFact(
id: 'pf-2',
personId: 'p-1',
type: CapturedFactType.giftIdea,
text: 'Alice wants ceramic espresso cups',
label: 'Coffee gear',
sourceKind: CaptureSourceKind.shareInbox,
sharedMessageId: 'sm-1',
createdAt: DateTime(2026, 5, 2),
updatedAt: DateTime(2026, 5, 2),
),
PersonFact(
id: 'pf-3',
personId: 'p-1',
type: CapturedFactType.placeIdea,
text: 'Unreviewed ramen place',
sourceKind: CaptureSourceKind.shareSheet,
createdAt: DateTime(2026, 5, 3),
updatedAt: DateTime(2026, 5, 3),
needsReview: true,
),
],
aiSuggestionDrafts: <AiSuggestionDraft>[
AiSuggestionDraft(
@@ -104,6 +126,14 @@ void main() {
expect(prompt, isNot(contains('thread-secret')));
expect(prompt, isNot(contains('Raw private chat text')));
expect(prompt, isNot(contains('Secret medical detail')));
expect(prompt, contains('capturedFacts'));
expect(prompt, contains('shared_message_datetime'));
expect(prompt, contains('2026-02-01T10:00:00.000Z'));
expect(prompt, contains('about 3 months ago'));
expect(prompt, contains('ceramic espresso cups'));
expect(prompt, contains('coffee gear'));
expect(prompt, contains('this person wants ceramic espresso cups'));
expect(prompt, isNot(contains('unreviewed ramen place')));
expect(prompt, contains('priorSuggestions'));
expect(prompt, contains('jazz concert downtown'));
expect(prompt, contains('dismissed'));
@@ -0,0 +1,53 @@
import 'package:flutter_test/flutter_test.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';
void main() {
test('builds Google Calendar template URL for event suggestions', () {
final Uri? uri = calendarUriForSuggestion(
AiSuggestionDraft(
id: 'ai-1',
personId: 'p-1',
kind: AiSuggestionKind.eventIdea,
title: 'Coffee festival',
details: 'Visit the tasting session.',
suggestedAt: DateTime(2026, 5, 16),
suggestedFor: DateTime.utc(2026, 5, 20, 18),
eventEndsAt: DateTime.utc(2026, 5, 20, 20),
eventLocation: 'Local roaster hall',
eventUrl: 'https://example.com/events/coffee',
sourceUrls: const <String>['https://example.com/events/coffee'],
confidence: 0.8,
status: AiSuggestionStatus.pending,
sourceRunId: 'run-1',
),
);
expect(uri, isNotNull);
expect(uri!.host, 'calendar.google.com');
expect(uri.queryParameters['action'], 'TEMPLATE');
expect(uri.queryParameters['text'], 'Coffee festival');
expect(uri.queryParameters['dates'], '20260520T180000Z/20260520T200000Z');
expect(uri.queryParameters['location'], 'Local roaster hall');
expect(uri.queryParameters['details'], contains('Visit the tasting'));
expect(uri.queryParameters['details'], contains('https://example.com'));
});
test('does not build a calendar URL without event timing', () {
final Uri? uri = calendarUriForSuggestion(
AiSuggestionDraft(
id: 'ai-2',
personId: 'p-1',
kind: AiSuggestionKind.eventIdea,
title: 'Untimed event',
details: '',
suggestedAt: DateTime(2026, 5, 16),
confidence: 0.6,
status: AiSuggestionStatus.pending,
sourceRunId: 'run-1',
),
);
expect(uri, isNull);
});
}
@@ -71,6 +71,7 @@ void main() {
expect(result.completed, isTrue);
expect(result.createdDraftCount, 1);
expect(textClient.calls, 1);
expect(textClient.lastEnableWebSearch, isTrue);
expect(textClient.lastPrompt, isNot(contains(state.people.first.name)));
expect(state.aiSuggestionDrafts, hasLength(1));
expect(
@@ -120,6 +121,139 @@ void main() {
expect(textClient.calls, 0);
});
test('manual digest bypasses scheduled network policy', () async {
final FakeTextClient textClient = FakeTextClient(
response: '''
{
"suggestions": [
{
"personToken": "person_001",
"kind": "checkIn",
"title": "Send a quick note",
"details": "Ask how the week is going.",
"confidence": 0.7
}
]
}
''',
);
final ProviderContainer container = _createContainer(
textClient: textClient,
notifier: FakeNotifier(),
environment: const FakeEnvironment(canRunValue: false),
);
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Taylor Quinn',
relationship: 'Friend',
notes: '',
tags: const <String>['tea'],
);
final LlmDigestRunResult result = await container
.read(llmDigestOrchestratorProvider)
.runManualDigest();
expect(result.started, isTrue);
expect(result.completed, isTrue);
expect(textClient.calls, 1);
});
test('digest respects disabled web grounding setting', () async {
final FakeTextClient textClient = FakeTextClient(
response: '''
{
"suggestions": [
{
"personToken": "person_001",
"kind": "checkIn",
"title": "Send a quick note",
"details": "Ask how the week is going.",
"confidence": 0.7
}
]
}
''',
);
final ProviderContainer container = _createContainer(
textClient: textClient,
notifier: FakeNotifier(),
);
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Taylor Quinn',
relationship: 'Friend',
notes: '',
tags: const <String>['tea'],
);
await container
.read(llmDigestConfigProvider.notifier)
.setWebSearchEnabled(false);
final LlmDigestRunResult result = await container
.read(llmDigestOrchestratorProvider)
.runManualDigest();
expect(result.completed, isTrue);
expect(textClient.lastEnableWebSearch, isFalse);
});
test(
'digest retries without web grounding after grounded quota error',
() async {
final SequenceTextClient textClient = SequenceTextClient(<Object>[
const LlmProviderException(
provider: LlmProvider.google,
statusCode: 429,
message: 'Google AI rejected the digest request with 429 quota.',
),
'''
{
"suggestions": [
{
"personToken": "person_001",
"kind": "checkIn",
"title": "Send a quick note",
"details": "Ask how the week is going.",
"confidence": 0.7
}
]
}
''',
]);
final ProviderContainer container = _createContainer(
textClient: textClient,
notifier: FakeNotifier(),
);
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Taylor Quinn',
relationship: 'Friend',
notes: '',
tags: const <String>['tea'],
);
final LlmDigestRunResult result = await container
.read(llmDigestOrchestratorProvider)
.runManualDigest();
expect(result.completed, isTrue);
expect(textClient.enableWebSearchValues, <bool>[true, false]);
},
);
test('manual digest reports provider rate limits without Dio noise', () async {
final ThrowingTextClient textClient = ThrowingTextClient(
error: const LlmProviderException(
@@ -183,14 +317,17 @@ class FakeTextClient implements LlmDigestTextClient {
final String response;
int calls = 0;
String? lastPrompt;
bool? lastEnableWebSearch;
@override
Future<String> complete({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
calls += 1;
lastPrompt = userPrompt;
lastEnableWebSearch = enableWebSearch;
return response;
}
}
@@ -204,11 +341,35 @@ class ThrowingTextClient implements LlmDigestTextClient {
Future<String> complete({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
throw error;
}
}
class SequenceTextClient implements LlmDigestTextClient {
SequenceTextClient(this.results);
final List<Object> results;
final List<bool> enableWebSearchValues = <bool>[];
int _index = 0;
@override
Future<String> complete({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
enableWebSearchValues.add(enableWebSearch);
final Object result = results[_index];
_index += 1;
if (result is Exception) {
throw result;
}
return result as String;
}
}
class FakeNotifier implements AiDigestNotifier {
int? lastCount;
@@ -39,6 +39,33 @@ void main() {
expect(payload.rawText, 'loves cozy Italian restaurants');
});
test('stores source message datetime when WhatsApp text includes one', () {
final SharedPayload payload = SharePayloadParser.parse(
rawText: '[19/05/2026, 14:30] Ava Hart: ginger tea helped',
sourceApp: 'whatsapp',
platform: 'ios',
);
expect(payload.sourceDisplayName, 'Ava Hart');
expect(payload.rawText, 'ginger tea helped');
expect(payload.sharedMessageDateTime, DateTime(2026, 5, 19, 14, 30));
expect(payload.parsingHints, contains('shared_message_datetime_detected'));
expect(payload.metadata, contains('shared_message_datetime'));
});
test(
'does not store source message datetime for arbitrary content dates',
() {
final SharedPayload payload = SharePayloadParser.parse(
rawText: 'Ava birthday is on May 20 and she likes ginger tea.',
sourceApp: 'share_sheet',
platform: 'ios',
);
expect(payload.sharedMessageDateTime, isNull);
},
);
test('uses explicit signal source app hint and extracts sender', () {
final SharedPayload payload = SharePayloadParser.parse(
rawText: 'Marek: this concert looks promising',
@@ -0,0 +1,58 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/features/share_intake/domain/shared_message_datetime_extractor.dart';
void main() {
group('SharedMessageDateTimeExtractor', () {
test('extracts WhatsApp bracketed European timestamp', () {
final DateTime? value = SharedMessageDateTimeExtractor.extract(
'[19/05/2026, 14:30] Ava Hart: I was sick but ginger tea helped',
);
expect(value, DateTime(2026, 5, 19, 14, 30));
});
test('extracts exported chat prefix with US meridiem timestamp', () {
final DateTime? value = SharedMessageDateTimeExtractor.extract(
'5/19/26, 2:30 PM - Ava Hart: ginger tea helped',
);
expect(value, DateTime(2026, 5, 19, 14, 30));
});
test('extracts labelled month-name timestamp', () {
final DateTime? value = SharedMessageDateTimeExtractor.extract(
'Received at: 19 May 2026 at 14:30\nAva: ginger tea helped',
);
expect(value, DateTime(2026, 5, 19, 14, 30));
});
test(
'extracts relative labelled timestamp using provided reference date',
() {
final DateTime? value = SharedMessageDateTimeExtractor.extract(
'Yesterday at 21:05 - Ava Hart: ginger tea helped',
now: DateTime(2026, 5, 19, 9),
);
expect(value, DateTime(2026, 5, 18, 21, 5));
},
);
test('does not treat arbitrary content date as message timestamp', () {
final DateTime? value = SharedMessageDateTimeExtractor.extract(
'Ava birthday is on May 20 and she likes ginger tea.',
);
expect(value, isNull);
});
test('returns null instead of throwing for invalid dates', () {
final DateTime? value = SharedMessageDateTimeExtractor.extract(
'[99/99/2026, 25:99] Ava: impossible',
);
expect(value, isNull);
});
});
}
@@ -10,6 +10,7 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <sentry_flutter/sentry_flutter_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
BatteryPlusWindowsPluginRegisterWithRegistrar(
@@ -20,4 +21,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
SentryFlutterPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SentryFlutterPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
+1
View File
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
flutter_secure_storage_windows
sentry_flutter
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST