import 'dart:convert'; import 'package:http/http.dart' as http; import '../utils/logger.dart'; import 'rag_service.dart'; import 'chat_memory_service.dart'; import 'materials_rag_service.dart'; import '../models/content_chunk.dart'; /// Service for RAG-enhanced AI communication using Ollama API class RAGAIService { static const String _baseUrl = 'http://89.114.196.110:11434/api/chat'; static const String _model = 'qwen3-coder:30b'; static const int _timeoutSeconds = 60; static const int _maxTokens = 4000; /// Generate AI response with RAG context, conversation memory, and teacher materials static Future generateRAGResponse({ required String userQuery, required String context, required TutorMode mode, required List sources, }) async { try { Logger.info('Generating RAG response with ${sources.length} sources'); // PASSO 1 — Criar a lista messages vazia List> messages = []; // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO) messages.add({ 'role': 'system', 'content': '''Tu és "O GOAT", o Assistente IA oficial do Teach it. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. Respondes sempre como o GOAT. Tens personalidade confiante, motivadora e orgulhosa. Ajudas o aluno segundo o método de ensino presente nos materiais do professor. Usas formatação Markdown clara e organizada.''', }); // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore final conversationHistory = await ChatMemoryService.getRecentMessages(limit: 20); for (final msg in conversationHistory) { messages.add({ 'role': msg['role'] as String, 'content': msg['content'] as String, }); } // PASSO 4 — BUSCAR PDFs DO PROFESSOR NO Firebase Storage (RAG CHUNK RETRIEVAL) final pdfContext = await MaterialsRAGService.getRelevantChunks( userQuery: userQuery, maxMaterials: 5, maxChunks: 5, ); if (pdfContext.isNotEmpty) { messages.add({ 'role': 'system', 'content': pdfContext, // Já vem formatado com [CHUNK 1], [CHUNK 2], etc. }); } // PASSO 5 — SÓ AGORA adicionar a pergunta do user messages.add({ 'role': 'user', 'content': userQuery, }); // Log do tamanho do array para verificação Logger.info('Built messages array with ${messages.length} messages for API'); // Save user message to Firestore (after building the messages array) await ChatMemoryService.saveMessage( role: 'user', content: userQuery, ); // Call Ollama API with complete messages array final response = await _callOllamaAPIWithMessages(messages); // Save AI response to memory await ChatMemoryService.saveMessage( role: 'assistant', content: response, ); // Process response and create RAGResponse final ragResponse = _createRAGResponse( query: userQuery, aiResponse: response, mode: mode, sources: sources, ); Logger.info('RAG response generated successfully'); return ragResponse; } catch (e) { Logger.error('Error generating RAG response: $e'); return _createErrorResponse(userQuery, mode, e.toString()); } } /// Build RAG-enhanced prompt for Ollama with teacher materials static String _buildRAGPrompt( String userQuery, String context, String materialsContext, TutorMode mode, ) { final promptBuilder = StringBuffer(); // System prompt with role and instructions promptBuilder.writeln( 'Você é um assistente educacional especializado da Escola Profissional de Vila do Conde.', ); promptBuilder.writeln( 'Sua função é ajudar os alunos usando APENAS o conteúdo fornecido abaixo.', ); promptBuilder.writeln( 'NÃO use conhecimento externo. Baseie todas as respostas exclusivamente no material educacional.', ); promptBuilder.writeln('Seja claro, paciente e educativo.\n'); // Add teacher materials (PDFs) if available if (materialsContext.isNotEmpty) { promptBuilder.writeln('=== MATERIAL DO PROFESSOR ==='); promptBuilder.writeln(materialsContext); promptBuilder.writeln('\n=== FIM DO MATERIAL DO PROFESSOR ===\n'); } // Add context promptBuilder.writeln('=== CONTEÚDO EDUCACIONAL DISPONÍVEL ==='); promptBuilder.writeln(context); promptBuilder.writeln('\n=== FIM DO CONTEÚDO ===\n'); // Mode-specific instructions promptBuilder.writeln('=== MODO DE TUTORIA ==='); switch (mode) { case TutorMode.explanation: promptBuilder.writeln('MODO: EXPLICAÇÃO DETALHADA'); promptBuilder.writeln( 'Forneça explicações claras e detalhadas baseadas exclusivamente no conteúdo.', ); promptBuilder.writeln( 'Use exemplos do material e estruture a resposta de forma lógica.', ); promptBuilder.writeln( 'Se o conteúdo não tiver informação suficiente, indique isso claramente.', ); break; case TutorMode.tutor: promptBuilder.writeln('MODO: TUTORIA SOCRÁTICA'); promptBuilder.writeln( 'Use o método socrático - faça perguntas que guiem o aluno.', ); promptBuilder.writeln('Baseie-se apenas no conteúdo fornecido.'); promptBuilder.writeln('Incentive o pensamento crítico e a descoberta.'); break; case TutorMode.exploration: promptBuilder.writeln('MODO: EXPLORAÇÃO E DESCOBERTA'); promptBuilder.writeln( 'Ajude o aluno a explorar o conceito através de descoberta.', ); promptBuilder.writeln( 'Conecte ideias relacionadas presentes no conteúdo.', ); promptBuilder.writeln( 'Sugira investigações baseadas no material disponível.', ); break; } // User query promptBuilder.writeln('\n=== PERGUNTA DO ALUNO ==='); promptBuilder.writeln(userQuery); promptBuilder.writeln('\n=== RESPOSTA ==='); return promptBuilder.toString(); } /// System message for O GOAT identity (for legacy calls) static const String _systemMessage = '''Tu és "O GOAT", o Assistente IA oficial do Teach it. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. Respondes sempre como o GOAT. Tens personalidade confiante, motivadora e orgulhosa. Ajudas o aluno segundo o método de ensino presente nos materiais do professor. Usas formatação clara e organizada.'''; /// Call Ollama API with complete messages array static Future _callOllamaAPIWithMessages( List> messages, ) async { try { Logger.info('Calling Ollama API with ${messages.length} messages'); final url = Uri.parse(_baseUrl); final requestBody = { 'model': _model, 'messages': messages, 'stream': false, 'options': {'temperature': 0.7, 'top_p': 0.9, 'max_tokens': _maxTokens}, }; final response = await http .post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode(requestBody), ) .timeout(Duration(seconds: _timeoutSeconds)); if (response.statusCode == 200) { final responseData = jsonDecode(response.body); final message = responseData['message']; final content = message?['content'] ?? ''; Logger.info('Ollama API response received'); return content.trim(); } else { throw Exception('API Error: ${response.statusCode} - ${response.body}'); } } catch (e) { Logger.error('Error calling Ollama API: $e'); throw Exception('Failed to call AI service: $e'); } } /// Legacy: Call Ollama API with single prompt (for backward compatibility) static Future _callOllamaAPI(String prompt) async { return _callOllamaAPIWithMessages([ {'role': 'system', 'content': _systemMessage}, {'role': 'user', 'content': prompt}, ]); } /// Create RAGResponse from AI response static RAGResponse _createRAGResponse({ required String query, required String aiResponse, required TutorMode mode, required List sources, }) { try { // Create source citations final citations = sources .map( (chunk) => SourceCitation( contentId: chunk.contentId, chunkId: chunk.id, title: chunk.sourceDocument, concept: chunk.concept, subject: chunk.subject, excerpt: _getExcerpt(chunk.text), relevance: _calculateRelevance(query, chunk.text), pageNumber: chunk.pageNumber, ), ) .toList(); // Calculate confidence based on sources and response quality final confidence = _calculateResponseConfidence(aiResponse, sources); // Extract related concepts from sources final relatedConcepts = _extractRelatedConcepts(sources); return RAGResponse( answer: aiResponse, sources: citations, confidence: confidence, mode: mode, relatedConcepts: relatedConcepts, metadata: { 'model': _model, 'queryLength': query.length, 'responseLength': aiResponse.length, 'sourceCount': sources.length, 'processingTime': DateTime.now().millisecondsSinceEpoch, 'temperature': 0.7, }, ); } catch (e) { Logger.error('Error creating RAG response: $e'); throw Exception('Failed to create RAG response: $e'); } } /// Get excerpt from text static String _getExcerpt(String text, {int maxLength = 200}) { if (text.length <= maxLength) return text; return text.substring(0, maxLength - 3) + '...'; } /// Calculate relevance score static double _calculateRelevance(String query, String text) { final queryWords = query.toLowerCase().split(' '); final textLower = text.toLowerCase(); int matches = 0; for (final word in queryWords) { if (word.length > 2 && textLower.contains(word)) { matches++; } } return matches / queryWords.length; } /// Calculate response confidence static double _calculateResponseConfidence( String response, List sources, ) { double confidence = 0.0; // Base confidence on number of sources confidence += (sources.length / 5.0) * 0.4; // Max 0.4 for sources // Boost confidence if response mentions concepts from sources final sourceConcepts = sources.map((s) => s.concept.toLowerCase()).toSet(); final responseLower = response.toLowerCase(); int conceptMatches = 0; for (final concept in sourceConcepts) { if (responseLower.contains(concept)) { conceptMatches++; } } confidence += (conceptMatches / sourceConcepts.length) * 0.3; // Max 0.3 for concept matching // Boost confidence if response is substantial if (response.length > 100) { confidence += 0.2; } // Boost confidence if response cites sources if (responseLower.contains('fonte') || responseLower.contains('conteúdo') || responseLower.contains('material')) { confidence += 0.1; } return confidence.clamp(0.0, 1.0); } /// Extract related concepts static List _extractRelatedConcepts(List sources) { final concepts = {}; for (final source in sources) { concepts.add(source.concept); if (source.subConcept != null) { concepts.add(source.subConcept!); } } return concepts.toList()..sort(); } /// Create error response static RAGResponse _createErrorResponse( String query, TutorMode mode, String error, ) { return RAGResponse( answer: 'Desculpe, ocorreu um erro ao processar sua pergunta: $error. Por favor, tente novamente mais tarde.', sources: [], confidence: 0.0, mode: mode, relatedConcepts: [], metadata: {'error': error, 'model': _model}, ); } /// Simple chat without RAG (for fallback) static Future simpleChat(String message) async { try { Logger.info('Simple chat call'); final url = Uri.parse(_baseUrl); final requestBody = { 'model': _model, 'messages': [ { 'role': 'user', 'content': 'Responda de forma curta e direta: $message', }, ], 'stream': false, 'options': {'temperature': 0.7, 'max_tokens': 500}, }; final response = await http .post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode(requestBody), ) .timeout(Duration(seconds: _timeoutSeconds)); if (response.statusCode == 200) { final responseData = jsonDecode(response.body); final message = responseData['message']; final content = message?['content'] ?? ''; return content.trim(); } else { throw Exception('API Error: ${response.statusCode}'); } } catch (e) { Logger.error('Error in simple chat: $e'); return 'Desculpe, não consegui processar sua mensagem no momento.'; } } /// Check if Ollama service is available static Future isServiceAvailable() async { try { Logger.info('Checking Ollama service availability'); final url = Uri.parse('http://89.114.196.110:11434/api/tags'); final response = await http.get(url).timeout(Duration(seconds: 10)); if (response.statusCode == 200) { final responseData = jsonDecode(response.body); final models = responseData['models'] as List? ?? []; final hasModel = models.any( (model) => (model['name'] as String? ?? '').contains('qwen3-coder'), ); Logger.info('Ollama service available, model found: $hasModel'); return hasModel; } else { Logger.warning( 'Ollama service returned status: ${response.statusCode}', ); return false; } } catch (e) { Logger.error('Ollama service not available: $e'); return false; } } /// Get model information static Future?> getModelInfo() async { try { final url = Uri.parse('http://89.114.196.110:11434/api/tags'); final response = await http.get(url).timeout(Duration(seconds: 10)); if (response.statusCode == 200) { final responseData = jsonDecode(response.body); final models = responseData['models'] as List? ?? []; for (final model in models) { if ((model['name'] as String? ?? '').contains('qwen3-coder')) { return model as Map; } } } return null; } catch (e) { Logger.error('Error getting model info: $e'); return null; } } /// Test the service with a simple query static Future testService() async { try { final testQuery = 'Olá, você está funcionando?'; final response = await simpleChat(testQuery); return 'Service test successful: $response'; } catch (e) { return 'Service test failed: $e'; } } /// Cache do último contexto PDF enviado ao modelo — reutilizado em follow-ups static String _lastPdfContext = ''; /// Limpar contexto cacheado — chamar ao mudar de material static void clearLastContext() { _lastPdfContext = ''; Logger.info('Last PDF context cleared'); } /// Detecta se a query é um follow-up (pergunta curta/vaga sem keywords de conteúdo) static bool _isFollowUp(String query) { final q = query.trim().toLowerCase(); // Menos de 6 palavras E começa com pronome/advérbio de follow-up final words = q.split(RegExp(r'\s+')); if (words.length > 8) return false; const followUpStarters = [ 'e ', 'e o', 'e a', 'e os', 'e as', 'mas ', 'então ', 'explica', 'explique', 'explica melhor', 'melhor', 'mais detalhes', 'podes', 'pode ', 'consegues', 'e se ', 'e quando', 'dá um exemplo', 'da um exemplo', 'um exemplo', 'exemplo', 'como assim', 'o que significa', 'porquê', 'porque isso', 'e o ponto', 'e a regra', 'continua', 'continua', 'o que mais', 'mais algum', 'e depois', 'e agora', ]; return followUpStarters.any((s) => q.startsWith(s) || q == s.trim()); } /// Simple ask method for chat UI - uses conversation memory, teacher PDFs, and O GOAT identity /// [selectedMaterialIds] — se fornecido, limita o RAG apenas aos materiais escolhidos pelo aluno static Future ask( String userQuery, { List? selectedMaterialIds, }) async { Logger.info('USING RAG AI SERVICE'); // PASSO 1 — Criar a lista messages vazia List> messages = []; // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO) messages.add({ 'role': 'system', 'content': '''Tu és "O GOAT", o Assistente IA oficial do Teach it. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. Respondes sempre como o GOAT. Tens personalidade confiante, motivadora e orgulhosa. Usas formatação Markdown clara e organizada. REGRAS CRÍTICAS SOBRE O CONTEXTO: - Quando te for fornecido contexto de materiais (entre [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo. - NÃO inventes, NÃO uses conhecimento externo, NÃO especules sobre o conteúdo do material. - Se a resposta não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível." - Cita sempre de onde tiraste a informação (ex: "Segundo o material...").''', }); // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore (máx 4 para poupar heap) final conversationHistory = await ChatMemoryService.getRecentMessages(limit: 4); for (final msg in conversationHistory) { messages.add({ 'role': msg['role'] as String, 'content': msg['content'] as String, }); } // Log de confirmação de ordem do histórico if (messages.length > 1) { Logger.info('History order fixed. First message: ${messages[1]}'); } // PASSO 4 — BUSCAR PDFs DO PROFESSOR NO Firebase Storage (RAG CHUNK RETRIEVAL) // Detectar follow-up e reutilizar contexto anterior se disponível String pdfContext; if (_isFollowUp(userQuery) && _lastPdfContext.isNotEmpty) { pdfContext = _lastPdfContext; Logger.info('Follow-up detected — reusing last PDF context (${pdfContext.length} chars)'); } else { pdfContext = await MaterialsRAGService.getRelevantChunks( userQuery: userQuery, maxMaterials: 5, maxChunks: 5, selectedMaterialIds: selectedMaterialIds, ); if (pdfContext.isNotEmpty) { _lastPdfContext = pdfContext; Logger.info('PDF context sent to model (${pdfContext.length} chars): ${pdfContext.length > 300 ? pdfContext.substring(0, 300) : pdfContext}'); } } if (pdfContext.isEmpty && selectedMaterialIds != null && selectedMaterialIds.isNotEmpty) { // Contexto vazio com materiais seleccionados — retornar resposta local imediatamente const noContextReply = 'Neste momento não tenho acesso ao conteúdo do ficheiro selecionado. ' 'Tenta novamente ou faz uma pergunta geral — estou aqui para ajudar! 💪'; await ChatMemoryService.saveMessage(role: 'user', content: userQuery); await ChatMemoryService.saveMessage(role: 'assistant', content: noContextReply); return noContextReply; } if (pdfContext.isEmpty && (selectedMaterialIds == null || selectedMaterialIds.isEmpty)) { // Sem material seleccionado — pedir ao utilizador para seleccionar um const noMaterialReply = 'Para responder a perguntas sobre conteúdo, preciso que selecciones um material primeiro. ' '📚 Usa o botão de materiais para escolher um PDF e depois faz a tua pergunta!'; await ChatMemoryService.saveMessage(role: 'user', content: userQuery); await ChatMemoryService.saveMessage(role: 'assistant', content: noMaterialReply); return noMaterialReply; } // PASSO 5 — adicionar a pergunta do user (com contexto embutido se disponível) final userContent = pdfContext.isNotEmpty ? '''Usa APENAS o seguinte contexto para responder. Não uses conhecimento externo. Se a resposta não estiver no contexto, diz: "Não encontrei essa informação no material disponível." $pdfContext Pergunta: $userQuery''' : userQuery; messages.add({ 'role': 'user', 'content': userContent, }); Logger.info('USING RAG AI SERVICE - Built messages array with ${messages.length} messages'); // Save user message to Firestore await ChatMemoryService.saveMessage(role: 'user', content: userQuery); // Call API final response = await _callOllamaAPIWithMessages(messages); // Save AI response to memory await ChatMemoryService.saveMessage(role: 'assistant', content: response); return response; } }