Add LLM digest setup and share timestamp context
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user