Files
rely/lib/core/llm/llm_service.dart
T
Rijad Zuzo f655adfbea 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.
2026-05-17 00:17:20 +02:00

329 lines
9.9 KiB
Dart

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);
});