feat: add local-first private AI digest workflow
Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:relationship_saver/app/data/relationship_repository.dart';
|
||||
import 'package:relationship_saver/app/data/storage/local_data_store_in_memory.dart';
|
||||
import 'package:relationship_saver/app/data/storage/local_data_store_provider.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/ideas/domain/idea_models.dart';
|
||||
import 'package:relationship_saver/features/reminders/application/reminder_scheduler.dart';
|
||||
import 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart';
|
||||
import 'package:relationship_saver/features/sync/data/storage/sync_state_store_in_memory.dart';
|
||||
import 'package:relationship_saver/features/sync/data/storage/sync_state_store_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
});
|
||||
|
||||
test('accepting gift draft creates idea and marks draft accepted', () async {
|
||||
final ProviderContainer container = _createContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final LocalDataState initial = await container.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
final String personId = initial.people.first.id;
|
||||
|
||||
await container
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.saveAiSuggestionDrafts(<AiSuggestionDraft>[
|
||||
AiSuggestionDraft(
|
||||
id: 'ai-1',
|
||||
personId: personId,
|
||||
kind: AiSuggestionKind.giftIdea,
|
||||
title: 'Coffee sampler',
|
||||
details: 'Small local roaster pack.',
|
||||
suggestedAt: DateTime(2026, 5, 16),
|
||||
confidence: 0.8,
|
||||
status: AiSuggestionStatus.pending,
|
||||
sourceRunId: 'run-1',
|
||||
),
|
||||
]);
|
||||
|
||||
await container
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.acceptAiSuggestionDraft('ai-1');
|
||||
|
||||
final LocalDataState after = await container.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
expect(after.aiSuggestionDrafts.single.status, AiSuggestionStatus.accepted);
|
||||
expect(
|
||||
after.ideas.any(
|
||||
(RelationshipIdea idea) =>
|
||||
idea.personId == personId &&
|
||||
idea.type == IdeaType.gift &&
|
||||
idea.title == 'Coffee sampler',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('dismissing draft does not create local artifacts', () async {
|
||||
final ProviderContainer container = _createContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final LocalDataState initial = await container.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
final String personId = initial.people.first.id;
|
||||
final int ideaCount = initial.ideas.length;
|
||||
|
||||
await container
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.saveAiSuggestionDrafts(<AiSuggestionDraft>[
|
||||
AiSuggestionDraft(
|
||||
id: 'ai-2',
|
||||
personId: personId,
|
||||
kind: AiSuggestionKind.eventIdea,
|
||||
title: 'Museum afternoon',
|
||||
details: 'Pick a quiet exhibit.',
|
||||
suggestedAt: DateTime(2026, 5, 16),
|
||||
confidence: 0.7,
|
||||
status: AiSuggestionStatus.pending,
|
||||
sourceRunId: 'run-1',
|
||||
),
|
||||
]);
|
||||
await container
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.dismissAiSuggestionDraft('ai-2');
|
||||
|
||||
final LocalDataState after = await container.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
expect(
|
||||
after.aiSuggestionDrafts.single.status,
|
||||
AiSuggestionStatus.dismissed,
|
||||
);
|
||||
expect(after.ideas.length, ideaCount);
|
||||
});
|
||||
}
|
||||
|
||||
ProviderContainer _createContainer() {
|
||||
return ProviderContainer(
|
||||
overrides: [
|
||||
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
|
||||
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
|
||||
reminderSchedulerProvider.overrideWithValue(
|
||||
const NoopReminderScheduler(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/ai_digest_response_parser.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||
|
||||
void main() {
|
||||
test('maps valid tokenized suggestions back to local person ids', () {
|
||||
final AiDigestParseResult result = const AiDigestResponseParser().parse(
|
||||
response: '''
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"personToken": "person_001",
|
||||
"kind": "giftIdea",
|
||||
"title": "Coffee tasting set",
|
||||
"details": "Pick a small local sampler.",
|
||||
"suggestedTiming": "2026-05-20T18:00:00Z",
|
||||
"confidence": 0.82,
|
||||
"reason": "Matches known interests."
|
||||
}
|
||||
]
|
||||
}
|
||||
''',
|
||||
tokenToPersonId: const <String, String>{'person_001': 'p-1'},
|
||||
sourceRunId: 'run-1',
|
||||
suggestedAt: DateTime(2026, 5, 16),
|
||||
);
|
||||
|
||||
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.status, AiSuggestionStatus.pending);
|
||||
});
|
||||
|
||||
test('rejects unknown person tokens and malformed items', () {
|
||||
final AiDigestParseResult result = const AiDigestResponseParser().parse(
|
||||
response: '''
|
||||
[
|
||||
{"personToken": "person_404", "kind": "giftIdea", "title": "Bad"},
|
||||
{"personToken": "person_001", "kind": "unknown", "title": "Bad"},
|
||||
{"personToken": "person_001", "kind": "checkIn", "title": ""}
|
||||
]
|
||||
''',
|
||||
tokenToPersonId: const <String, String>{'person_001': 'p-1'},
|
||||
sourceRunId: 'run-1',
|
||||
suggestedAt: DateTime(2026, 5, 16),
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.drafts, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:relationship_saver/app/state/local_data_state.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart';
|
||||
import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart';
|
||||
import 'package:relationship_saver/features/ideas/domain/idea_models.dart';
|
||||
import 'package:relationship_saver/features/moments/domain/moment_models.dart';
|
||||
import 'package:relationship_saver/features/people/domain/person_models.dart';
|
||||
import 'package:relationship_saver/features/reminders/domain/reminder_models.dart';
|
||||
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
|
||||
|
||||
void main() {
|
||||
test('builds pseudonymous payload without identifiable profile data', () {
|
||||
final LocalDataState state = LocalDataState(
|
||||
people: <PersonProfile>[
|
||||
PersonProfile(
|
||||
id: 'p-1',
|
||||
name: 'Alice Secret',
|
||||
relationship: 'Sister',
|
||||
affinityScore: 85,
|
||||
nextMoment: DateTime(2026, 6, 1),
|
||||
tags: const <String>['coffee', 'books'],
|
||||
notes: 'Passport number and private address should stay local.',
|
||||
aliases: const <String>['Ally'],
|
||||
location: 'Zurich, Switzerland',
|
||||
lastInteractedAt: DateTime(2026, 5, 1),
|
||||
),
|
||||
],
|
||||
moments: const <RelationshipMoment>[],
|
||||
ideas: const <RelationshipIdea>[],
|
||||
reminders: const <ReminderRule>[],
|
||||
tasks: const <DashboardTask>[],
|
||||
sourceLinks: <SourceProfileLink>[
|
||||
SourceProfileLink(
|
||||
id: 'sl-1',
|
||||
sourceApp: 'whatsapp',
|
||||
normalizedDisplayName: 'Alice Secret',
|
||||
profileId: 'p-1',
|
||||
firstSeenAt: DateTime(2026, 5, 1),
|
||||
lastSeenAt: DateTime(2026, 5, 1),
|
||||
sourceUserId: 'wa:alice',
|
||||
sourceThreadId: 'thread-secret',
|
||||
sourceFingerprint: 'whatsapp|u:wa:alice',
|
||||
),
|
||||
],
|
||||
sharedMessages: <SharedMessageEntry>[
|
||||
SharedMessageEntry(
|
||||
id: 'sm-1',
|
||||
profileId: 'p-1',
|
||||
payload: SharedPayload(
|
||||
sourceApp: 'whatsapp',
|
||||
payloadType: SharedPayloadType.text,
|
||||
rawText: 'Raw private chat text',
|
||||
createdAt: DateTime(2026, 5, 1),
|
||||
receivedAt: DateTime(2026, 5, 1),
|
||||
platform: 'ios',
|
||||
sourceDisplayName: 'Alice Secret',
|
||||
sourceUserId: 'wa:alice',
|
||||
sourceThreadId: 'thread-secret',
|
||||
),
|
||||
importedAt: DateTime(2026, 5, 1),
|
||||
resolvedAutomatically: true,
|
||||
),
|
||||
],
|
||||
personFacts: <PersonFact>[
|
||||
PersonFact(
|
||||
id: 'pf-1',
|
||||
personId: 'p-1',
|
||||
type: CapturedFactType.note,
|
||||
text: 'Secret medical detail',
|
||||
sourceKind: CaptureSourceKind.manual,
|
||||
createdAt: DateTime(2026, 5, 1),
|
||||
updatedAt: DateTime(2026, 5, 1),
|
||||
isSensitive: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final AnonymizedLlmDigestContext context = AnonymizedLlmContextBuilder(
|
||||
now: () => DateTime(2026, 5, 16),
|
||||
).build(state);
|
||||
final String prompt = context.toPromptJson();
|
||||
|
||||
expect(context.tokenToPersonId, <String, String>{'person_001': 'p-1'});
|
||||
expect(prompt, contains('person_001'));
|
||||
expect(prompt, isNot(contains('Alice')));
|
||||
expect(prompt, isNot(contains('Ally')));
|
||||
expect(prompt, isNot(contains('Zurich')));
|
||||
expect(prompt, isNot(contains('Passport')));
|
||||
expect(prompt, isNot(contains('wa:alice')));
|
||||
expect(prompt, isNot(contains('thread-secret')));
|
||||
expect(prompt, isNot(contains('Raw private chat text')));
|
||||
expect(prompt, isNot(contains('Secret medical detail')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:relationship_saver/app/data/relationship_repository.dart';
|
||||
import 'package:relationship_saver/app/data/storage/local_data_store_in_memory.dart';
|
||||
import 'package:relationship_saver/app/data/storage/local_data_store_provider.dart';
|
||||
import 'package:relationship_saver/app/state/local_data_state.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/llm_digest_environment.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/llm_digest_text_client.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/domain/ai_digest_models.dart';
|
||||
import 'package:relationship_saver/features/reminders/application/reminder_scheduler.dart';
|
||||
import 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart';
|
||||
import 'package:relationship_saver/features/sync/data/storage/sync_state_store_in_memory.dart';
|
||||
import 'package:relationship_saver/features/sync/data/storage/sync_state_store_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
});
|
||||
|
||||
test(
|
||||
'scheduled digest creates review drafts with one LLM call and notification',
|
||||
() 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,
|
||||
"reason": "Contact has been quiet."
|
||||
}
|
||||
]
|
||||
}
|
||||
''',
|
||||
);
|
||||
final FakeNotifier notifier = FakeNotifier();
|
||||
final ProviderContainer container = _createContainer(
|
||||
textClient: textClient,
|
||||
notifier: notifier,
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(llmDigestConfigProvider.notifier).setEnabled(true);
|
||||
|
||||
final LlmDigestRunResult result = await container
|
||||
.read(llmDigestOrchestratorProvider)
|
||||
.runScheduledDigest();
|
||||
|
||||
final LocalDataState state = await container.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
expect(result.completed, isTrue);
|
||||
expect(result.createdDraftCount, 1);
|
||||
expect(textClient.calls, 1);
|
||||
expect(textClient.lastPrompt, isNot(contains(state.people.first.name)));
|
||||
expect(state.aiSuggestionDrafts, hasLength(1));
|
||||
expect(
|
||||
state.aiSuggestionDrafts.single.status,
|
||||
AiSuggestionStatus.pending,
|
||||
);
|
||||
expect(notifier.lastCount, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('scheduled digest respects weekly cooldown', () async {
|
||||
final LlmDigestRunStore runStore = const LlmDigestRunStore();
|
||||
await runStore.write(LlmDigestRunState(lastCompletedAt: DateTime.now()));
|
||||
final FakeTextClient textClient = FakeTextClient(response: '[]');
|
||||
final ProviderContainer container = _createContainer(
|
||||
textClient: textClient,
|
||||
notifier: FakeNotifier(),
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(llmDigestConfigProvider.notifier).setEnabled(true);
|
||||
|
||||
final LlmDigestRunResult result = await container
|
||||
.read(llmDigestOrchestratorProvider)
|
||||
.runScheduledDigest();
|
||||
|
||||
expect(result.started, isFalse);
|
||||
expect(textClient.calls, 0);
|
||||
});
|
||||
|
||||
test('scheduled digest skips when network policy is not satisfied', () async {
|
||||
final FakeTextClient textClient = FakeTextClient(response: '[]');
|
||||
final ProviderContainer container = _createContainer(
|
||||
textClient: textClient,
|
||||
notifier: FakeNotifier(),
|
||||
environment: const FakeEnvironment(canRunValue: false),
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(llmDigestConfigProvider.notifier).setEnabled(true);
|
||||
|
||||
final LlmDigestRunResult result = await container
|
||||
.read(llmDigestOrchestratorProvider)
|
||||
.runScheduledDigest();
|
||||
|
||||
expect(result.started, isFalse);
|
||||
expect(textClient.calls, 0);
|
||||
});
|
||||
}
|
||||
|
||||
ProviderContainer _createContainer({
|
||||
required FakeTextClient textClient,
|
||||
required FakeNotifier notifier,
|
||||
LlmDigestEnvironment environment = const FakeEnvironment(),
|
||||
}) {
|
||||
return ProviderContainer(
|
||||
overrides: [
|
||||
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
|
||||
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
|
||||
reminderSchedulerProvider.overrideWithValue(
|
||||
const NoopReminderScheduler(),
|
||||
),
|
||||
llmDigestInitializerProvider.overrideWithValue(() async {}),
|
||||
llmDigestApiKeyConfiguredProvider.overrideWithValue(true),
|
||||
llmDigestTextClientProvider.overrideWithValue(textClient),
|
||||
llmDigestEnvironmentProvider.overrideWithValue(environment),
|
||||
aiDigestNotifierProvider.overrideWithValue(notifier),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class FakeTextClient implements LlmDigestTextClient {
|
||||
FakeTextClient({required this.response});
|
||||
|
||||
final String response;
|
||||
int calls = 0;
|
||||
String? lastPrompt;
|
||||
|
||||
@override
|
||||
Future<String> complete({
|
||||
required String systemPrompt,
|
||||
required String userPrompt,
|
||||
}) async {
|
||||
calls += 1;
|
||||
lastPrompt = userPrompt;
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNotifier implements AiDigestNotifier {
|
||||
int? lastCount;
|
||||
|
||||
@override
|
||||
Future<void> showDigestReady(int count) async {
|
||||
lastCount = count;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeEnvironment implements LlmDigestEnvironment {
|
||||
const FakeEnvironment({this.canRunValue = true});
|
||||
|
||||
final bool canRunValue;
|
||||
|
||||
@override
|
||||
Future<bool> canRun(LlmDigestConfigState config) async => canRunValue;
|
||||
}
|
||||
Reference in New Issue
Block a user