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 VICO (SEMPRE PRIMEIRO) messages.add({ 'role': 'system', 'content': r'''Tu és "Vico", o Assistente IA oficial do Learn It. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. Respondes sempre como o Vico. 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. IMPORTANTE: NUNCA uses LaTeX ou símbolos como $ ou $$ para fórmulas matemáticas. Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √). IMPORTANTE - RESPOSTAS COMPLETAS: - NUNCA termines respostas com dois pontos (:). - NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ". - SEMPRE completa as frases e fornece a resposta completa. - Se precisares de explicar um cálculo, explica-o completamente com o resultado final. - Se precisares de definir algo, fornece a definição completa.''', }); // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore final conversationId = ChatMemoryService.currentConversationId; if (conversationId != null) { final conversationHistory = await ChatMemoryService.getConversationMessages( conversationId: conversationId, 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: 10, maxChunks: 20, ); 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 Vico identity (for legacy calls) static const String _systemMessage = r'''Tu és "Vico", o Assistente IA oficial do Learn It. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. Respondes sempre como o Vico. 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. IMPORTANTE: NUNCA uses LaTeX ou símbolos como $ ou $$ para fórmulas matemáticas. Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √). IMPORTANTE - RESPOSTAS COMPLETAS: - NUNCA termines respostas com dois pontos (:). - NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ". - SEMPRE completa as frases e fornece a resposta completa. - Se precisares de explicar um cálculo, explica-o completamente com o resultado final. - Se precisares de definir algo, fornece a definição completa.'''; /// 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']; var content = message?['content'] ?? ''; // Post-process to remove LaTeX symbols content = _removeLaTeXSymbols(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) { Logger.info('Ollama service available'); return true; } 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; } } /// Remove LaTeX symbols from AI response static String _removeLaTeXSymbols(String text) { // Remove patterns like $$...$$ (display math) var cleaned = text.replaceAll(RegExp(r'\$\$[^$]*\$\$'), ''); // Remove patterns like $...$ (inline math) - be more careful // Only remove when properly closed cleaned = cleaned.replaceAllMapped( RegExp(r'\$[^$\n]+?\$'), (match) => match.group(0)!.replaceAll(r'$', ''), ); // Remove any remaining standalone $ symbols cleaned = cleaned.replaceAll(RegExp(r'(? generateQuiz( String prompt, { bool isMathematics = false, }) async { final systemPrompt = isMathematics ? _getMathematicsSystemPrompt() : _getTextBasedSystemPrompt(); final messages = >[ {'role': 'system', 'content': systemPrompt}, {'role': 'user', 'content': prompt}, ]; final raw = await _callOllamaAPIWithMessages(messages); // Filter out table questions for mathematics if (isMathematics) { return _filterTableQuestions(raw); } return raw; } /// Filter out questions that reference tables, graphs, or specific dates static String _filterTableQuestions(String json) { try { final List questions = jsonDecode(json); final List> filtered = []; // Keywords that indicate table/graph dependence final tableKeywords = [ 'tabela', 'gráfico', 'dia 1/', 'início de', 'final de', 'tendência', 'evolução', 'ao longo do tempo', 'percentagem no início', 'percentagem no final', 'ano foi de', 'dia específico', 'data específica', ]; for (final q in questions) { if (q is Map) { final questionText = (q['q'] as String? ?? '').toLowerCase(); // Check if question contains table keywords final hasTableKeyword = tableKeywords.any( (keyword) => questionText.contains(keyword.toLowerCase()), ); // Skip questions with table keywords if (!hasTableKeyword) { filtered.add(q); } } } // If filtered list is empty, return original to avoid empty response if (filtered.isEmpty) { Logger.warning( 'All questions filtered out as table questions, returning original', ); return json; } return jsonEncode(filtered); } catch (e) { Logger.error('Error filtering table questions: $e'); return json; } } /// System prompt for mathematics quizzes static String _getMathematicsSystemPrompt() { return '''És um assistente educativo especializado em criar EXERCÍCIOS DE MATEMÁTICA. REGRAS CRÍTICAS: 1. ANALISA TODO O CONTEÚDO fornecido: - Lê TODO o documento do início ao fim - Identifica TODOS os tópicos e tipos de exercícios presentes - NÃO te limites apenas aos primeiros exercícios - Cria perguntas sobre TODOS os tópicos encontrados no documento 2. MANTÉM o NÍVEL DE DIFICULDADE da ficha original: - Analisa a complexidade dos exercícios na ficha - Cria exercícios com o MESMO nível de dificuldade - NÃO faças perguntas mais avançadas do que o que está na ficha - Se a ficha tem exercícios simples, cria exercícios simples - Se a ficha tem exercícios complexos, cria exercícios complexos 3. ESTRICTAMENTE PROIBIDO criar perguntas que envolvam tabelas ou gráficos: - NUNCA faças perguntas que dependam de dados de tabelas - NUNCA faças perguntas que dependam de dados de gráficos - NUNCA faças perguntas sobre "tendência" ou "evolução ao longo do tempo" - NUNCA faças perguntas com datas específicas (ex: "dia 1/1/2017", "início de 2017") - NUNCA faças perguntas sobre "percentagem no início" ou "percentagem no final" - Se a ficha tem exercícios com tabelas, adapta-os para usar valores diretamente no texto - Fornece os dados necessários diretamente no texto da pergunta 4. Cria perguntas COMPLETAMENTE INDEPENDENTES: - CADA pergunta deve ser respondida SEM depender de outras perguntas - NUNCA faças referências à "pergunta anterior" ou "pergunta seguinte" - NUNCA uses resultados de perguntas anteriores em novas perguntas - CADA pergunta deve ter TODOS os dados necessários no seu enunciado - O aluno deve conseguir responder a qualquer pergunta independentemente da ordem 5. Usa VALORES DIFERENTES do conteúdo: - Os valores numéricos nas perguntas podem ser DIFERENTES dos que estão no PDF - Usa valores que mantenham o mesmo tipo de problema mas com números diferentes - Exemplo: se o PDF tem "um prisma de 5cm", podes usar "um prisma de 7cm" - O importante é manter a ESTRUTURA e TIPO de problema, não os valores exatos 6. EXEMPLOS DE PERGUNTAS PROIBIDAS (NÃO faças estas): - "Qual é a percentagem da área afetada pela vespa no início do dia 1/1/2017?" (PROIBIDO - depende de tabela) - "Com o passar do tempo, a área afetada tende para:" (PROIBIDO - depende de gráfico/evolução) - "Em que ano a produção foi de X toneladas?" (PROIBIDO - depende de tabela) - "Usando o resultado da pergunta anterior, calcule..." (PROIBIDO - depende de pergunta anterior) - "Considerando o valor calculado acima, determine..." (PROIBIDO - depende de pergunta anterior) 7. EXEMPLOS DE PERGUNTAS PERMITIDAS (faz estas): - "Um prisma tem base quadrada com 5cm de lado e altura 12cm. Qual é o volume?" (PERMITIDO - dados diretos, independente) - "Calcule a área de um círculo com raio 7cm." (PERMITIDO - dados diretos, independente) - "Resolva a equação 2x + 5 = 15." (PERMITIDO - dados diretos, independente) - "Uma esfera tem raio de 3 metros. Determine o seu volume." (PERMITIDO - dados diretos, independente) 8. ANALISA OS EXEMPLOS DE EXERCÍCIOS no contexto fornecido: - Identifica os TIPOS de problemas que aparecem na ficha (ex: determinar volume de sólidos, planos que decompõem prismas, equações, frações, etc.) - Repara na COMPLEXIDADE e estrutura dos exercícios originais - Gera exercícios NOVOS que seguem a MESMA estrutura e complexidade mas com VALORES DIFERENTES 9. NÃO copies perguntas do conteúdo fornecido 10. Usa valores diferentes mas mantém o TIPO e ESTRUTURA do problema 11. INCLUI TODOS OS DADOS NECESSÁRIOS no texto da pergunta: - Fornece TODOS os valores numéricos necessários - Exemplo: "Um prisma tem base quadrada com 5cm de lado e altura 12cm. Qual é o volume?" - NUNCA faças perguntas que dependam de dados que não forneces no texto 12. Na explicação, usa texto normal, SEM LaTeX: - Escreve "a fração é 15/44" em vez de "\\frac{15}{44}" - Escreve "raiz quadrada de 25" em vez de "\\sqrt{25}" - Escreve "x ao quadrado" em vez de "x^2" - Apenas usa LaTeX na própria pergunta se for estritamente necessário para a notação matemática 13. Cria exercícios variados sobre TODOS os tópicos: - Diferentes valores numéricos - Diferentes contextos quando aplicável - Mesmo TIPO e complexidade de problema matemático - VARIADOS entre todos os tópicos do documento 14. EXEMPLOS DE TIPOS DE EXERCÍCIOS DE MATEMÁTICA: - Determinar volumes de sólidos (prismas, pirâmides, cilindros, cones, esferas) - Planos que decompõem sólidos em partes geometricamente iguais - Equações lineares e quadráticas - Sistemas de equações - Funções e gráficos - Geometria analítica - Trigonometria - Probabilidade e estatística - Cálculo de áreas e perímetros - Frações e números racionais - Potências e raízes FORMATO JSON: [{"q":"Pergunta com dados completos incluídos","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação em texto normal sem LaTeX"}] ans é o índice (0-3) da opção correcta.'''; } /// System prompt for text-based subject quizzes static String _getTextBasedSystemPrompt() { return '''És um assistente educativo especializado em criar quizzes pedagógicos. REGRAS CRÍTICAS: 1. Cria perguntas de compreensão, análise e síntese 2. Baseia-te nos conceitos e temas do conteúdo 3. Evita perguntas de cópia direta 4. Foca em entender e aplicar os conceitos 5. INCLUI CONTEXTO SUFICIENTE EM CADA PERGUNTA: - Cada pergunta deve ser compreensível por si só - Fornece contexto necessário sobre o assunto da pergunta - Exemplo PROIBIDO: "Em que ano a vespa asiática afetou mais de 50% da área?" (sem contexto) - Exemplo PERMITIDO: "De acordo com o estudo sobre a vespa asiática em Portugal, em que ano esta espécie afetou mais de 50% da área de distribuição?" - Exemplo PROIBIDO: "Qual foi a principal causa?" (sem contexto) - Exemplo PERMITIDO: "Qual foi a principal causa da extinção do dodo, segundo o texto?" 6. EVITA perguntas que dependam de dados específicos não mencionados: - NUNCA faças perguntas sobre "ano X" ou "data X" sem especificar de que ano/data se trata - NUNCA faças perguntas sobre "porcentagem X" sem explicar o contexto - NUNCA faças perguntas que dependam de tabelas ou gráficos 7. EXEMPLOS DE PERGUNTAS BEM FORMULADAS: - "De acordo com o texto sobre a vespa asiática, qual é o principal impacto desta espécie na biodiversidade portuguesa?" - "O estudo sobre a mudança climática menciona que a temperatura média aumentou. Qual foi a principal causa mencionada?" - "Segundo o documento sobre a história de Portugal, qual foi o resultado da Batalha de Aljubarrota?" FORMATO JSON: [{"q":"Pergunta com contexto suficiente","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação"}] ans é o índice (0-3) da opção correcta.'''; } /// 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 é small talk (saudação, conversa casual) — sem necessidade de contexto PDF static bool _isSmallTalk(String query) { final q = query.trim().toLowerCase(); const triggers = [ 'olá', 'ola', 'oi', 'ei', 'hey', 'hi', 'tudo bem', 'tudo bom', 'como estás', 'como estas', 'como vai', 'bom dia', 'boa tarde', 'boa noite', 'obrigado', 'obrigada', 'muito obrigado', 'muito obrigada', 'valeu', 'ok', 'okay', 'fixe', 'ótimo', 'otimo', 'perfeito', 'excelente', 'adeus', 'até logo', 'até mais', 'tchau', 'quem és', 'quem es', 'quem é o vico', 'o que és', 'o que fazes', 'apresenta-te', 'apresentate', ]; // Exact match or starts with a trigger phrase if (triggers.any( (t) => q == t || q.startsWith('$t ') || q.startsWith('$t,'), )) { return true; } // Very short messages with no educational keywords final words = q.split(RegExp(r'\s+')); if (words.length <= 3) { const eduKeywords = [ 'explica', 'define', 'o que é', 'como funciona', 'porque', 'fórmula', 'formula', 'exemplo', 'exercício', 'exercicio', 'matéria', 'materia', 'tema', 'conceito', 'resumo', ]; if (!eduKeywords.any((k) => q.contains(k))) return true; } return false; } /// 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()); } /// Build dynamic system prompt based on selected materials and discipline static String _buildSystemPrompt({ List? selectedMaterialNames, String? disciplineName, bool isMathematics = false, }) { final buffer = StringBuffer(); buffer.writeln( r'''Tu és "Vico", o Assistente IA oficial do Learn It — uma plataforma educativa portuguesa.''', ); buffer.writeln(); buffer.writeln('Nunca referes o nome do modelo de linguagem.'); buffer.writeln('Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo.'); buffer.writeln('Respondes sempre como o Vico.'); buffer.writeln(); buffer.writeln('Tens personalidade simpática, confiante e motivadora.'); buffer.writeln( 'Podes responder normalmente a saudações, agradecimentos e conversa casual — sê natural e amigável.', ); buffer.writeln(); // Material context section if (disciplineName != null && disciplineName.isNotEmpty) { buffer.writeln('DISCIPLINA ATUAL: $disciplineName'); } if (selectedMaterialNames != null && selectedMaterialNames.isNotEmpty) { buffer.writeln( 'MATERIAIS SELECIONADOS: ${selectedMaterialNames.join(", ")}', ); } if (disciplineName != null || (selectedMaterialNames != null && selectedMaterialNames.isNotEmpty)) { buffer.writeln(); } // LaTeX prohibition (always) buffer.writeln( 'IMPORTANTE: NUNCA uses LaTeX ou símbolos como \$ ou \$\$ para fórmulas matemáticas.', ); buffer.writeln( 'Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).', ); buffer.writeln(); // Discipline-specific rules if (isMathematics) { buffer.writeln('REGRAS CRÍTICAS PARA MATEMÁTICA:'); buffer.writeln( '- O material fornecido serve como REFERÊNCIA de matéria, fórmulas e métodos.', ); buffer.writeln( '- Podes CRIAR exercícios NOVOS que sigam a mesma lógica, fórmulas e métodos do material.', ); buffer.writeln( '- NÃO copies exercícios diretamente do material — cria variações com valores diferentes.', ); buffer.writeln( '- Se o aluno pedir exercícios, cria exercícios novos baseados nos CONCEITOS e FÓRMULAS do material.', ); buffer.writeln( '- Se a resposta não estiver no contexto, usa o teu conhecimento matemático geral para ajudar, mas prioriza os métodos do material.', ); buffer.writeln( '- SEMPRE fornece o passo a passo completo com o resultado final.', ); } else { buffer.writeln('REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS:'); buffer.writeln( '- Quando te for fornecido contexto de materiais do professor (indicado com [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo.', ); buffer.writeln( '- NÃO inventes factos educativos, NÃO uses conhecimento externo sobre matérias escolares.', ); buffer.writeln( '- Se a resposta educativa não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível."', ); } buffer.writeln( '- Para conversa casual e saudações não precisas de contexto — responde livremente com a tua personalidade.', ); buffer.writeln(); // Material handling rules (always) buffer.writeln('IMPORTANTE - COMO TRATAR MATERIAIS SELECIONADOS:'); buffer.writeln( '- Quando o aluno selecionar materiais (PDFs, fichas, exames), ASSUME que o aluno quer ajuda com esses materiais.', ); buffer.writeln( '- NUNCA perguntes "que ficha pretendo resolver" ou "o que pretendo resolver".', ); buffer.writeln( '- NUNCA perguntes "em que posso ajudar" quando materiais estão selecionados.', ); buffer.writeln( '- ASSUME automaticamente que o aluno quer explicação, resolução ou ajuda com os materiais selecionados.', ); if (selectedMaterialNames != null && selectedMaterialNames.isNotEmpty) { buffer.writeln( '- Se a pergunta do aluno for vaga (ex: "ajuda"), oferece ajuda específica sobre os materiais selecionados.', ); } buffer.writeln(); // Greeting rules (always) buffer.writeln('IMPORTANTE - NÃO REPITAS SAUDAÇÕES:'); buffer.writeln( '- NUNCA começes uma resposta com "Olá", "Olá de novo", "Oi", "Bom dia", etc., a menos que seja a PRIMEIRA mensagem da conversa.', ); buffer.writeln( '- Nas respostas subsequentes, vai DIRETO ao assunto sem saudar novamente.', ); buffer.writeln( '- Se já saudaste o utilizador uma vez na conversa, NUNCA o faças de novo.', ); buffer.writeln(); // Complete response rules (always) buffer.writeln('IMPORTANTE - RESPOSTAS COMPLETAS:'); buffer.writeln('- NUNCA termines respostas com dois pontos (:).'); buffer.writeln( '- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".', ); buffer.writeln( '- SEMPRE completa as frases e fornece a resposta completa.', ); buffer.writeln( '- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.', ); buffer.writeln( '- Se precisares de definir algo, fornece a definição completa.', ); return buffer.toString(); } /// Simple ask method for chat UI - uses conversation memory, teacher PDFs, and Vico identity /// [selectedMaterialIds] — se fornecido, limita o RAG apenas aos materiais escolhidos pelo aluno /// [selectedMaterialNames] — nomes dos materiais selecionados (para contextualizar a IA) /// [disciplineName] — nome da disciplina (ex: "Matemática A", "Português") /// [isMathematics] — true se for matemática ou disciplina quantitativa static Future ask( String userQuery, { List? selectedMaterialIds, List? selectedMaterialNames, String? disciplineName, bool isMathematics = false, }) async { Logger.info('USING RAG AI SERVICE'); // PASSO 1 — Criar a lista messages vazia List> messages = []; // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO) final systemPrompt = _buildSystemPrompt( selectedMaterialNames: selectedMaterialNames, disciplineName: disciplineName, isMathematics: isMathematics, ); messages.add({'role': 'system', 'content': systemPrompt}); // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore (máx 10 para manter contexto) final conversationId = ChatMemoryService.currentConversationId; var lastHistoryMessageIsDuplicate = false; if (conversationId != null) { final conversationHistory = await ChatMemoryService.getConversationMessages( conversationId: conversationId, limit: 10, ); for (final msg in conversationHistory) { messages.add({ 'role': msg['role'] as String, 'content': msg['content'] as String, }); } // Verificar se a última mensagem do histórico já é a pergunta atual // (evita duplicação quando a UI guardou a mensagem antes de chamar ask()) if (conversationHistory.isNotEmpty) { final lastMsg = conversationHistory.last; if (lastMsg['role'] == 'user' && lastMsg['content'] == userQuery) { lastHistoryMessageIsDuplicate = true; Logger.info( 'Last history message matches current query — skipping duplicate user message', ); } } } // 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) // Small talk: skip PDF lookup entirely and go straight to model if (_isSmallTalk(userQuery)) { Logger.info('Small talk detected — skipping PDF lookup'); if (!lastHistoryMessageIsDuplicate) { messages.add({'role': 'user', 'content': userQuery}); } // Only save to Firestore if not already saved by UI if (!lastHistoryMessageIsDuplicate) { await ChatMemoryService.saveMessage(role: 'user', content: userQuery); } final response = await _callOllamaAPIWithMessages(messages); await ChatMemoryService.saveMessage(role: 'assistant', content: response); return response; } // 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: 10, maxChunks: 20, 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) { // Sem contexto encontrado — responder com base na personalidade mas sem inventar conteúdo if (!lastHistoryMessageIsDuplicate) { messages.add({'role': 'user', 'content': userQuery}); } // Only save to Firestore if not already saved by UI if (!lastHistoryMessageIsDuplicate) { await ChatMemoryService.saveMessage(role: 'user', content: userQuery); } final response = await _callOllamaAPIWithMessages(messages); await ChatMemoryService.saveMessage(role: 'assistant', content: response); return response; } // 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; if (!lastHistoryMessageIsDuplicate) { messages.add({'role': 'user', 'content': userContent}); } Logger.info( 'USING RAG AI SERVICE - Built messages array with ${messages.length} messages', ); // Only save to Firestore if not already saved by UI if (!lastHistoryMessageIsDuplicate) { 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; } }