import 'dart:math'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:http/http.dart' as http; import 'dart:convert'; import '../models/content_chunk.dart'; import '../utils/logger.dart'; import 'vector_service.dart'; /// Modes for AI tutoring enum TutorMode { explanation, // Detailed explanations tutor, // Socratic questioning exploration, // Discovery-based learning } /// Response from RAG system class RAGResponse { final String answer; final List sources; final double confidence; final TutorMode mode; final List relatedConcepts; final Map metadata; RAGResponse({ required this.answer, required this.sources, required this.confidence, required this.mode, required this.relatedConcepts, required this.metadata, }); } /// Source citation for RAG responses class SourceCitation { final String contentId; final String chunkId; final String title; final String concept; final String subject; final String excerpt; final double relevance; final int? pageNumber; SourceCitation({ required this.contentId, required this.chunkId, required this.title, required this.concept, required this.subject, required this.excerpt, required this.relevance, this.pageNumber, }); } /// Service for Retrieval-Augmented Generation pipeline class RAGService { static final FirebaseFirestore _firestore = FirebaseFirestore.instance; static const int maxContextTokens = 4000; static const int maxChunksInContext = 5; /// System message for Vico identity - ALWAYS first in every conversation static const String _systemMessage = '''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.'''; /// Process a user query through RAG pipeline static Future processQuery({ required String userQuery, required TutorMode mode, String? subject, String? concept, int? grade, double? minDifficulty, double? maxDifficulty, int maxSources = 5, }) async { try { Logger.info( 'Processing RAG query: "${userQuery.substring(0, 50)}..." in ${mode.name} mode', ); // Detect if subject is math final isMathSubject = _isMathSubject(subject); Logger.info('Subject: $subject, Is math: $isMathSubject'); // 1. Generate embedding for user query final queryEmbedding = VectorService.generateEmbedding(userQuery); // 2. Retrieve relevant content chunks final relevantChunks = await VectorService.searchSimilar( queryEmbedding: queryEmbedding, subject: subject, concept: concept, grade: grade, minDifficulty: minDifficulty, maxDifficulty: maxDifficulty, k: maxSources + 2, // Get extra for filtering threshold: 0.3, ); if (relevantChunks.isEmpty) { Logger.warning('No relevant content found for query'); return _createNoContentResponse(userQuery, mode); } // 3. Build context window with math-specific filtering final context = _buildContextWindow( relevantChunks, userQuery, mode, isMathSubject: isMathSubject, ); // 4. Generate response (this will be handled by RAGAIService) final response = await _generateResponse( query: userQuery, context: context, mode: mode, sources: relevantChunks.take(maxSources).toList(), ); Logger.info( 'RAG response generated with confidence: ${response.confidence}', ); return response; } catch (e) { Logger.error('Error processing RAG query: $e'); return _createErrorResponse(userQuery, mode, e.toString()); } } /// Build context window from relevant chunks static String _buildContextWindow( List chunks, String userQuery, TutorMode mode, { bool isMathSubject = false, }) { try { final contextBuilder = StringBuffer(); // Add context header contextBuilder.writeln('=== CONTEÚDO EDUCACIONAL RELEVANTE ===\n'); // Sort chunks by relevance and take top chunks final sortedChunks = chunks.take(maxChunksInContext).toList(); for (int i = 0; i < sortedChunks.length; i++) { final chunk = sortedChunks[i]; var chunkText = chunk.text; // Filter table data for math subjects if (isMathSubject) { chunkText = _filterTableData(chunkText); } contextBuilder.writeln('--- Fonte ${i + 1} ---'); contextBuilder.writeln('Disciplina: ${chunk.subject}'); contextBuilder.writeln('Conceito: ${chunk.concept}'); if (chunk.subConcept != null) { contextBuilder.writeln('Subconceito: ${chunk.subConcept}'); } contextBuilder.writeln('Unidade: ${chunk.unit}'); contextBuilder.writeln('Dificuldade: ${chunk.difficulty}'); if (chunk.pageNumber != null) { contextBuilder.writeln('Página: ${chunk.pageNumber}'); } contextBuilder.writeln('\nConteúdo:\n$chunkText\n'); } // Add mode-specific instructions contextBuilder.writeln('\n=== INSTRUÇÕES DE TUTORIA ==='); contextBuilder.writeln('Modo: ${_getModeInstructions(mode)}'); // Add math-specific instructions if applicable if (isMathSubject) { contextBuilder.writeln('\n=== INSTRUÇÕES PARA MATEMÁTICA ==='); contextBuilder.writeln('Para notações matemáticas:'); contextBuilder.writeln( r'- NUNCA use LaTeX ou símbolos como $ ou $$ para fórmulas', ); contextBuilder.writeln( '- Use apenas texto normal e caracteres Unicode (ex: x², ³, ¹⁄², π, √)', ); contextBuilder.writeln( '- Preserve a notação matemática original quando possível', ); contextBuilder.writeln('- Explique passo a passo os cálculos'); contextBuilder.writeln('- Use exemplos numéricos concretos'); } contextBuilder.writeln('Pergunta do Aluno: $userQuery\n'); final contextText = contextBuilder.toString(); // Check context length and truncate if necessary if (contextText.length > maxContextTokens) { Logger.warning('Context too long, truncating'); return contextText.substring(0, maxContextTokens - 100) + '...[truncated]'; } return contextText; } catch (e) { Logger.error('Error building context window: $e'); return 'Erro ao construir contexto'; } } /// Get mode-specific instructions static String _getModeInstructions(TutorMode mode) { switch (mode) { case TutorMode.explanation: return 'Forneça explicações detalhadas e claras baseadas apenas no conteúdo fornecido. Use exemplos do material e estruture a resposta de forma lógica.'; case TutorMode.tutor: return 'Use o método socrático - faça perguntas que guiem o aluno a descobrir a resposta. Baseie-se apenas no conteúdo fornecido e incentive o pensamento crítico.'; case TutorMode.exploration: return 'Ajude o aluno a explorar o conceito através de descoberta. Conecte com ideias relacionadas e sugira investigações baseadas no conteúdo.'; } } /// Generate response (placeholder - will be implemented in RAGAIService) static Future _generateResponse({ required String query, required String context, required TutorMode mode, required List sources, }) async { 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(); // Call Ollama API final url = Uri.parse('http://89.114.196.110:11434/api/chat'); // Build prompt with context final prompt = _buildPrompt(query, context, mode); final response = await http .post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'model': 'qwen3-coder:30b', 'messages': [ {'role': 'system', 'content': _systemMessage}, {'role': 'user', 'content': prompt}, ], 'stream': false, }), ) .timeout(const Duration(seconds: 60)); if (response.statusCode == 200) { final responseData = jsonDecode(response.body); var answer = responseData['message']['content'] as String; // Post-process to remove LaTeX symbols answer = _removeLaTeXSymbols(answer); final confidence = _calculateConfidence(sources); final relatedConcepts = _extractRelatedConcepts(sources); return RAGResponse( answer: answer, sources: citations, confidence: confidence, mode: mode, relatedConcepts: relatedConcepts, metadata: { 'queryLength': query.length, 'contextLength': context.length, 'sourceCount': sources.length, 'processingTime': DateTime.now().millisecondsSinceEpoch, 'apiResponse': true, }, ); } else { Logger.error('Ollama API error: ${response.statusCode}'); // Fallback to mock response final answer = _generateMockAnswer(query, context, mode); final confidence = _calculateConfidence(sources); final relatedConcepts = _extractRelatedConcepts(sources); return RAGResponse( answer: answer, sources: citations, confidence: confidence, mode: mode, relatedConcepts: relatedConcepts, metadata: { 'queryLength': query.length, 'contextLength': context.length, 'sourceCount': sources.length, 'processingTime': DateTime.now().millisecondsSinceEpoch, 'apiResponse': false, 'apiError': response.statusCode, }, ); } } catch (e) { Logger.error('Error calling Ollama API: $e'); // Fallback to mock response final answer = _generateMockAnswer(query, context, mode); final confidence = _calculateConfidence(sources); final relatedConcepts = _extractRelatedConcepts(sources); return RAGResponse( answer: answer, sources: 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(), confidence: confidence, mode: mode, relatedConcepts: relatedConcepts, metadata: { 'queryLength': query.length, 'contextLength': context.length, 'sourceCount': sources.length, 'processingTime': DateTime.now().millisecondsSinceEpoch, 'apiResponse': false, 'apiError': e.toString(), }, ); } } /// Get excerpt from text static String _getExcerpt(String text, {int maxLength = 200}) { if (text.length <= maxLength) return text; return text.substring(0, maxLength - 3) + '...'; } /// Build prompt for AI model with educational context static String _buildPrompt(String query, String context, TutorMode mode) { final modeInstructions = _getModeInstructions(mode); return '''$modeInstructions === CONTEÚDO EDUCACIONAL RELEVANTE === $context === PERGUNTA DO ALUNO === $query === INSTRUÇÕES === - Baseie sua resposta APENAS no conteúdo educacional fornecido acima - Se o conteúdo não for suficiente para responder, explique o que está disponível - Use linguagem clara e educacional - Adapte a resposta ao nível do aluno - Forneça exemplos quando possível - Seja conciso mas completo - NUNCA use LaTeX ou símbolos como \$ ou \$\$ para fórmulas - Use apenas texto normal e caracteres Unicode para símbolos matemáticos'''; } /// 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; } /// Generate mock answer (placeholder) static String _generateMockAnswer( String query, String context, TutorMode mode, ) { switch (mode) { case TutorMode.explanation: return 'Baseado no conteúdo fornecido, posso explicar que $query. Esta abordagem é fundamentada nos conceitos apresentados no material educacional. A resposta detalhada envolve os princípios teóricos e práticos descritos nas fontes relevantes.'; case TutorMode.tutor: return 'Para entender $query, vamos explorar juntos. O que você já sabe sobre este conceito? Baseado no material, podemos investigar os aspectos fundamentais passo a passo.'; case TutorMode.exploration: return 'Vamos descobrir mais sobre $query! O conteúdo sugere várias abordagens interessantes. Que aspecto gostaria de explorar primeiro? Podemos investigar as conexões entre os diferentes conceitos apresentados.'; } } /// Calculate confidence score static double _calculateConfidence(List sources) { if (sources.isEmpty) return 0.0; // Base confidence on number and quality of sources double confidence = min(sources.length * 0.2, 0.8); // Boost confidence if sources are highly relevant final avgRelevance = sources.map((s) => s.difficulty).reduce((a, b) => a + b) / sources.length; confidence += avgRelevance * 0.2; return min(confidence, 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(); } /// Detect if subject is math-related static bool _isMathSubject(String? subject) { if (subject == null) return false; final lowerSubject = subject.toLowerCase(); final mathKeywords = [ 'matemática', 'math', 'matematica', 'álgebra', 'algebra', 'geometria', 'cálculo', 'calculo', 'estatística', 'estatistica', 'funções', 'funcoes', 'equações', 'equacoes', 'números', 'numeros', ]; return mathKeywords.any((keyword) => lowerSubject.contains(keyword)); } /// Filter out table data from text (for math subjects) /// Removes lines that look like tabular data with multiple numbers static String _filterTableData(String text) { final lines = text.split('\n'); final filtered = []; for (final line in lines) { final trimmed = line.trim(); // Skip lines that look like table data // Pattern: multiple numbers separated by spaces/tabs final numberPattern = RegExp(r'\d+\s+\d+'); final matches = numberPattern.allMatches(trimmed); // If a line has 2+ number pairs separated by spaces, it's likely table data if (matches.length >= 2) { continue; } // Skip lines with specific date patterns (table data) if (RegExp(r'\d{1,2}/\d{1,2}/\d{4}').hasMatch(trimmed) && RegExp(r'\d+').allMatches(trimmed).length > 2) { continue; } // Keep the line filtered.add(line); } return filtered.join('\n'); } /// Remove LaTeX symbols from AI response static String _removeLaTeXSymbols(String text) { // Remove patterns like $...$ and $$...$$ var cleaned = text.replaceAll(RegExp(r'\$\$[^$]+\$\$'), ''); cleaned = cleaned.replaceAll(RegExp(r'\$[^$]+\$'), ''); // Also remove standalone $ symbols cleaned = cleaned.replaceAll(r'$', r'\$'); return cleaned; } /// Create response for no content found static RAGResponse _createNoContentResponse(String query, TutorMode mode) { return RAGResponse( answer: 'Desculpe, não encontrei conteúdo relevante para responder à sua pergunta sobre "$query". Tente reformular a pergunta ou verifique se o material sobre este tópico está disponível.', sources: [], confidence: 0.0, mode: mode, relatedConcepts: [], metadata: {'error': 'no_content_found'}, ); } /// Create error response static RAGResponse _createErrorResponse( String query, TutorMode mode, String error, ) { return RAGResponse( answer: 'Ocorreu um erro ao processar sua pergunta. Por favor, tente novamente mais tarde.', sources: [], confidence: 0.0, mode: mode, relatedConcepts: [], metadata: {'error': error}, ); } /// Get conversation history for context static Future>> getConversationHistory({ required String userId, int limit = 10, }) async { try { Logger.info('Getting conversation history for user: $userId'); final querySnapshot = await _firestore .collection('conversations') .where('userId', isEqualTo: userId) .orderBy('createdAt', descending: true) .limit(limit) .get(); return querySnapshot.docs .map((doc) => {'id': doc.id, ...doc.data() as Map}) .toList(); } catch (e) { Logger.error('Error getting conversation history: $e'); return []; } } /// Save conversation to Firestore static Future saveConversation({ required String userId, required String query, required RAGResponse response, }) async { try { Logger.info('Saving conversation for user: $userId'); final conversationData = { 'userId': userId, 'query': query, 'answer': response.answer, 'mode': response.mode.name, 'confidence': response.confidence, 'sources': response.sources .map( (s) => { 'contentId': s.contentId, 'chunkId': s.chunkId, 'title': s.title, 'concept': s.concept, 'subject': s.subject, 'excerpt': s.excerpt, 'relevance': s.relevance, 'pageNumber': s.pageNumber, }, ) .toList(), 'relatedConcepts': response.relatedConcepts, 'metadata': response.metadata, 'createdAt': FieldValue.serverTimestamp(), }; final docRef = await _firestore .collection('conversations') .add(conversationData); final conversationId = docRef.id; Logger.info('Conversation saved: $conversationId'); return conversationId; } catch (e) { Logger.error('Error saving conversation: $e'); throw Exception('Failed to save conversation: $e'); } } /// Get popular concepts static Future>> getPopularConcepts({ int limit = 20, }) async { try { Logger.info('Getting popular concepts'); final querySnapshot = await _firestore .collection('contentChunks') .where('isActive', isEqualTo: true) .limit(100) .get(); final conceptCounts = {}; for (final doc in querySnapshot.docs) { final data = doc.data(); final concept = data['concept'] as String? ?? 'Unknown'; conceptCounts[concept] = (conceptCounts[concept] ?? 0) + 1; } final sortedConcepts = conceptCounts.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); return sortedConcepts .take(limit) .map((entry) => {'concept': entry.key, 'count': entry.value}) .toList(); } catch (e) { Logger.error('Error getting popular concepts: $e'); return []; } } }