Add LLM digest setup and share timestamp context

This commit is contained in:
Rijad Zuzo
2026-05-19 19:17:35 +02:00
parent 9d2da0c600
commit 5d80af375e
42 changed files with 2878 additions and 39 deletions
@@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/llm/llm_diagnostics_log.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
});
test('records and exports LLM diagnostics', () async {
const LlmDiagnosticsLog log = LlmDiagnosticsLog();
await log.record(
'provider_error',
'Google AI request failed with 429.',
details: '{"statusCode":429,"responseBody":"quota"}',
);
final List<LlmDiagnosticsEvent> events = await log.read();
expect(events, hasLength(1));
expect(events.single.stage, 'provider_error');
expect(events.single.details, contains('statusCode'));
expect(log.exportText(events), contains('Google AI request failed'));
});
test('trims very large diagnostics before storing', () async {
const LlmDiagnosticsLog log = LlmDiagnosticsLog();
await log.record(
'provider_error',
'large',
details: List<String>.filled(9000, 'x').join(),
);
final List<LlmDiagnosticsEvent> events = await log.read();
expect(events.single.details, endsWith('[truncated]'));
expect(events.single.details!.length, lessThan(9000));
});
}
@@ -0,0 +1,125 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/llm/ollama/ollama_tool_chat_client.dart';
import 'package:relationship_saver/core/llm/ollama/ollama_web_search_client.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import '../../helpers/queue_http_client_adapter.dart';
void main() {
test(
'runs Ollama tool calls and feeds search results back to model',
() async {
final List<Map<String, dynamic>> requestBodies = <Map<String, dynamic>>[];
final QueueHttpClientAdapter adapter = QueueHttpClientAdapter(
<AdapterHandler>[
(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
requestBodies.add(
jsonDecode(options.data as String) as Map<String, dynamic>,
);
return jsonResponse(200, <String, dynamic>{
'message': <String, dynamic>{
'role': 'assistant',
'content': '',
'tool_calls': <Map<String, dynamic>>[
<String, dynamic>{
'function': <String, dynamic>{
'name': 'search_web',
'arguments': <String, dynamic>{
'query': 'Zurich jazz events May 2026',
'maxResults': 3,
},
},
},
],
},
});
},
(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
requestBodies.add(
jsonDecode(options.data as String) as Map<String, dynamic>,
);
return jsonResponse(200, <String, dynamic>{
'message': <String, dynamic>{
'role': 'assistant',
'content': '''
{
"suggestions": [
{
"personToken": "person_001",
"kind": "eventIdea",
"title": "Jazz night",
"details": "A grounded event suggestion.",
"confidence": 0.8
}
]
}
''',
},
});
},
],
);
final Dio dio = Dio();
dio.httpClientAdapter = adapter;
final OllamaToolChatClient client = OllamaToolChatClient(
dio: dio,
webSearchClient: const FakeWebSearchClient(),
);
final String response = await client.complete(
baseUrl: 'http://macbook.local:11434',
model: 'qwen3:8b',
systemPrompt: 'Return digest JSON.',
userPrompt: '{"people":[]}',
);
expect(response, contains(AiSuggestionKind.eventIdea.name));
expect(adapter.fetchCount, 2);
expect(requestBodies.first['tools'], isA<List<dynamic>>());
final List<dynamic> secondMessages =
requestBodies.last['messages'] as List<dynamic>;
final Map<String, dynamic> toolMessage = secondMessages
.whereType<Map<String, dynamic>>()
.singleWhere(
(Map<String, dynamic> message) => message['role'] == 'tool',
);
expect(toolMessage['tool_name'], 'search_web');
expect(toolMessage['content'], contains('moods.ch'));
},
);
}
class FakeWebSearchClient implements OllamaWebSearchClient {
const FakeWebSearchClient();
@override
Future<String> fetchPageText(String url) async {
return 'Fetched text for $url';
}
@override
Future<List<OllamaWebSearchResult>> search(
String query, {
int maxResults = 5,
}) async {
return const <OllamaWebSearchResult>[
OllamaWebSearchResult(
title: 'Moods Jazz Club',
url: 'https://www.moods.ch/',
snippet: 'Upcoming jazz concerts in Zurich.',
),
];
}
}
@@ -10,10 +10,14 @@ void main() {
"suggestions": [
{
"personToken": "person_001",
"kind": "giftIdea",
"kind": "eventIdea",
"title": "Coffee tasting set",
"details": "Pick a small local sampler.",
"suggestedTiming": "2026-05-20T18:00:00Z",
"eventEndsAt": "2026-05-20T20:00:00Z",
"eventLocation": "Local roaster hall",
"eventUrl": "https://example.com/events/coffee",
"sourceUrls": ["https://example.com/events/coffee"],
"confidence": 0.82,
"reason": "Matches known interests."
}
@@ -28,8 +32,13 @@ void main() {
expect(result.success, isTrue);
expect(result.drafts, hasLength(1));
expect(result.drafts.single.personId, 'p-1');
expect(result.drafts.single.kind, AiSuggestionKind.giftIdea);
expect(result.drafts.single.kind, AiSuggestionKind.eventIdea);
expect(result.drafts.single.status, AiSuggestionStatus.pending);
expect(result.drafts.single.eventLocation, 'Local roaster hall');
expect(result.drafts.single.eventUrl, 'https://example.com/events/coffee');
expect(result.drafts.single.sourceUrls, <String>[
'https://example.com/events/coffee',
]);
});
test('rejects unknown person tokens and malformed items', () {
@@ -53,6 +53,7 @@ void main() {
rawText: 'Raw private chat text',
createdAt: DateTime(2026, 5, 1),
receivedAt: DateTime(2026, 5, 1),
sharedMessageDateTime: DateTime.utc(2026, 2, 1, 10),
platform: 'ios',
sourceDisplayName: 'Alice Secret',
sourceUserId: 'wa:alice',
@@ -73,6 +74,27 @@ void main() {
updatedAt: DateTime(2026, 5, 1),
isSensitive: true,
),
PersonFact(
id: 'pf-2',
personId: 'p-1',
type: CapturedFactType.giftIdea,
text: 'Alice wants ceramic espresso cups',
label: 'Coffee gear',
sourceKind: CaptureSourceKind.shareInbox,
sharedMessageId: 'sm-1',
createdAt: DateTime(2026, 5, 2),
updatedAt: DateTime(2026, 5, 2),
),
PersonFact(
id: 'pf-3',
personId: 'p-1',
type: CapturedFactType.placeIdea,
text: 'Unreviewed ramen place',
sourceKind: CaptureSourceKind.shareSheet,
createdAt: DateTime(2026, 5, 3),
updatedAt: DateTime(2026, 5, 3),
needsReview: true,
),
],
aiSuggestionDrafts: <AiSuggestionDraft>[
AiSuggestionDraft(
@@ -104,6 +126,14 @@ void main() {
expect(prompt, isNot(contains('thread-secret')));
expect(prompt, isNot(contains('Raw private chat text')));
expect(prompt, isNot(contains('Secret medical detail')));
expect(prompt, contains('capturedFacts'));
expect(prompt, contains('shared_message_datetime'));
expect(prompt, contains('2026-02-01T10:00:00.000Z'));
expect(prompt, contains('about 3 months ago'));
expect(prompt, contains('ceramic espresso cups'));
expect(prompt, contains('coffee gear'));
expect(prompt, contains('this person wants ceramic espresso cups'));
expect(prompt, isNot(contains('unreviewed ramen place')));
expect(prompt, contains('priorSuggestions'));
expect(prompt, contains('jazz concert downtown'));
expect(prompt, contains('dismissed'));
@@ -0,0 +1,53 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/features/ai_digest/application/calendar_event_launcher.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
void main() {
test('builds Google Calendar template URL for event suggestions', () {
final Uri? uri = calendarUriForSuggestion(
AiSuggestionDraft(
id: 'ai-1',
personId: 'p-1',
kind: AiSuggestionKind.eventIdea,
title: 'Coffee festival',
details: 'Visit the tasting session.',
suggestedAt: DateTime(2026, 5, 16),
suggestedFor: DateTime.utc(2026, 5, 20, 18),
eventEndsAt: DateTime.utc(2026, 5, 20, 20),
eventLocation: 'Local roaster hall',
eventUrl: 'https://example.com/events/coffee',
sourceUrls: const <String>['https://example.com/events/coffee'],
confidence: 0.8,
status: AiSuggestionStatus.pending,
sourceRunId: 'run-1',
),
);
expect(uri, isNotNull);
expect(uri!.host, 'calendar.google.com');
expect(uri.queryParameters['action'], 'TEMPLATE');
expect(uri.queryParameters['text'], 'Coffee festival');
expect(uri.queryParameters['dates'], '20260520T180000Z/20260520T200000Z');
expect(uri.queryParameters['location'], 'Local roaster hall');
expect(uri.queryParameters['details'], contains('Visit the tasting'));
expect(uri.queryParameters['details'], contains('https://example.com'));
});
test('does not build a calendar URL without event timing', () {
final Uri? uri = calendarUriForSuggestion(
AiSuggestionDraft(
id: 'ai-2',
personId: 'p-1',
kind: AiSuggestionKind.eventIdea,
title: 'Untimed event',
details: '',
suggestedAt: DateTime(2026, 5, 16),
confidence: 0.6,
status: AiSuggestionStatus.pending,
sourceRunId: 'run-1',
),
);
expect(uri, isNull);
});
}
@@ -71,6 +71,7 @@ void main() {
expect(result.completed, isTrue);
expect(result.createdDraftCount, 1);
expect(textClient.calls, 1);
expect(textClient.lastEnableWebSearch, isTrue);
expect(textClient.lastPrompt, isNot(contains(state.people.first.name)));
expect(state.aiSuggestionDrafts, hasLength(1));
expect(
@@ -120,6 +121,139 @@ void main() {
expect(textClient.calls, 0);
});
test('manual digest bypasses scheduled network policy', () async {
final FakeTextClient textClient = FakeTextClient(
response: '''
{
"suggestions": [
{
"personToken": "person_001",
"kind": "checkIn",
"title": "Send a quick note",
"details": "Ask how the week is going.",
"confidence": 0.7
}
]
}
''',
);
final ProviderContainer container = _createContainer(
textClient: textClient,
notifier: FakeNotifier(),
environment: const FakeEnvironment(canRunValue: false),
);
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Taylor Quinn',
relationship: 'Friend',
notes: '',
tags: const <String>['tea'],
);
final LlmDigestRunResult result = await container
.read(llmDigestOrchestratorProvider)
.runManualDigest();
expect(result.started, isTrue);
expect(result.completed, isTrue);
expect(textClient.calls, 1);
});
test('digest respects disabled web grounding setting', () async {
final FakeTextClient textClient = FakeTextClient(
response: '''
{
"suggestions": [
{
"personToken": "person_001",
"kind": "checkIn",
"title": "Send a quick note",
"details": "Ask how the week is going.",
"confidence": 0.7
}
]
}
''',
);
final ProviderContainer container = _createContainer(
textClient: textClient,
notifier: FakeNotifier(),
);
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Taylor Quinn',
relationship: 'Friend',
notes: '',
tags: const <String>['tea'],
);
await container
.read(llmDigestConfigProvider.notifier)
.setWebSearchEnabled(false);
final LlmDigestRunResult result = await container
.read(llmDigestOrchestratorProvider)
.runManualDigest();
expect(result.completed, isTrue);
expect(textClient.lastEnableWebSearch, isFalse);
});
test(
'digest retries without web grounding after grounded quota error',
() async {
final SequenceTextClient textClient = SequenceTextClient(<Object>[
const LlmProviderException(
provider: LlmProvider.google,
statusCode: 429,
message: 'Google AI rejected the digest request with 429 quota.',
),
'''
{
"suggestions": [
{
"personToken": "person_001",
"kind": "checkIn",
"title": "Send a quick note",
"details": "Ask how the week is going.",
"confidence": 0.7
}
]
}
''',
]);
final ProviderContainer container = _createContainer(
textClient: textClient,
notifier: FakeNotifier(),
);
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Taylor Quinn',
relationship: 'Friend',
notes: '',
tags: const <String>['tea'],
);
final LlmDigestRunResult result = await container
.read(llmDigestOrchestratorProvider)
.runManualDigest();
expect(result.completed, isTrue);
expect(textClient.enableWebSearchValues, <bool>[true, false]);
},
);
test('manual digest reports provider rate limits without Dio noise', () async {
final ThrowingTextClient textClient = ThrowingTextClient(
error: const LlmProviderException(
@@ -183,14 +317,17 @@ class FakeTextClient implements LlmDigestTextClient {
final String response;
int calls = 0;
String? lastPrompt;
bool? lastEnableWebSearch;
@override
Future<String> complete({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
calls += 1;
lastPrompt = userPrompt;
lastEnableWebSearch = enableWebSearch;
return response;
}
}
@@ -204,11 +341,35 @@ class ThrowingTextClient implements LlmDigestTextClient {
Future<String> complete({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
throw error;
}
}
class SequenceTextClient implements LlmDigestTextClient {
SequenceTextClient(this.results);
final List<Object> results;
final List<bool> enableWebSearchValues = <bool>[];
int _index = 0;
@override
Future<String> complete({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
enableWebSearchValues.add(enableWebSearch);
final Object result = results[_index];
_index += 1;
if (result is Exception) {
throw result;
}
return result as String;
}
}
class FakeNotifier implements AiDigestNotifier {
int? lastCount;
@@ -39,6 +39,33 @@ void main() {
expect(payload.rawText, 'loves cozy Italian restaurants');
});
test('stores source message datetime when WhatsApp text includes one', () {
final SharedPayload payload = SharePayloadParser.parse(
rawText: '[19/05/2026, 14:30] Ava Hart: ginger tea helped',
sourceApp: 'whatsapp',
platform: 'ios',
);
expect(payload.sourceDisplayName, 'Ava Hart');
expect(payload.rawText, 'ginger tea helped');
expect(payload.sharedMessageDateTime, DateTime(2026, 5, 19, 14, 30));
expect(payload.parsingHints, contains('shared_message_datetime_detected'));
expect(payload.metadata, contains('shared_message_datetime'));
});
test(
'does not store source message datetime for arbitrary content dates',
() {
final SharedPayload payload = SharePayloadParser.parse(
rawText: 'Ava birthday is on May 20 and she likes ginger tea.',
sourceApp: 'share_sheet',
platform: 'ios',
);
expect(payload.sharedMessageDateTime, isNull);
},
);
test('uses explicit signal source app hint and extracts sender', () {
final SharedPayload payload = SharePayloadParser.parse(
rawText: 'Marek: this concert looks promising',
@@ -0,0 +1,58 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/features/share_intake/domain/shared_message_datetime_extractor.dart';
void main() {
group('SharedMessageDateTimeExtractor', () {
test('extracts WhatsApp bracketed European timestamp', () {
final DateTime? value = SharedMessageDateTimeExtractor.extract(
'[19/05/2026, 14:30] Ava Hart: I was sick but ginger tea helped',
);
expect(value, DateTime(2026, 5, 19, 14, 30));
});
test('extracts exported chat prefix with US meridiem timestamp', () {
final DateTime? value = SharedMessageDateTimeExtractor.extract(
'5/19/26, 2:30 PM - Ava Hart: ginger tea helped',
);
expect(value, DateTime(2026, 5, 19, 14, 30));
});
test('extracts labelled month-name timestamp', () {
final DateTime? value = SharedMessageDateTimeExtractor.extract(
'Received at: 19 May 2026 at 14:30\nAva: ginger tea helped',
);
expect(value, DateTime(2026, 5, 19, 14, 30));
});
test(
'extracts relative labelled timestamp using provided reference date',
() {
final DateTime? value = SharedMessageDateTimeExtractor.extract(
'Yesterday at 21:05 - Ava Hart: ginger tea helped',
now: DateTime(2026, 5, 19, 9),
);
expect(value, DateTime(2026, 5, 18, 21, 5));
},
);
test('does not treat arbitrary content date as message timestamp', () {
final DateTime? value = SharedMessageDateTimeExtractor.extract(
'Ava birthday is on May 20 and she likes ginger tea.',
);
expect(value, isNull);
});
test('returns null instead of throwing for invalid dates', () {
final DateTime? value = SharedMessageDateTimeExtractor.extract(
'[99/99/2026, 25:99] Ava: impossible',
);
expect(value, isNull);
});
});
}