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`
|
- `BACKGROUND_SYNC_INTERVAL_SECONDS`
|
||||||
- `ENABLE_LOCAL_NOTIFICATIONS`
|
- `ENABLE_LOCAL_NOTIFICATIONS`
|
||||||
- `ENABLE_WHATSAPP_SHARE_INTAKE`
|
- `ENABLE_WHATSAPP_SHARE_INTAKE`
|
||||||
|
- `SENTRY_DSN` defaults to the Bugsink project DSN
|
||||||
|
- `SENTRY_ENVIRONMENT`
|
||||||
|
- `SENTRY_RELEASE`
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -75,6 +78,14 @@ flutter run \
|
|||||||
--dart-define=BACKEND_BASE_URL=https://api.example.com
|
--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:
|
Run tests:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<application
|
<application
|
||||||
android:label="relationship_saver"
|
android:label="relationship_saver"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -14,6 +14,33 @@ Runtime override (for future settings screens):
|
|||||||
AppConfig.overrideBackendBaseUrl('https://staging.example.com');
|
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
|
## Run With Fake Gateway
|
||||||
|
|
||||||
Use fake gateway for local/offline development:
|
Use fake gateway for local/offline development:
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ PODS:
|
|||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- url_launcher_ios (0.0.1):
|
||||||
|
- Flutter
|
||||||
- workmanager_apple (0.0.1):
|
- workmanager_apple (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ DEPENDENCIES:
|
|||||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- 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`)
|
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
@@ -59,6 +62,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/sentry_flutter/ios"
|
:path: ".symlinks/plugins/sentry_flutter/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
|
url_launcher_ios:
|
||||||
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
workmanager_apple:
|
workmanager_apple:
|
||||||
:path: ".symlinks/plugins/workmanager_apple/ios"
|
:path: ".symlinks/plugins/workmanager_apple/ios"
|
||||||
|
|
||||||
@@ -73,6 +78,7 @@ SPEC CHECKSUMS:
|
|||||||
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
||||||
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
||||||
|
|
||||||
PODFILE CHECKSUM: 1959d098c91d8a792531a723c4a9d7e9f6a01e38
|
PODFILE CHECKSUM: 1959d098c91d8a792531a723c4a9d7e9f6a01e38
|
||||||
|
|||||||
@@ -43,6 +43,13 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<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>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Relationship Saver can import shared media and links you send into the app.</string>
|
<string>Relationship Saver can import shared media and links you send into the app.</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ extension LocalRepositoryAiDigestOperations on LocalRepository {
|
|||||||
? IdeaType.gift
|
? IdeaType.gift
|
||||||
: IdeaType.event,
|
: IdeaType.event,
|
||||||
title: draft.title,
|
title: draft.title,
|
||||||
details: _aiDraftDetails(draft),
|
details: _aiIdeaDetails(draft),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
);
|
);
|
||||||
await _setState(
|
await _setState(
|
||||||
@@ -156,6 +156,18 @@ String _aiDraftDetails(AiSuggestionDraft draft) {
|
|||||||
return '${draft.details.trim()}$reason'.trim();
|
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> {
|
extension<T> on Iterable<T> {
|
||||||
T? get firstOrNull => isEmpty ? null : first;
|
T? get firstOrNull => isEmpty ? null : first;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ extension LocalRepositoryShareOperations on LocalRepository {
|
|||||||
rawText: input.messageText.trim(),
|
rawText: input.messageText.trim(),
|
||||||
createdAt: input.sharedAt ?? now,
|
createdAt: input.sharedAt ?? now,
|
||||||
receivedAt: now,
|
receivedAt: now,
|
||||||
|
sharedMessageDateTime: input.sharedAt,
|
||||||
platform: 'legacy',
|
platform: 'legacy',
|
||||||
sourceDisplayName: _trimToNull(input.sourceDisplayName),
|
sourceDisplayName: _trimToNull(input.sourceDisplayName),
|
||||||
sourceUserId: _trimToNull(input.sourceUserId),
|
sourceUserId: _trimToNull(input.sourceUserId),
|
||||||
@@ -390,7 +391,8 @@ extension LocalRepositoryShareOperations on LocalRepository {
|
|||||||
String? consumedInboxEntryId,
|
String? consumedInboxEntryId,
|
||||||
}) async {
|
}) async {
|
||||||
final String sourceApp = payload.sourceApp;
|
final String sourceApp = payload.sourceApp;
|
||||||
final DateTime sharedAt = payload.createdAt;
|
final DateTime sharedAt =
|
||||||
|
payload.sharedMessageDateTime ?? payload.createdAt;
|
||||||
final String? sourceUserId = payload.sourceUserId;
|
final String? sourceUserId = payload.sourceUserId;
|
||||||
final String? sourceThreadId = payload.sourceThreadId;
|
final String? sourceThreadId = payload.sourceThreadId;
|
||||||
final bool createdSourceLink = matchedLink == null;
|
final bool createdSourceLink = matchedLink == null;
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ class AppConfig {
|
|||||||
|
|
||||||
static const String _defaultSentryDsn = String.fromEnvironment(
|
static const String _defaultSentryDsn = String.fromEnvironment(
|
||||||
'SENTRY_DSN',
|
'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: '',
|
defaultValue: '',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -53,9 +64,27 @@ class AppConfig {
|
|||||||
defaultValue: 180,
|
defaultValue: 180,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Optional Sentry DSN for release crash/error reporting.
|
/// Sentry-compatible DSN for release crash/error reporting.
|
||||||
static String get sentryDsn => _defaultSentryDsn.trim();
|
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).
|
/// Runtime override for backend URL (e.g. local settings screen).
|
||||||
static void overrideBackendBaseUrl(String? baseUrl) {
|
static void overrideBackendBaseUrl(String? baseUrl) {
|
||||||
_baseUrlOverride = 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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:relationship_saver/core/llm/captured_fact_draft_parser.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_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/people/domain/person_models.dart';
|
||||||
import 'package:relationship_saver/features/share_intake/domain/share_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({
|
Future<String> completeText({
|
||||||
required String systemPrompt,
|
required String systemPrompt,
|
||||||
required String userPrompt,
|
required String userPrompt,
|
||||||
|
bool enableWebSearch = false,
|
||||||
}) async {
|
}) async {
|
||||||
final config = await _config;
|
final config = await _config;
|
||||||
final String? apiKey = await _ref
|
final String? apiKey = await _ref
|
||||||
@@ -117,6 +121,7 @@ Generate 3-5 helpful relationship signals for these people.''';
|
|||||||
model: config.model,
|
model: config.model,
|
||||||
systemPrompt: systemPrompt,
|
systemPrompt: systemPrompt,
|
||||||
userPrompt: userPrompt,
|
userPrompt: userPrompt,
|
||||||
|
enableWebSearch: enableWebSearch,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +152,9 @@ Be conservative. If the content is ambiguous, prefer "note". Return JSON only.''
|
|||||||
Sender: ${payload.sourceDisplayName ?? "unknown"}
|
Sender: ${payload.sourceDisplayName ?? "unknown"}
|
||||||
Platform: ${payload.platform}
|
Platform: ${payload.platform}
|
||||||
URL: ${payload.url ?? "none"}
|
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:
|
Raw text:
|
||||||
${payload.rawText}''';
|
${payload.rawText}''';
|
||||||
|
|
||||||
@@ -230,14 +238,30 @@ ${payload.rawText}''';
|
|||||||
required String model,
|
required String model,
|
||||||
required String systemPrompt,
|
required String systemPrompt,
|
||||||
required String userPrompt,
|
required String userPrompt,
|
||||||
|
bool enableWebSearch = false,
|
||||||
}) async {
|
}) async {
|
||||||
final Dio dio = _createDio();
|
final Dio dio = provider == LlmProvider.ollama
|
||||||
|
? _createDio(receiveTimeout: const Duration(minutes: 5))
|
||||||
|
: _createDio();
|
||||||
final String resolvedBaseUrl = _normalizeBaseUrl(baseUrl);
|
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 {
|
try {
|
||||||
|
late final String content;
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case LlmProvider.openai:
|
case LlmProvider.openai:
|
||||||
return _callOpenAI(
|
content = await _callOpenAI(
|
||||||
dio,
|
dio,
|
||||||
resolvedBaseUrl,
|
resolvedBaseUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -246,7 +270,7 @@ ${payload.rawText}''';
|
|||||||
userPrompt,
|
userPrompt,
|
||||||
);
|
);
|
||||||
case LlmProvider.anthropic:
|
case LlmProvider.anthropic:
|
||||||
return _callAnthropic(
|
content = await _callAnthropic(
|
||||||
dio,
|
dio,
|
||||||
resolvedBaseUrl,
|
resolvedBaseUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -255,24 +279,26 @@ ${payload.rawText}''';
|
|||||||
userPrompt,
|
userPrompt,
|
||||||
);
|
);
|
||||||
case LlmProvider.google:
|
case LlmProvider.google:
|
||||||
return _callGoogleAI(
|
content = await _callGoogleAI(
|
||||||
dio,
|
dio,
|
||||||
resolvedBaseUrl,
|
resolvedBaseUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
model,
|
model,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
userPrompt,
|
userPrompt,
|
||||||
|
enableWebSearch: enableWebSearch,
|
||||||
);
|
);
|
||||||
case LlmProvider.ollama:
|
case LlmProvider.ollama:
|
||||||
return _callOllama(
|
content = await _callOllama(
|
||||||
dio,
|
dio,
|
||||||
resolvedBaseUrl,
|
resolvedBaseUrl,
|
||||||
model,
|
model,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
userPrompt,
|
userPrompt,
|
||||||
|
enableWebSearch: enableWebSearch,
|
||||||
);
|
);
|
||||||
case LlmProvider.openaiCompatible:
|
case LlmProvider.openaiCompatible:
|
||||||
return _callOpenAI(
|
content = await _callOpenAI(
|
||||||
dio,
|
dio,
|
||||||
resolvedBaseUrl,
|
resolvedBaseUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -281,11 +307,88 @@ ${payload.rawText}''';
|
|||||||
userPrompt,
|
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) {
|
} on DioException catch (error) {
|
||||||
|
await _recordProviderFailure(
|
||||||
|
provider: provider,
|
||||||
|
baseUrl: resolvedBaseUrl,
|
||||||
|
model: model,
|
||||||
|
enableWebSearch: enableWebSearch,
|
||||||
|
systemPrompt: systemPrompt,
|
||||||
|
userPrompt: userPrompt,
|
||||||
|
error: error,
|
||||||
|
);
|
||||||
throw _mapProviderError(provider, 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(
|
Future<String> _callOpenAI(
|
||||||
Dio dio,
|
Dio dio,
|
||||||
String baseUrl,
|
String baseUrl,
|
||||||
@@ -364,8 +467,9 @@ ${payload.rawText}''';
|
|||||||
String? apiKey,
|
String? apiKey,
|
||||||
String model,
|
String model,
|
||||||
String systemPrompt,
|
String systemPrompt,
|
||||||
String userPrompt,
|
String userPrompt, {
|
||||||
) async {
|
bool enableWebSearch = false,
|
||||||
|
}) async {
|
||||||
final String modelPath = model.startsWith('models/')
|
final String modelPath = model.startsWith('models/')
|
||||||
? model
|
? model
|
||||||
: 'models/$model';
|
: 'models/$model';
|
||||||
@@ -387,6 +491,10 @@ ${payload.rawText}''';
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
if (enableWebSearch)
|
||||||
|
'tools': [
|
||||||
|
{'google_search': <String, dynamic>{}},
|
||||||
|
],
|
||||||
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
|
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -409,8 +517,20 @@ ${payload.rawText}''';
|
|||||||
String baseUrl,
|
String baseUrl,
|
||||||
String model,
|
String model,
|
||||||
String systemPrompt,
|
String systemPrompt,
|
||||||
String userPrompt,
|
String userPrompt, {
|
||||||
) async {
|
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
|
final Response<Map<String, dynamic>> response = await dio
|
||||||
.post<Map<String, dynamic>>(
|
.post<Map<String, dynamic>>(
|
||||||
'$baseUrl/api/chat',
|
'$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 _normalizeBaseUrl(String baseUrl) {
|
||||||
String normalized = baseUrl.trim();
|
String normalized = baseUrl.trim();
|
||||||
while (normalized.endsWith('/')) {
|
while (normalized.endsWith('/')) {
|
||||||
@@ -617,11 +828,11 @@ String _normalizeBaseUrl(String baseUrl) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
Dio _createDio() {
|
Dio _createDio({Duration receiveTimeout = const Duration(seconds: 20)}) {
|
||||||
return Dio(
|
return Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
connectTimeout: const Duration(seconds: 20),
|
connectTimeout: const Duration(seconds: 20),
|
||||||
receiveTimeout: const Duration(seconds: 20),
|
receiveTimeout: receiveTimeout,
|
||||||
sendTimeout: const Duration(seconds: 20),
|
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({
|
Future<void> _initializeInternal({
|
||||||
FutureOr<void> Function()? appRunner,
|
FutureOr<void> Function()? appRunner,
|
||||||
}) async {
|
}) async {
|
||||||
if (!kReleaseMode || AppConfig.sentryDsn.isEmpty) {
|
if (AppConfig.sentryDsn.isEmpty) {
|
||||||
if (appRunner != null) {
|
if (appRunner != null) {
|
||||||
await Future<void>.sync(appRunner);
|
await Future<void>.sync(appRunner);
|
||||||
}
|
}
|
||||||
@@ -37,25 +37,90 @@ class SentryInit {
|
|||||||
|
|
||||||
await SentryFlutter.init((options) {
|
await SentryFlutter.init((options) {
|
||||||
options.dsn = AppConfig.sentryDsn;
|
options.dsn = AppConfig.sentryDsn;
|
||||||
options.environment = AppConfig.useFakeBackend
|
options.environment = AppConfig.sentryEnvironment;
|
||||||
? 'development'
|
options.release = AppConfig.sentryRelease;
|
||||||
: 'production';
|
options.sendDefaultPii = false;
|
||||||
options.tracesSampleRate = 0.1;
|
options.tracesSampleRate = 0.1;
|
||||||
}, appRunner: appRunner);
|
}, appRunner: appRunner);
|
||||||
|
await Sentry.configureScope((scope) async {
|
||||||
|
await scope.setTag('service', 'relationship_saver');
|
||||||
|
await scope.setTag('build_mode', kReleaseMode ? 'release' : 'debug');
|
||||||
|
});
|
||||||
|
_installGlobalHandlers();
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void captureException(Object error, {StackTrace? stackTrace}) {
|
void captureException(
|
||||||
|
Object error, {
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
String? area,
|
||||||
|
Map<String, dynamic>? context,
|
||||||
|
}) {
|
||||||
if (_initialized) {
|
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) {
|
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) {
|
final sentryInitProvider = Provider<SentryInit>((Ref ref) {
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ class AiDigestResponseParser {
|
|||||||
suggestedFor:
|
suggestedFor:
|
||||||
_dateOrNull(item['suggestedFor']) ??
|
_dateOrNull(item['suggestedFor']) ??
|
||||||
_dateOrNull(item['suggestedTiming']),
|
_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']),
|
confidence: _confidence(item['confidence']),
|
||||||
status: AiSuggestionStatus.pending,
|
status: AiSuggestionStatus.pending,
|
||||||
sourceRunId: sourceRunId,
|
sourceRunId: sourceRunId,
|
||||||
@@ -158,6 +168,40 @@ class AiDigestResponseParser {
|
|||||||
return DateTime.tryParse(value)?.toLocal();
|
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) {
|
double _confidence(Object? raw) {
|
||||||
if (raw is num) {
|
if (raw is num) {
|
||||||
return raw.toDouble().clamp(0.0, 1.0).toDouble();
|
return raw.toDouble().clamp(0.0, 1.0).toDouble();
|
||||||
@@ -172,4 +216,12 @@ class AiDigestResponseParser {
|
|||||||
}
|
}
|
||||||
return trimmed.substring(0, maxChars).trim();
|
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/app/state/local_data_state.dart';
|
||||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.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/people/domain/person_models.dart';
|
||||||
|
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
|
||||||
|
|
||||||
class AnonymizedLlmDigestContext {
|
class AnonymizedLlmDigestContext {
|
||||||
const AnonymizedLlmDigestContext({
|
const AnonymizedLlmDigestContext({
|
||||||
@@ -23,12 +24,14 @@ class AnonymizedLlmContextBuilder {
|
|||||||
const AnonymizedLlmContextBuilder({
|
const AnonymizedLlmContextBuilder({
|
||||||
this.maxPeople = 20,
|
this.maxPeople = 20,
|
||||||
this.maxSignalsPerPerson = 8,
|
this.maxSignalsPerPerson = 8,
|
||||||
|
this.maxFactsPerPerson = 8,
|
||||||
this.maxPriorSuggestionsPerPerson = 6,
|
this.maxPriorSuggestionsPerPerson = 6,
|
||||||
DateTime Function()? now,
|
DateTime Function()? now,
|
||||||
}) : _now = now ?? DateTime.now;
|
}) : _now = now ?? DateTime.now;
|
||||||
|
|
||||||
final int maxPeople;
|
final int maxPeople;
|
||||||
final int maxSignalsPerPerson;
|
final int maxSignalsPerPerson;
|
||||||
|
final int maxFactsPerPerson;
|
||||||
final int maxPriorSuggestionsPerPerson;
|
final int maxPriorSuggestionsPerPerson;
|
||||||
final DateTime Function() _now;
|
final DateTime Function() _now;
|
||||||
|
|
||||||
@@ -69,7 +72,11 @@ class AnonymizedLlmContextBuilder {
|
|||||||
'Do not infer or ask for names.',
|
'Do not infer or ask for names.',
|
||||||
'Return JSON only.',
|
'Return JSON only.',
|
||||||
'Prefer practical suggestions that can be reviewed before saving.',
|
'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.',
|
'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>{
|
'limits': <String, dynamic>{
|
||||||
'maxItems': 10,
|
'maxItems': 10,
|
||||||
@@ -188,10 +195,81 @@ class AnonymizedLlmContextBuilder {
|
|||||||
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
|
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
).take(maxSignalsPerPerson).toList(growable: false),
|
).take(maxSignalsPerPerson).toList(growable: false),
|
||||||
|
'preferenceSignals': _preferenceSignals(signals, now),
|
||||||
|
'capturedFacts': _capturedFacts(state, person, now),
|
||||||
'priorSuggestions': _priorSuggestions(state, person),
|
'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(
|
List<Map<String, String>> _priorSuggestions(
|
||||||
LocalDataState state,
|
LocalDataState state,
|
||||||
PersonProfile person,
|
PersonProfile person,
|
||||||
@@ -272,6 +350,18 @@ class AnonymizedLlmContextBuilder {
|
|||||||
return 'last contact about $days days ago';
|
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) {
|
List<String> _safeList(Iterable<String> values) {
|
||||||
final List<String> out = <String>[];
|
final List<String> out = <String>[];
|
||||||
final Set<String> seen = <String>{};
|
final Set<String> seen = <String>{};
|
||||||
@@ -297,6 +387,58 @@ class AnonymizedLlmContextBuilder {
|
|||||||
.toLowerCase();
|
.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) {
|
int _daysUntil(DateTime date, DateTime now) {
|
||||||
return date.difference(DateTime(now.year, now.month, now.day)).inDays;
|
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/app/state/local_data_state.dart';
|
||||||
import 'package:relationship_saver/core/llm/llm_config.dart';
|
import 'package:relationship_saver/core/llm/llm_config.dart';
|
||||||
import 'package:relationship_saver/core/llm/llm_service.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_notifier.dart';
|
||||||
import 'package:relationship_saver/features/ai_digest/application/ai_digest_response_parser.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';
|
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_config.dart';
|
||||||
import 'package:relationship_saver/features/ai_digest/data/llm_digest_run_store.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:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||||
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
const Duration _scheduledCooldown = Duration(days: 7);
|
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(
|
return const LlmDigestRunResult(
|
||||||
started: false,
|
started: false,
|
||||||
completed: false,
|
completed: false,
|
||||||
@@ -143,12 +146,11 @@ class LlmDigestOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final String sourceRunId = 'digest-${_uuid.v4()}';
|
final String sourceRunId = 'digest-${_uuid.v4()}';
|
||||||
final String response = await _ref
|
final String response = await _completeDigestPrompt(
|
||||||
.read(llmDigestTextClientProvider)
|
userPrompt: context.toPromptJson(),
|
||||||
.complete(
|
enableWebSearch: config.enableWebSearch,
|
||||||
systemPrompt: _systemPrompt,
|
sourceRunId: sourceRunId,
|
||||||
userPrompt: context.toPromptJson(),
|
);
|
||||||
);
|
|
||||||
final AiDigestParseResult parsed = const AiDigestResponseParser().parse(
|
final AiDigestParseResult parsed = const AiDigestResponseParser().parse(
|
||||||
response: response,
|
response: response,
|
||||||
tokenToPersonId: context.tokenToPersonId,
|
tokenToPersonId: context.tokenToPersonId,
|
||||||
@@ -157,6 +159,18 @@ class LlmDigestOrchestrator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!parsed.success) {
|
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.');
|
await _markFailure(parsed.error ?? 'Invalid digest response.');
|
||||||
return LlmDigestRunResult(
|
return LlmDigestRunResult(
|
||||||
started: true,
|
started: true,
|
||||||
@@ -185,8 +199,19 @@ class LlmDigestOrchestrator {
|
|||||||
completed: true,
|
completed: true,
|
||||||
createdDraftCount: parsed.drafts.length,
|
createdDraftCount: parsed.drafts.length,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error, stackTrace) {
|
||||||
final String reason = _digestFailureReason(error);
|
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);
|
await _markFailure(reason);
|
||||||
return LlmDigestRunResult(
|
return LlmDigestRunResult(
|
||||||
started: true,
|
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 {
|
Future<void> _markFailure(String reason) async {
|
||||||
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
|
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
|
||||||
final LlmDigestRunState current = await store.read();
|
final LlmDigestRunState current = await store.read();
|
||||||
@@ -241,7 +305,21 @@ String _digestFailureReason(Object error) {
|
|||||||
return '$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.
|
You create private relationship-maintenance digest suggestions.
|
||||||
The input contains pseudonymous people only. Never ask for or invent names.
|
The input contains pseudonymous people only. Never ask for or invent names.
|
||||||
Return exactly one JSON object:
|
Return exactly one JSON object:
|
||||||
@@ -252,14 +330,24 @@ Return exactly one JSON object:
|
|||||||
"kind": "giftIdea" | "eventIdea" | "checkIn" | "reminder",
|
"kind": "giftIdea" | "eventIdea" | "checkIn" | "reminder",
|
||||||
"title": "short action title",
|
"title": "short action title",
|
||||||
"details": "practical details for the user to review",
|
"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,
|
"confidence": 0.0,
|
||||||
"reason": "brief non-sensitive rationale"
|
"reason": "brief non-sensitive rationale"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Return at most 10 suggestions. Use only personToken values present in input.
|
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 =
|
final Provider<LlmDigestOrchestrator> llmDigestOrchestratorProvider =
|
||||||
Provider<LlmDigestOrchestrator>((Ref ref) => LlmDigestOrchestrator(ref));
|
Provider<LlmDigestOrchestrator>((Ref ref) => LlmDigestOrchestrator(ref));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ abstract interface class LlmDigestTextClient {
|
|||||||
Future<String> complete({
|
Future<String> complete({
|
||||||
required String systemPrompt,
|
required String systemPrompt,
|
||||||
required String userPrompt,
|
required String userPrompt,
|
||||||
|
bool enableWebSearch = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,10 +18,12 @@ class ConfiguredLlmDigestTextClient implements LlmDigestTextClient {
|
|||||||
Future<String> complete({
|
Future<String> complete({
|
||||||
required String systemPrompt,
|
required String systemPrompt,
|
||||||
required String userPrompt,
|
required String userPrompt,
|
||||||
|
bool enableWebSearch = false,
|
||||||
}) {
|
}) {
|
||||||
return _service.completeText(
|
return _service.completeText(
|
||||||
systemPrompt: systemPrompt,
|
systemPrompt: systemPrompt,
|
||||||
userPrompt: userPrompt,
|
userPrompt: userPrompt,
|
||||||
|
enableWebSearch: enableWebSearch,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const String _preferredWeekdayKey = 'llm_digest_preferred_weekday';
|
|||||||
const String _preferredHourKey = 'llm_digest_preferred_hour';
|
const String _preferredHourKey = 'llm_digest_preferred_hour';
|
||||||
const String _requireWifiKey = 'llm_digest_require_wifi';
|
const String _requireWifiKey = 'llm_digest_require_wifi';
|
||||||
const String _requireChargingKey = 'llm_digest_require_charging';
|
const String _requireChargingKey = 'llm_digest_require_charging';
|
||||||
|
const String _enableWebSearchKey = 'llm_digest_enable_web_search';
|
||||||
|
|
||||||
class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
|
class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
|
||||||
Future<void>? _initializeFuture;
|
Future<void>? _initializeFuture;
|
||||||
@@ -29,6 +30,7 @@ class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
|
|||||||
preferredHour: prefs.getInt(_preferredHourKey) ?? 21,
|
preferredHour: prefs.getInt(_preferredHourKey) ?? 21,
|
||||||
requireWifi: prefs.getBool(_requireWifiKey) ?? true,
|
requireWifi: prefs.getBool(_requireWifiKey) ?? true,
|
||||||
requireCharging: prefs.getBool(_requireChargingKey) ?? true,
|
requireCharging: prefs.getBool(_requireChargingKey) ?? true,
|
||||||
|
enableWebSearch: prefs.getBool(_enableWebSearchKey) ?? true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +70,12 @@ class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
|
|||||||
requireCharging: requireCharging,
|
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>
|
final NotifierProvider<LlmDigestConfigNotifier, LlmDigestConfigState>
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ class AiSuggestionDraft {
|
|||||||
required this.status,
|
required this.status,
|
||||||
required this.sourceRunId,
|
required this.sourceRunId,
|
||||||
this.suggestedFor,
|
this.suggestedFor,
|
||||||
|
this.eventEndsAt,
|
||||||
|
this.eventLocation,
|
||||||
|
this.eventUrl,
|
||||||
|
this.sourceUrls = const <String>[],
|
||||||
this.reason,
|
this.reason,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,6 +35,10 @@ class AiSuggestionDraft {
|
|||||||
final String details;
|
final String details;
|
||||||
final DateTime suggestedAt;
|
final DateTime suggestedAt;
|
||||||
final DateTime? suggestedFor;
|
final DateTime? suggestedFor;
|
||||||
|
final DateTime? eventEndsAt;
|
||||||
|
final String? eventLocation;
|
||||||
|
final String? eventUrl;
|
||||||
|
final List<String> sourceUrls;
|
||||||
final double confidence;
|
final double confidence;
|
||||||
final AiSuggestionStatus status;
|
final AiSuggestionStatus status;
|
||||||
final String sourceRunId;
|
final String sourceRunId;
|
||||||
@@ -44,6 +52,10 @@ class AiSuggestionDraft {
|
|||||||
String? details,
|
String? details,
|
||||||
DateTime? suggestedAt,
|
DateTime? suggestedAt,
|
||||||
DateTime? suggestedFor,
|
DateTime? suggestedFor,
|
||||||
|
DateTime? eventEndsAt,
|
||||||
|
String? eventLocation,
|
||||||
|
String? eventUrl,
|
||||||
|
List<String>? sourceUrls,
|
||||||
double? confidence,
|
double? confidence,
|
||||||
AiSuggestionStatus? status,
|
AiSuggestionStatus? status,
|
||||||
String? sourceRunId,
|
String? sourceRunId,
|
||||||
@@ -57,6 +69,10 @@ class AiSuggestionDraft {
|
|||||||
details: details ?? this.details,
|
details: details ?? this.details,
|
||||||
suggestedAt: suggestedAt ?? this.suggestedAt,
|
suggestedAt: suggestedAt ?? this.suggestedAt,
|
||||||
suggestedFor: suggestedFor ?? this.suggestedFor,
|
suggestedFor: suggestedFor ?? this.suggestedFor,
|
||||||
|
eventEndsAt: eventEndsAt ?? this.eventEndsAt,
|
||||||
|
eventLocation: eventLocation ?? this.eventLocation,
|
||||||
|
eventUrl: eventUrl ?? this.eventUrl,
|
||||||
|
sourceUrls: sourceUrls ?? this.sourceUrls,
|
||||||
confidence: confidence ?? this.confidence,
|
confidence: confidence ?? this.confidence,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
sourceRunId: sourceRunId ?? this.sourceRunId,
|
sourceRunId: sourceRunId ?? this.sourceRunId,
|
||||||
@@ -73,6 +89,10 @@ class AiSuggestionDraft {
|
|||||||
'details': details,
|
'details': details,
|
||||||
'suggestedAt': suggestedAt.toUtc().toIso8601String(),
|
'suggestedAt': suggestedAt.toUtc().toIso8601String(),
|
||||||
'suggestedFor': suggestedFor?.toUtc().toIso8601String(),
|
'suggestedFor': suggestedFor?.toUtc().toIso8601String(),
|
||||||
|
'eventEndsAt': eventEndsAt?.toUtc().toIso8601String(),
|
||||||
|
'eventLocation': eventLocation,
|
||||||
|
'eventUrl': eventUrl,
|
||||||
|
'sourceUrls': sourceUrls,
|
||||||
'confidence': confidence,
|
'confidence': confidence,
|
||||||
'status': status.name,
|
'status': status.name,
|
||||||
'sourceRunId': sourceRunId,
|
'sourceRunId': sourceRunId,
|
||||||
@@ -101,6 +121,14 @@ class AiSuggestionDraft {
|
|||||||
suggestedFor: json['suggestedFor'] == null
|
suggestedFor: json['suggestedFor'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(json['suggestedFor'] as String).toLocal(),
|
: 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,
|
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
|
||||||
status: AiSuggestionStatus.values.firstWhere(
|
status: AiSuggestionStatus.values.firstWhere(
|
||||||
(AiSuggestionStatus value) => value.name == statusName,
|
(AiSuggestionStatus value) => value.name == statusName,
|
||||||
@@ -178,6 +206,7 @@ class LlmDigestConfigState {
|
|||||||
this.preferredHour = 21,
|
this.preferredHour = 21,
|
||||||
this.requireWifi = true,
|
this.requireWifi = true,
|
||||||
this.requireCharging = true,
|
this.requireCharging = true,
|
||||||
|
this.enableWebSearch = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
@@ -186,6 +215,7 @@ class LlmDigestConfigState {
|
|||||||
final int preferredHour;
|
final int preferredHour;
|
||||||
final bool requireWifi;
|
final bool requireWifi;
|
||||||
final bool requireCharging;
|
final bool requireCharging;
|
||||||
|
final bool enableWebSearch;
|
||||||
|
|
||||||
LlmDigestConfigState copyWith({
|
LlmDigestConfigState copyWith({
|
||||||
bool? enabled,
|
bool? enabled,
|
||||||
@@ -194,6 +224,7 @@ class LlmDigestConfigState {
|
|||||||
int? preferredHour,
|
int? preferredHour,
|
||||||
bool? requireWifi,
|
bool? requireWifi,
|
||||||
bool? requireCharging,
|
bool? requireCharging,
|
||||||
|
bool? enableWebSearch,
|
||||||
}) {
|
}) {
|
||||||
return LlmDigestConfigState(
|
return LlmDigestConfigState(
|
||||||
enabled: enabled ?? this.enabled,
|
enabled: enabled ?? this.enabled,
|
||||||
@@ -202,6 +233,7 @@ class LlmDigestConfigState {
|
|||||||
preferredHour: preferredHour ?? this.preferredHour,
|
preferredHour: preferredHour ?? this.preferredHour,
|
||||||
requireWifi: requireWifi ?? this.requireWifi,
|
requireWifi: requireWifi ?? this.requireWifi,
|
||||||
requireCharging: requireCharging ?? this.requireCharging,
|
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/data/relationship_repository.dart';
|
||||||
import 'package:relationship_saver/app/state/local_data_state.dart';
|
import 'package:relationship_saver/app/state/local_data_state.dart';
|
||||||
import 'package:relationship_saver/core/config/app_theme.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/ai_digest/domain/ai_digest_models.dart';
|
||||||
import 'package:relationship_saver/features/people/domain/person_models.dart';
|
import 'package:relationship_saver/features/people/domain/person_models.dart';
|
||||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||||
@@ -165,6 +166,13 @@ class _AiSuggestionCard extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.check_rounded),
|
icon: const Icon(Icons.check_rounded),
|
||||||
label: const Text('Accept'),
|
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(
|
OutlinedButton.icon(
|
||||||
onPressed: onDismiss,
|
onPressed: onDismiss,
|
||||||
icon: const Icon(Icons.close_rounded),
|
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) {
|
IconData _iconForKind(AiSuggestionKind kind) {
|
||||||
return switch (kind) {
|
return switch (kind) {
|
||||||
AiSuggestionKind.giftIdea => Icons.card_giftcard_rounded,
|
AiSuggestionKind.giftIdea => Icons.card_giftcard_rounded,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:math' as math;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:relationship_saver/core/config/app_theme.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_models.dart';
|
||||||
import 'package:relationship_saver/features/local/local_repository.dart';
|
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||||
@@ -22,6 +23,12 @@ class DashboardView extends ConsumerWidget {
|
|||||||
data: (LocalDataState data) {
|
data: (LocalDataState data) {
|
||||||
final DashboardSummary summary = data.summary();
|
final DashboardSummary summary = data.summary();
|
||||||
final List<DashboardTask> tasks = data.tasks;
|
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(
|
return LayoutBuilder(
|
||||||
builder: (BuildContext context, BoxConstraints constraints) {
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
@@ -42,12 +49,19 @@ class DashboardView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
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(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
|
_HomeQueueCard(
|
||||||
|
unresolvedShares: data.sharedInbox,
|
||||||
|
upcomingEvents: upcomingEventSuggestions,
|
||||||
|
peopleById: peopleById,
|
||||||
|
compact: compact,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
_RelationshipGraphCard(people: data.people, compact: compact),
|
_RelationshipGraphCard(people: data.people, compact: compact),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Wrap(
|
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 {
|
class _RelationshipGraphCard extends StatelessWidget {
|
||||||
const _RelationshipGraphCard({required this.people, required this.compact});
|
const _RelationshipGraphCard({required this.people, required this.compact});
|
||||||
|
|
||||||
@@ -2566,6 +2867,38 @@ String _shortName(String name) {
|
|||||||
return parts.first;
|
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) {
|
String _shortDate(DateTime value) {
|
||||||
const List<String> weekdays = <String>[
|
const List<String> weekdays = <String>[
|
||||||
'Mon',
|
'Mon',
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:relationship_saver/core/config/app_config.dart';
|
import 'package:relationship_saver/core/config/app_config.dart';
|
||||||
import 'package:relationship_saver/core/config/app_theme.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_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/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_background_scheduler.dart';
|
||||||
import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.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),
|
const SizedBox(height: 12),
|
||||||
_SettingRow(
|
_SettingRow(
|
||||||
compact: compact,
|
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:
|
value:
|
||||||
'${digestConfig.requireWifi ? 'Wi-Fi' : 'Any network'} + ${digestConfig.requireCharging ? 'charging' : 'battery allowed'}',
|
'${digestConfig.requireWifi ? 'Wi-Fi' : 'Any network'} + ${digestConfig.requireCharging ? 'charging' : 'battery allowed'}',
|
||||||
),
|
),
|
||||||
@@ -150,6 +160,19 @@ class SettingsView extends ConsumerWidget {
|
|||||||
: 'Enable Weekly AI Digest',
|
: '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(
|
OutlinedButton.icon(
|
||||||
onPressed: ref.watch(llmConfigProvider).isConfigured
|
onPressed: ref.watch(llmConfigProvider).isConfigured
|
||||||
? () => _runPrivateDigest(context, ref)
|
? () => _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 {
|
Future<void> _showLlmDebug(BuildContext context) async {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -891,8 +934,10 @@ class _LlmDebugDialog extends ConsumerStatefulWidget {
|
|||||||
class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
|
class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
|
||||||
late final TextEditingController _promptController;
|
late final TextEditingController _promptController;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
|
bool _loadingEvents = true;
|
||||||
String? _response;
|
String? _response;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
List<LlmDiagnosticsEvent> _events = const <LlmDiagnosticsEvent>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -900,6 +945,7 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
|
|||||||
_promptController = TextEditingController(
|
_promptController = TextEditingController(
|
||||||
text: 'Reply with one short sentence confirming the connection works.',
|
text: 'Reply with one short sentence confirming the connection works.',
|
||||||
);
|
);
|
||||||
|
_loadDiagnostics();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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 {
|
Future<void> _runDebugPrompt() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_running = true;
|
_running = true;
|
||||||
@@ -996,6 +1114,7 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_response = response;
|
_response = response;
|
||||||
});
|
});
|
||||||
|
await _loadDiagnostics();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@@ -1003,6 +1122,7 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_error = error.toString();
|
_error = error.toString();
|
||||||
});
|
});
|
||||||
|
await _loadDiagnostics();
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
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 {
|
class _LlmDebugConnectionSummary extends StatelessWidget {
|
||||||
const _LlmDebugConnectionSummary({required this.config});
|
const _LlmDebugConnectionSummary({required this.config});
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class SharedPayload {
|
|||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.receivedAt,
|
required this.receivedAt,
|
||||||
required this.platform,
|
required this.platform,
|
||||||
|
this.sharedMessageDateTime,
|
||||||
this.url,
|
this.url,
|
||||||
this.sourceDisplayName,
|
this.sourceDisplayName,
|
||||||
this.sourceUserId,
|
this.sourceUserId,
|
||||||
@@ -53,6 +54,7 @@ class SharedPayload {
|
|||||||
final String? url;
|
final String? url;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime receivedAt;
|
final DateTime receivedAt;
|
||||||
|
final DateTime? sharedMessageDateTime;
|
||||||
final String platform;
|
final String platform;
|
||||||
final String? sourceDisplayName;
|
final String? sourceDisplayName;
|
||||||
final String? sourceUserId;
|
final String? sourceUserId;
|
||||||
@@ -68,6 +70,7 @@ class SharedPayload {
|
|||||||
String? url,
|
String? url,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? receivedAt,
|
DateTime? receivedAt,
|
||||||
|
DateTime? sharedMessageDateTime,
|
||||||
String? platform,
|
String? platform,
|
||||||
String? sourceDisplayName,
|
String? sourceDisplayName,
|
||||||
String? sourceUserId,
|
String? sourceUserId,
|
||||||
@@ -83,6 +86,8 @@ class SharedPayload {
|
|||||||
url: url ?? this.url,
|
url: url ?? this.url,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
receivedAt: receivedAt ?? this.receivedAt,
|
receivedAt: receivedAt ?? this.receivedAt,
|
||||||
|
sharedMessageDateTime:
|
||||||
|
sharedMessageDateTime ?? this.sharedMessageDateTime,
|
||||||
platform: platform ?? this.platform,
|
platform: platform ?? this.platform,
|
||||||
sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName,
|
sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName,
|
||||||
sourceUserId: sourceUserId ?? this.sourceUserId,
|
sourceUserId: sourceUserId ?? this.sourceUserId,
|
||||||
@@ -101,6 +106,7 @@ class SharedPayload {
|
|||||||
'url': url,
|
'url': url,
|
||||||
'createdAt': createdAt.toUtc().toIso8601String(),
|
'createdAt': createdAt.toUtc().toIso8601String(),
|
||||||
'receivedAt': receivedAt.toUtc().toIso8601String(),
|
'receivedAt': receivedAt.toUtc().toIso8601String(),
|
||||||
|
'sharedMessageDateTime': sharedMessageDateTime?.toUtc().toIso8601String(),
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'sourceDisplayName': sourceDisplayName,
|
'sourceDisplayName': sourceDisplayName,
|
||||||
'sourceUserId': sourceUserId,
|
'sourceUserId': sourceUserId,
|
||||||
@@ -130,6 +136,9 @@ class SharedPayload {
|
|||||||
json['receivedAt'] as String? ??
|
json['receivedAt'] as String? ??
|
||||||
DateTime.now().toUtc().toIso8601String(),
|
DateTime.now().toUtc().toIso8601String(),
|
||||||
).toLocal(),
|
).toLocal(),
|
||||||
|
sharedMessageDateTime: json['sharedMessageDateTime'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['sharedMessageDateTime'] as String).toLocal(),
|
||||||
platform: json['platform'] as String? ?? 'unknown',
|
platform: json['platform'] as String? ?? 'unknown',
|
||||||
sourceDisplayName: json['sourceDisplayName'] as String?,
|
sourceDisplayName: json['sourceDisplayName'] as String?,
|
||||||
sourceUserId: json['sourceUserId'] as String?,
|
sourceUserId: json['sourceUserId'] as String?,
|
||||||
@@ -244,7 +253,8 @@ class SharedMessageEntry {
|
|||||||
|
|
||||||
String get sourceApp => payload.sourceApp;
|
String get sourceApp => payload.sourceApp;
|
||||||
String get messageText => payload.rawText;
|
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;
|
SharedPayloadType get payloadType => payload.payloadType;
|
||||||
String? get url => payload.url;
|
String? get url => payload.url;
|
||||||
String? get sourceDisplayName => payload.sourceDisplayName;
|
String? get sourceDisplayName => payload.sourceDisplayName;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:relationship_saver/features/local/local_models.dart';
|
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/domain/signal_share_parser.dart';
|
||||||
import 'package:relationship_saver/features/share_intake/whatsapp_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 DateTime now = DateTime.now();
|
||||||
final String normalizedSourceApp = _normalizeSourceApp(sourceApp);
|
final String normalizedSourceApp = _normalizeSourceApp(sourceApp);
|
||||||
final String trimmed = rawText.trim();
|
final String trimmed = rawText.trim();
|
||||||
|
final DateTime? sharedMessageDateTime =
|
||||||
|
SharedMessageDateTimeExtractor.extract(trimmed, now: receivedAt ?? now);
|
||||||
final _ParsedSourceMetadata? sourceMetadata = _parseSourceMetadata(
|
final _ParsedSourceMetadata? sourceMetadata = _parseSourceMetadata(
|
||||||
sourceApp: normalizedSourceApp,
|
sourceApp: normalizedSourceApp,
|
||||||
rawText: trimmed,
|
rawText: trimmed,
|
||||||
@@ -48,6 +51,7 @@ class SharePayloadParser {
|
|||||||
url: url,
|
url: url,
|
||||||
createdAt: createdAt ?? now,
|
createdAt: createdAt ?? now,
|
||||||
receivedAt: receivedAt ?? now,
|
receivedAt: receivedAt ?? now,
|
||||||
|
sharedMessageDateTime: sharedMessageDateTime,
|
||||||
platform: platform,
|
platform: platform,
|
||||||
sourceDisplayName: sourceMetadata?.sourceDisplayName,
|
sourceDisplayName: sourceMetadata?.sourceDisplayName,
|
||||||
sourceUserId: sourceMetadata?.sourceUserId,
|
sourceUserId: sourceMetadata?.sourceUserId,
|
||||||
@@ -56,11 +60,14 @@ class SharePayloadParser {
|
|||||||
if (normalizedSourceApp != 'share_sheet')
|
if (normalizedSourceApp != 'share_sheet')
|
||||||
'source_app_$normalizedSourceApp',
|
'source_app_$normalizedSourceApp',
|
||||||
if (url != null) 'contains_url',
|
if (url != null) 'contains_url',
|
||||||
|
if (sharedMessageDateTime != null) 'shared_message_datetime_detected',
|
||||||
if (sourceMetadata?.sourceDisplayName case final String _)
|
if (sourceMetadata?.sourceDisplayName case final String _)
|
||||||
'sender_detected',
|
'sender_detected',
|
||||||
],
|
],
|
||||||
metadata: <String, String>{
|
metadata: <String, String>{
|
||||||
if (url case final String value) 'primaryUrl': value,
|
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 <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
#include <sentry_flutter/sentry_flutter_plugin.h>
|
#include <sentry_flutter/sentry_flutter_plugin.h>
|
||||||
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
@@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) sentry_flutter_registrar =
|
g_autoptr(FlPluginRegistrar) sentry_flutter_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "SentryFlutterPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "SentryFlutterPlugin");
|
||||||
sentry_flutter_plugin_register_with_registrar(sentry_flutter_registrar);
|
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
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
sentry_flutter
|
sentry_flutter
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import flutter_secure_storage_darwin
|
|||||||
import package_info_plus
|
import package_info_plus
|
||||||
import sentry_flutter
|
import sentry_flutter
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin"))
|
BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin"))
|
||||||
@@ -21,4 +22,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin"))
|
SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,73 @@
|
|||||||
PODS:
|
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_secure_storage_darwin (10.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- FlutterMacOS (1.0.0)
|
- 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:
|
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`)
|
- flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- 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:
|
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:
|
flutter_secure_storage_darwin:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
:path: Flutter/ephemeral
|
: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:
|
SPEC CHECKSUMS:
|
||||||
|
battery_plus: f51ad29136e025b714b96f7d096f44f604615da7
|
||||||
|
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
||||||
|
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
|
||||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
|
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||||
|
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
||||||
|
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
||||||
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
|
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||||
|
|
||||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||||
|
|
||||||
|
|||||||
@@ -1029,6 +1029,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
uuid:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ dependencies:
|
|||||||
flutter_local_notifications: ^20.1.0
|
flutter_local_notifications: ^20.1.0
|
||||||
workmanager: ^0.9.0+3
|
workmanager: ^0.9.0+3
|
||||||
sentry_flutter: ^8.14.2
|
sentry_flutter: ^8.14.2
|
||||||
|
url_launcher: ^6.3.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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": [
|
"suggestions": [
|
||||||
{
|
{
|
||||||
"personToken": "person_001",
|
"personToken": "person_001",
|
||||||
"kind": "giftIdea",
|
"kind": "eventIdea",
|
||||||
"title": "Coffee tasting set",
|
"title": "Coffee tasting set",
|
||||||
"details": "Pick a small local sampler.",
|
"details": "Pick a small local sampler.",
|
||||||
"suggestedTiming": "2026-05-20T18:00:00Z",
|
"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,
|
"confidence": 0.82,
|
||||||
"reason": "Matches known interests."
|
"reason": "Matches known interests."
|
||||||
}
|
}
|
||||||
@@ -28,8 +32,13 @@ void main() {
|
|||||||
expect(result.success, isTrue);
|
expect(result.success, isTrue);
|
||||||
expect(result.drafts, hasLength(1));
|
expect(result.drafts, hasLength(1));
|
||||||
expect(result.drafts.single.personId, 'p-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.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', () {
|
test('rejects unknown person tokens and malformed items', () {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ void main() {
|
|||||||
rawText: 'Raw private chat text',
|
rawText: 'Raw private chat text',
|
||||||
createdAt: DateTime(2026, 5, 1),
|
createdAt: DateTime(2026, 5, 1),
|
||||||
receivedAt: DateTime(2026, 5, 1),
|
receivedAt: DateTime(2026, 5, 1),
|
||||||
|
sharedMessageDateTime: DateTime.utc(2026, 2, 1, 10),
|
||||||
platform: 'ios',
|
platform: 'ios',
|
||||||
sourceDisplayName: 'Alice Secret',
|
sourceDisplayName: 'Alice Secret',
|
||||||
sourceUserId: 'wa:alice',
|
sourceUserId: 'wa:alice',
|
||||||
@@ -73,6 +74,27 @@ void main() {
|
|||||||
updatedAt: DateTime(2026, 5, 1),
|
updatedAt: DateTime(2026, 5, 1),
|
||||||
isSensitive: true,
|
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>[
|
aiSuggestionDrafts: <AiSuggestionDraft>[
|
||||||
AiSuggestionDraft(
|
AiSuggestionDraft(
|
||||||
@@ -104,6 +126,14 @@ void main() {
|
|||||||
expect(prompt, isNot(contains('thread-secret')));
|
expect(prompt, isNot(contains('thread-secret')));
|
||||||
expect(prompt, isNot(contains('Raw private chat text')));
|
expect(prompt, isNot(contains('Raw private chat text')));
|
||||||
expect(prompt, isNot(contains('Secret medical detail')));
|
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('priorSuggestions'));
|
||||||
expect(prompt, contains('jazz concert downtown'));
|
expect(prompt, contains('jazz concert downtown'));
|
||||||
expect(prompt, contains('dismissed'));
|
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.completed, isTrue);
|
||||||
expect(result.createdDraftCount, 1);
|
expect(result.createdDraftCount, 1);
|
||||||
expect(textClient.calls, 1);
|
expect(textClient.calls, 1);
|
||||||
|
expect(textClient.lastEnableWebSearch, isTrue);
|
||||||
expect(textClient.lastPrompt, isNot(contains(state.people.first.name)));
|
expect(textClient.lastPrompt, isNot(contains(state.people.first.name)));
|
||||||
expect(state.aiSuggestionDrafts, hasLength(1));
|
expect(state.aiSuggestionDrafts, hasLength(1));
|
||||||
expect(
|
expect(
|
||||||
@@ -120,6 +121,139 @@ void main() {
|
|||||||
expect(textClient.calls, 0);
|
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 {
|
test('manual digest reports provider rate limits without Dio noise', () async {
|
||||||
final ThrowingTextClient textClient = ThrowingTextClient(
|
final ThrowingTextClient textClient = ThrowingTextClient(
|
||||||
error: const LlmProviderException(
|
error: const LlmProviderException(
|
||||||
@@ -183,14 +317,17 @@ class FakeTextClient implements LlmDigestTextClient {
|
|||||||
final String response;
|
final String response;
|
||||||
int calls = 0;
|
int calls = 0;
|
||||||
String? lastPrompt;
|
String? lastPrompt;
|
||||||
|
bool? lastEnableWebSearch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> complete({
|
Future<String> complete({
|
||||||
required String systemPrompt,
|
required String systemPrompt,
|
||||||
required String userPrompt,
|
required String userPrompt,
|
||||||
|
bool enableWebSearch = false,
|
||||||
}) async {
|
}) async {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
lastPrompt = userPrompt;
|
lastPrompt = userPrompt;
|
||||||
|
lastEnableWebSearch = enableWebSearch;
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,11 +341,35 @@ class ThrowingTextClient implements LlmDigestTextClient {
|
|||||||
Future<String> complete({
|
Future<String> complete({
|
||||||
required String systemPrompt,
|
required String systemPrompt,
|
||||||
required String userPrompt,
|
required String userPrompt,
|
||||||
|
bool enableWebSearch = false,
|
||||||
}) async {
|
}) async {
|
||||||
throw error;
|
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 {
|
class FakeNotifier implements AiDigestNotifier {
|
||||||
int? lastCount;
|
int? lastCount;
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,33 @@ void main() {
|
|||||||
expect(payload.rawText, 'loves cozy Italian restaurants');
|
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', () {
|
test('uses explicit signal source app hint and extracts sender', () {
|
||||||
final SharedPayload payload = SharePayloadParser.parse(
|
final SharedPayload payload = SharePayloadParser.parse(
|
||||||
rawText: 'Marek: this concert looks promising',
|
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 <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
#include <sentry_flutter/sentry_flutter_plugin.h>
|
#include <sentry_flutter/sentry_flutter_plugin.h>
|
||||||
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
BatteryPlusWindowsPluginRegisterWithRegistrar(
|
BatteryPlusWindowsPluginRegisterWithRegistrar(
|
||||||
@@ -20,4 +21,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
SentryFlutterPluginRegisterWithRegistrar(
|
SentryFlutterPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("SentryFlutterPlugin"));
|
registry->GetRegistrarForPlugin("SentryFlutterPlugin"));
|
||||||
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
connectivity_plus
|
connectivity_plus
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
sentry_flutter
|
sentry_flutter
|
||||||
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Reference in New Issue
Block a user