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:
Rijad Zuzo
2026-05-17 00:17:20 +02:00
parent dab50abf0e
commit f655adfbea
212 changed files with 24178 additions and 15895 deletions
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
CapturedFactDraft? parseCapturedFactDraftSuggestion(
String response, {
required SharedPayload fallbackPayload,
}) {
try {
final int jsonStart = response.indexOf('{');
final int jsonEnd = response.lastIndexOf('}');
if (jsonStart == -1 || jsonEnd == -1 || jsonEnd <= jsonStart) {
return null;
}
final Map<String, dynamic> json =
jsonDecode(response.substring(jsonStart, jsonEnd + 1))
as Map<String, dynamic>;
final String typeName = '${json['type'] ?? CapturedFactType.note.name}';
final DateTime? parsedDate = _parseDate(json['dateValue']);
final double? confidence = _parseConfidence(json['confidence']);
return CapturedFactDraft(
type: CapturedFactType.values.firstWhere(
(CapturedFactType type) => type.name == typeName,
orElse: () => CapturedFactType.note,
),
text: '${json['text'] ?? fallbackPayload.rawText}'.trim(),
label: _trimToNull(json['label'] as String?),
dateValue: parsedDate,
confidence: confidence,
isSensitive: json['isSensitive'] as bool? ?? false,
needsReview: true,
);
} catch (_) {
return null;
}
}
String? _trimToNull(String? value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
DateTime? _parseDate(Object? raw) {
if (raw == null) {
return null;
}
final String value = '$raw'.trim();
if (value.isEmpty) {
return null;
}
return DateTime.tryParse(value)?.toLocal();
}
double? _parseConfidence(Object? raw) {
if (raw == null) {
return null;
}
final double? value = switch (raw) {
final num number => number.toDouble(),
final String text => double.tryParse(text),
_ => null,
};
if (value == null) {
return null;
}
final num clamped = value.clamp(0, 1);
return clamped.toDouble();
}
+107
View File
@@ -0,0 +1,107 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const String _llmApiKeyStorageKey = 'llm_api_key';
const String _llmProviderStorageKey = 'llm_provider';
enum LlmProvider { openai, anthropic, google }
class LlmConfigState {
const LlmConfigState({
this.apiKeyConfigured = false,
this.provider = LlmProvider.openai,
this.isConfigured = false,
});
final bool apiKeyConfigured;
final LlmProvider provider;
final bool isConfigured;
LlmConfigState copyWith({
bool? apiKeyConfigured,
LlmProvider? provider,
bool? isConfigured,
}) {
return LlmConfigState(
apiKeyConfigured: apiKeyConfigured ?? this.apiKeyConfigured,
provider: provider ?? this.provider,
isConfigured: isConfigured ?? this.isConfigured,
);
}
}
class LlmConfigNotifier extends Notifier<LlmConfigState> {
Future<void>? _initializeFuture;
@override
LlmConfigState build() {
return const LlmConfigState();
}
Future<void> initialize() async {
final Future<void>? inFlight = _initializeFuture;
if (inFlight != null) {
return inFlight;
}
_initializeFuture = _initializeFromStorage();
return _initializeFuture;
}
Future<void> _initializeFromStorage() async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
final String? storedKey = await secureStorage.read(
key: _llmApiKeyStorageKey,
);
final String? storedProvider = await secureStorage.read(
key: _llmProviderStorageKey,
);
state = LlmConfigState(
apiKeyConfigured: storedKey != null && storedKey.isNotEmpty,
provider: storedProvider != null
? LlmProvider.values.firstWhere(
(LlmProvider p) => p.name == storedProvider,
orElse: () => LlmProvider.openai,
)
: LlmProvider.openai,
isConfigured: storedKey != null && storedKey.isNotEmpty,
);
}
Future<void> setApiKey(String apiKey) async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
if (apiKey.isEmpty) {
await secureStorage.delete(key: _llmApiKeyStorageKey);
} else {
await secureStorage.write(key: _llmApiKeyStorageKey, value: apiKey);
}
state = state.copyWith(
apiKeyConfigured: apiKey.isNotEmpty,
isConfigured: apiKey.isNotEmpty,
);
}
Future<void> setProvider(LlmProvider provider) async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
await secureStorage.write(
key: _llmProviderStorageKey,
value: provider.name,
);
state = state.copyWith(provider: provider);
}
Future<String?> getApiKey() async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
return secureStorage.read(key: _llmApiKeyStorageKey);
}
Future<void> clearApiKey() async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
await secureStorage.delete(key: _llmApiKeyStorageKey);
state = state.copyWith(apiKeyConfigured: false, isConfigured: false);
}
}
final llmConfigProvider = NotifierProvider<LlmConfigNotifier, LlmConfigState>(
LlmConfigNotifier.new,
);
+328
View File
@@ -0,0 +1,328 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/llm/captured_fact_draft_parser.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
class LlmService {
LlmService(this._ref);
final Ref _ref;
Future<LlmConfigState> get _config async {
final state = _ref.read(llmConfigProvider);
if (!state.isConfigured) {
throw Exception('LLM not configured');
}
return state;
}
Future<List<GeneratedSignal>> generateSignals(
List<PersonProfile> people,
) async {
final config = await _config;
final apiKey = await _ref.read(llmConfigProvider.notifier).getApiKey();
if (apiKey == null || apiKey.isEmpty) {
throw Exception('No API key available');
}
final systemPrompt =
'''You are a relationship assistant that helps users maintain meaningful connections with their friends and family.
Based on the person's profile data, generate helpful signals such as:
- Check-in reminders
- Gift ideas
- Follow-up suggestions
- Relationship maintenance tips
Return your response as a JSON array of signals, each with:
- title: short actionable title
- description: brief explanation
- type: "recommendation", "gift", "followup", or "reminder"
- personId: the person's ID (use a valid ID from the input if applicable)
Only return the JSON array, no other text.''';
final peopleContext = people
.map(
(p) =>
'- ${p.name} (${p.relationship}): tags=${p.tags.join(", ")}, notes=${p.notes.isEmpty ? "none" : p.notes}, affinity=${p.affinityScore}',
)
.join('\n');
final userPrompt =
'''Here are the people in my life:
$peopleContext
Generate 3-5 helpful relationship signals for these people.''';
try {
final response = await _callLlm(
provider: config.provider,
apiKey: apiKey,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
return _parseSignalsResponse(response, people);
} catch (e) {
throw Exception('Failed to generate signals: $e');
}
}
Future<String> completeText({
required String systemPrompt,
required String userPrompt,
}) async {
final config = await _config;
final String? apiKey = await _ref
.read(llmConfigProvider.notifier)
.getApiKey();
if (apiKey == null || apiKey.isEmpty) {
throw Exception('No API key available');
}
return _callLlm(
provider: config.provider,
apiKey: apiKey,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
}
Future<CapturedFactDraft?> suggestCaptureDraft(SharedPayload payload) async {
final config = await _config;
final String? apiKey = await _ref
.read(llmConfigProvider.notifier)
.getApiKey();
if (apiKey == null || apiKey.isEmpty) {
throw Exception('No API key available');
}
final String systemPrompt =
'''You analyze shared messages and links for a relationship-tracking app.
Return exactly one JSON object with:
- type: one of "note", "like", "dislike", "importantDate", "giftIdea", "placeIdea", "activityIdea", "misc"
- text: cleaned user-facing summary
- label: short optional label
- dateValue: ISO-8601 date if the content clearly describes an important date, otherwise null
- confidence: number from 0 to 1
- isSensitive: true only if the content looks private or sensitive
Be conservative. If the content is ambiguous, prefer "note". Return JSON only.''';
final String userPrompt =
'''Source app: ${payload.sourceApp}
Sender: ${payload.sourceDisplayName ?? "unknown"}
Platform: ${payload.platform}
URL: ${payload.url ?? "none"}
Raw text:
${payload.rawText}''';
try {
final String response = await _callLlm(
provider: config.provider,
apiKey: apiKey,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
return parseCapturedFactDraftSuggestion(
response,
fallbackPayload: payload,
);
} catch (e) {
throw Exception('Failed to suggest share capture draft: $e');
}
}
Future<String> _callLlm({
required LlmProvider provider,
required String apiKey,
required String systemPrompt,
required String userPrompt,
}) async {
final dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 20),
sendTimeout: const Duration(seconds: 20),
),
);
switch (provider) {
case LlmProvider.openai:
return _callOpenAI(dio, apiKey, systemPrompt, userPrompt);
case LlmProvider.anthropic:
return _callAnthropic(dio, apiKey, systemPrompt, userPrompt);
case LlmProvider.google:
return _callGoogleAI(dio, apiKey, systemPrompt, userPrompt);
}
}
Future<String> _callOpenAI(
Dio dio,
String apiKey,
String systemPrompt,
String userPrompt,
) async {
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'https://api.openai.com/v1/chat/completions',
options: Options(
headers: {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
},
),
data: jsonEncode({
'model': 'gpt-4o-mini',
'messages': [
{'role': 'system', 'content': systemPrompt},
{'role': 'user', 'content': userPrompt},
],
'max_tokens': 1000,
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> choices = responseData['choices'] as List<dynamic>;
final Map<String, dynamic> firstChoice = choices[0] as Map<String, dynamic>;
final Map<String, dynamic> message =
firstChoice['message'] as Map<String, dynamic>;
final String content = message['content'] as String;
return content;
}
Future<String> _callAnthropic(
Dio dio,
String apiKey,
String systemPrompt,
String userPrompt,
) async {
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'https://api.anthropic.com/v1/messages',
options: Options(
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
),
data: jsonEncode({
'model': 'claude-3-haiku-20240307',
'system': systemPrompt,
'messages': [
{'role': 'user', 'content': userPrompt},
],
'max_tokens': 1000,
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> contentList = responseData['content'] as List<dynamic>;
final Map<String, dynamic> firstContent =
contentList[0] as Map<String, dynamic>;
final String content = firstContent['text'] as String;
return content;
}
Future<String> _callGoogleAI(
Dio dio,
String apiKey,
String systemPrompt,
String userPrompt,
) async {
final Response<Map<String, dynamic>>
response = await dio.post<Map<String, dynamic>>(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
options: Options(headers: {'Content-Type': 'application/json'}),
queryParameters: {'key': apiKey},
data: jsonEncode({
'systemInstruction': {
'parts': [
{'text': systemPrompt},
],
},
'contents': [
{
'parts': [
{'text': userPrompt},
],
},
],
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> candidates =
responseData['candidates'] as List<dynamic>;
final Map<String, dynamic> firstCandidate =
candidates[0] as Map<String, dynamic>;
final Map<String, dynamic> content =
firstCandidate['content'] as Map<String, dynamic>;
final List<dynamic> parts = content['parts'] as List<dynamic>;
final Map<String, dynamic> firstPart = parts[0] as Map<String, dynamic>;
final String contentText = firstPart['text'] as String;
return contentText;
}
List<GeneratedSignal> _parseSignalsResponse(
String response,
List<PersonProfile> people,
) {
try {
final jsonStart = response.indexOf('[');
final jsonEnd = response.lastIndexOf(']');
if (jsonStart == -1 || jsonEnd == -1) {
return [];
}
final jsonStr = response.substring(jsonStart, jsonEnd + 1);
final List<dynamic> items = jsonDecode(jsonStr) as List<dynamic>;
return items.map((item) {
final Map<String, dynamic> itemMap = item as Map<String, dynamic>;
final String? personId = itemMap['personId'] as String?;
final validPersonId =
personId != null && people.any((p) => p.id == personId)
? personId
: (people.isNotEmpty ? people.first.id : null);
return GeneratedSignal(
title: itemMap['title'] as String? ?? 'Check in',
description: itemMap['description'] as String? ?? '',
type: itemMap['type'] as String? ?? 'recommendation',
personId: validPersonId,
);
}).toList();
} catch (e) {
return [];
}
}
}
class GeneratedSignal {
const GeneratedSignal({
required this.title,
required this.description,
required this.type,
this.personId,
});
final String title;
final String description;
final String type;
final String? personId;
}
final llmServiceProvider = Provider<LlmService>((Ref ref) {
return LlmService(ref);
});