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,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);
|
||||
});
|
||||
Reference in New Issue
Block a user