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