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 get _config async { final state = _ref.read(llmConfigProvider); if (!state.isConfigured) { throw Exception('LLM not configured'); } return state; } Future> generateSignals( List 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 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 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 _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 _callOpenAI( Dio dio, String apiKey, String systemPrompt, String userPrompt, ) async { final Response> response = await dio .post>( '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 responseData = response.data!; final List choices = responseData['choices'] as List; final Map firstChoice = choices[0] as Map; final Map message = firstChoice['message'] as Map; final String content = message['content'] as String; return content; } Future _callAnthropic( Dio dio, String apiKey, String systemPrompt, String userPrompt, ) async { final Response> response = await dio .post>( '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 responseData = response.data!; final List contentList = responseData['content'] as List; final Map firstContent = contentList[0] as Map; final String content = firstContent['text'] as String; return content; } Future _callGoogleAI( Dio dio, String apiKey, String systemPrompt, String userPrompt, ) async { final Response> response = await dio.post>( '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 responseData = response.data!; final List candidates = responseData['candidates'] as List; final Map firstCandidate = candidates[0] as Map; final Map content = firstCandidate['content'] as Map; final List parts = content['parts'] as List; final Map firstPart = parts[0] as Map; final String contentText = firstPart['text'] as String; return contentText; } List _parseSignalsResponse( String response, List 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 items = jsonDecode(jsonStr) as List; return items.map((item) { final Map itemMap = item as Map; 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((Ref ref) { return LlmService(ref); });