From 321df8bb1d811c2656d8ff782a5df159cde335cd Mon Sep 17 00:00:00 2001 From: 240403 <240403@epvc.pt> Date: Sat, 16 May 2026 17:31:06 +0100 Subject: [PATCH] FUCKASS IA --- lib/core/services/materials_rag_service.dart | 444 ++++++++++++------ lib/core/services/rag_ai_service.dart | 53 ++- .../pages/tutor_chat_page_simple.dart | 349 +++++++++++--- pubspec.lock | 16 + pubspec.yaml | 1 + 5 files changed, 651 insertions(+), 212 deletions(-) diff --git a/lib/core/services/materials_rag_service.dart b/lib/core/services/materials_rag_service.dart index 161ec6f..1b88912 100644 --- a/lib/core/services/materials_rag_service.dart +++ b/lib/core/services/materials_rag_service.dart @@ -1,6 +1,7 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_storage/firebase_storage.dart'; +import 'package:syncfusion_flutter_pdf/pdf.dart'; import '../utils/logger.dart'; /// Service for RAG chunk retrieval from teacher PDFs @@ -15,18 +16,72 @@ class MaterialsRAGService { /// Cache de chunks extraídos dos PDFs: {fileName: [chunk1, chunk2, ...]} static final Map> _chunksCache = {}; - /// Tamanho de cada chunk em caracteres - static const int _chunkSize = 1000; - - /// Overlap entre chunks para manter contexto - static const int _chunkOverlap = 100; + /// Número máximo de janelas de contexto a enviar ao modelo + static const int _maxRelevantChunks = 5; + + /// Listar materiais disponíveis para o aluno autenticado + /// Retorna apenas materiais cujo classId corresponde a uma turma onde o aluno está inscrito + static Future>> getAvailableMaterialsForStudent() async { + try { + final user = _auth.currentUser; + if (user == null) return []; + + final uid = user.uid; + + // 1. Buscar classIds das inscrições do aluno + final enrollmentSnapshot = await _firestore + .collection('enrollments') + .where('studentId', isEqualTo: uid) + .get(); + + final enrolledClassIds = enrollmentSnapshot.docs + .map((doc) => doc.data()['classId'] as String?) + .where((id) => id != null) + .cast() + .toSet(); + + if (enrolledClassIds.isEmpty) return []; + + // 2. Buscar teacher IDs dessas turmas + final teacherIds = await _getTeacherIdsForStudent(uid); + if (teacherIds.isEmpty) return []; + + // 3. Buscar todos os materiais desses professores + final teacherIdList = teacherIds.take(10).toList(); + final snapshot = await _firestore + .collection('materials') + .where('teacherId', whereIn: teacherIdList) + .orderBy('createdAt', descending: true) + .get(); + + // 4. Filtrar: manter apenas materiais cujo classId está nas turmas do aluno + // ou materiais sem classId (compatibilidade com uploads antigos) + final result = >[]; + for (final doc in snapshot.docs) { + final data = doc.data(); + final classId = data['classId'] as String?; + if (classId == null || enrolledClassIds.contains(classId)) { + final fileName = data['fileName'] as String? ?? 'Material'; + result.add({'id': doc.id, 'name': fileName}); + } + } + + Logger.info('Available materials for student: ${result.length}'); + return result; + } catch (e) { + Logger.error('Error getting available materials for student: $e'); + return []; + } + } /// RAG CHUNK RETRIEVAL - Versão correta /// Busca chunks relevantes dos PDFs com base na query do usuário + /// Se [selectedMaterialIds] for fornecido e não vazio, filtra apenas esses materiais static Future getRelevantChunks({ required String userQuery, int maxMaterials = 5, int maxChunks = 5, + List? selectedMaterialIds, }) async { try { final user = _auth.currentUser; @@ -35,93 +90,112 @@ class MaterialsRAGService { return ''; } - final uid = user.uid; - - // 1. Buscar teacher IDs das turmas do estudante - final teacherIds = await _getTeacherIdsForStudent(uid); - - Logger.info('Teacher IDs for this student: $teacherIds'); - - if (teacherIds.isEmpty) { - Logger.info('No teachers found for student $uid'); - return ''; - } - - // 2. Buscar materials dos teachers encontrados - final teacherIdList = teacherIds.take(10).toList(); - - final snapshot = await _firestore - .collection('materials') - .where('teacherId', whereIn: teacherIdList) - .orderBy('createdAt', descending: true) - .limit(maxMaterials) - .get(); - - Logger.info('Materials found: ${snapshot.docs.length}'); - - if (snapshot.docs.isEmpty) { - Logger.info('No materials found for teachers: $teacherIdList'); - return ''; - } - - // 3. Extrair chunks de cada PDF - List allChunks = []; - - for (final doc in snapshot.docs) { - final data = doc.data(); - final fileName = data['fileName'] as String?; - - if (fileName == null) continue; - if (!fileName.toLowerCase().endsWith('.pdf')) continue; - - // Verificar cache de chunks - if (_chunksCache.containsKey(fileName)) { - allChunks.addAll(_chunksCache[fileName]!); - continue; + if (selectedMaterialIds != null && selectedMaterialIds.isNotEmpty) { + // Usar apenas os materiais selecionados pelo aluno + Logger.info('Fetching selected materials: $selectedMaterialIds'); + final batches = >[]; + for (int i = 0; i < selectedMaterialIds.length; i += 10) { + final batch = selectedMaterialIds.skip(i).take(10).toList(); + batches.add( + _firestore + .collection('materials') + .where(FieldPath.documentId, whereIn: batch) + .get(), + ); } + final results = await Future.wait(batches); + final allDocs = results.expand((s) => s.docs).toList(); + Logger.info('Selected materials found: ${allDocs.length}'); - // Extrair texto completo do PDF - try { - final teacherId = data['teacherId'] as String?; - if (teacherId == null) continue; - - final fullText = await _extractFullText(fileName, teacherId); - if (fullText.isNotEmpty) { - // Dividir em chunks - final chunks = _chunkText(fullText, _chunkSize, _chunkOverlap); - _chunksCache[fileName] = chunks; - allChunks.addAll(chunks); - - Logger.info('PDF "$fileName" -> ${chunks.length} chunks (${fullText.length} chars total)'); + // Processar directamente — sem chunking para não triplicar o texto em memória + final contextBuffer = StringBuffer(); + contextBuffer.writeln('Contexto dos materiais do professor:'); + bool hasContent = false; + for (final doc in allDocs) { + final data = doc.data() as Map; + final fileName = data['fileName'] as String?; + if (fileName == null) continue; + if (!fileName.toLowerCase().endsWith('.pdf')) continue; + + // Usar cache do texto completo se disponível (sufixo v2 invalida caches antigos) + final cacheKey = '${fileName}_v6'; + String fullText; + if (_chunksCache.containsKey(cacheKey) && _chunksCache[cacheKey]!.isNotEmpty) { + fullText = _chunksCache[cacheKey]!.first; + Logger.info('Using cached text for $fileName: ${fullText.length} chars'); + } else { + try { + final teacherId = data['teacherId'] as String?; + if (teacherId == null) continue; + final rawText = await _extractFullText(fileName, teacherId); + if (rawText.isEmpty) continue; + // Colapsar whitespace excessivo (PDFs de layout decorativo geram muitos \n) + String cleaned = rawText + .replaceAll(RegExp(r'[ \t]+'), ' ') + .replaceAll(RegExp(r'\n{2,}'), '\n') + .trim(); + // Tentar corrigir encoding LaTeX corrompido (Type1/OTF sem mapeamento Unicode) + cleaned = cleaned + .replaceAll('¸c˜ao', 'ção') + .replaceAll('˜ao', 'ão') + .replaceAll('¸c˜oes', 'ções') + .replaceAll('˜oes', 'ões') + .replaceAll('¸c', 'ç') + .replaceAll('´a', 'á') + .replaceAll('´e', 'é') + .replaceAll('´i', 'í') + .replaceAll('´o', 'ó') + .replaceAll('´u', 'ú') + .replaceAll('ˆa', 'â') + .replaceAll('ˆe', 'ê') + .replaceAll('ˆo', 'ô') + .replaceAll('`a', 'à'); + // Reconstruir espaços em texto colado (LaTeX sem ToUnicode map): + // inserir espaço antes de maiúscula precedida de minúscula/dígito + cleaned = cleaned.replaceAllMapped( + RegExp(r'([a-záéíóúàâêôãõç\d])([A-ZÁÉÍÓÚÀÂÊÔÃÕÇ])'), + (m) => '${m.group(1)} ${m.group(2)}', + ); + // inserir espaço entre dígito e letra + cleaned = cleaned.replaceAllMapped( + RegExp(r'(\d)([A-Za-záéíóúàâêôãõç])'), + (m) => '${m.group(1)} ${m.group(2)}', + ); + fullText = cleaned; + // Guardar texto completo no cache com key versionada + _chunksCache[cacheKey] = [fullText]; + Logger.info('PDF "$fileName" -> ${fullText.length} chars extracted'); + } catch (e) { + Logger.error('Error extracting text from $fileName: $e'); + continue; + } + } + + // PDFs pequenos: enviar texto completo (formulários, notas, etc.) + // PDFs grandes: keyword window search para não sobrecarregar o modelo + final String context; + if (fullText.length <= 10000) { + context = fullText; + Logger.info('Small PDF — sending full text (${fullText.length} chars)'); + } else { + final windows = _extractKeywordWindows(fullText, userQuery, _maxRelevantChunks); + context = windows.join('\n\n---\n\n'); + Logger.info('Large PDF — keyword windows: ${windows.length}'); + } + if (context.isNotEmpty) { + contextBuffer.writeln('\n[MATERIAL: $fileName]'); + contextBuffer.writeln(context); + hasContent = true; } - } catch (e) { - Logger.error('Error extracting text from $fileName: $e'); - continue; } + if (!hasContent) return ''; + return contextBuffer.toString(); } - if (allChunks.isEmpty) { - return ''; - } - - // 4. Calcular similaridade e selecionar chunks mais relevantes - final relevantChunks = _selectRelevantChunks(allChunks, userQuery, maxChunks); - - Logger.info('Total chunks: ${allChunks.length}, Selected: ${relevantChunks.length}'); - - // 5. Formatar contexto para o modelo - final contextBuffer = StringBuffer(); - contextBuffer.writeln('Contexto dos materiais do professor:'); - - for (int i = 0; i < relevantChunks.length; i++) { - contextBuffer.writeln('\n[CHUNK ${i + 1}]'); - contextBuffer.writeln(relevantChunks[i]); - } - - final result = contextBuffer.toString(); - Logger.info('RAG context size: ${result.length} chars (${relevantChunks.length} chunks)'); - - return result; + // Sem material seleccionado — não processar PDFs automaticamente + // O utilizador deve seleccionar um material antes de fazer perguntas sobre conteúdo + Logger.info('No selectedMaterialIds — skipping automatic PDF processing'); + return ''; } catch (e) { Logger.error('Error in RAG chunk retrieval: $e'); return ''; @@ -191,11 +265,17 @@ class MaterialsRAGService { } } - /// Extrair TODO o texto do PDF - /// CORRETO: Retorna texto completo, não resumo + /// Limite máximo de bytes descarregados do PDF via Firebase Storage (10 MB) + static const int _maxPdfBytes = 10 * 1024 * 1024; + + /// Limite máximo de caracteres de texto extraído do PDF completo (para chunking) + static const int _maxExtractedChars = 50000; + + /// Extrair texto real do PDF usando Firebase Storage SDK + syncfusion_flutter_pdf + /// Usa getData() para descarregar o ficheiro completo (sem truncar a meio do stream) static Future _extractFullText(String fileName, String teacherId) async { + PdfDocument? document; try { - // Get download URL from Storage final ref = _storage .ref() .child('teachers') @@ -203,57 +283,153 @@ class MaterialsRAGService { .child('materials') .child(fileName); - final downloadUrl = await ref.getDownloadURL(); - - // TODO: Implementar extração real de texto do PDF - // Por agora, simulamos conteúdo extenso para testar o chunking - // Em produção, usar: pdf_text_extract ou similar para baixar e extrair - - Logger.info('PDF available for extraction: $fileName at $downloadUrl'); - - // Simulação: retornar texto representativo do PDF - // Na implementação real, baixar o PDF e extrair todo o texto - return _simulatePdfContent(fileName); + Logger.info('PDF available for extraction: $fileName'); + + // getData descarrega o ficheiro completo de forma gerida pelo SDK do Firebase + // O PDF nunca é truncado a meio — recebemos sempre um ficheiro válido + final data = await ref.getData(_maxPdfBytes); + if (data == null || data.isEmpty) { + Logger.warning('No data received for $fileName'); + return ''; + } + + Logger.info('Downloaded ${data.length} bytes for $fileName'); + + // Extrair texto real com PdfDocument + document = PdfDocument(inputBytes: data); + final buffer = StringBuffer(); + + // 1. Extrair texto de todas as páginas — salta apenas páginas de estrutura + final extractor = PdfTextExtractor(document); + final totalPages = document.pages.count; + final startPage = totalPages > 4 ? 2 : 0; + for (int i = startPage; i < totalPages; i++) { + if (buffer.length >= _maxExtractedChars) break; + try { + final pageText = extractor.extractText(startPageIndex: i, endPageIndex: i).trim(); + if (pageText.length < 80) continue; + final lowerText = pageText.toLowerCase(); + final pipeCount = '|'.allMatches(pageText).length; + final isStructurePage = pipeCount > 3 || + (lowerText.contains('table of contents') && pageText.length < 800) || + (lowerText.contains('copyright') && pageText.length < 400) || + (lowerText.contains('color insert') && pageText.length < 400) || + lowerText.contains('just light novels') || + lowerText.contains('download all your fav') || + (lowerText.contains('www.') && pageText.length < 300); + if (isStructurePage) continue; + buffer.writeln(pageText); + } catch (_) {} + } + + // 2. Extrair valores dos campos de formulário (se existirem) + final form = document.form; + if (form.fields.count > 0) { + buffer.writeln('\n[CAMPOS DO FORMULÁRIO]'); + for (int i = 0; i < form.fields.count; i++) { + if (buffer.length >= _maxExtractedChars) break; + final field = form.fields[i]; + final name = field.name; + String value = ''; + if (field is PdfTextBoxField) { + value = field.text; + } else if (field is PdfComboBoxField) { + value = field.selectedValue; + } else if (field is PdfListBoxField) { + value = field.selectedValues.join(', '); + } else if (field is PdfRadioButtonListField) { + value = field.selectedValue; + } else if (field is PdfCheckBoxField) { + value = field.isChecked ? 'Sim' : 'Não'; + } + if ((name?.isNotEmpty ?? false) || value.isNotEmpty) { + buffer.writeln('$name: $value'); + } + } + } + + final fullText = buffer.toString(); + + // Truncar ao limite + final result = fullText.length > _maxExtractedChars + ? fullText.substring(0, _maxExtractedChars) + : fullText; + + Logger.info('Extracted ${result.length} chars from $fileName (${document.pages.count} pages, ${form.fields.count} form fields)'); + Logger.info('Text preview: ${result.length > 200 ? result.substring(0, 200) : result}'); + return result.trim(); } catch (e) { - Logger.error('Error extracting full text from PDF $fileName: $e'); + Logger.error('Error extracting text from $fileName: $e'); return ''; + } finally { + document?.dispose(); } } - /// Simular conteúdo de PDF para testar chunking - /// REMOVER em produção - substituir por extração real - static String _simulatePdfContent(String fileName) { - // Conteúdo simulado extenso para testar chunk retrieval - final buffer = StringBuffer(); - buffer.writeln('CONTEÚDO DO PDF: $fileName'); - buffer.writeln(); - buffer.writeln('INTRODUÇÃO'); - buffer.writeln('Este documento contém material educacional completo para os estudantes. ' - 'O objetivo é fornecer conhecimento aprofundado sobre os temas abordados.'); - buffer.writeln(); - - // Gerar conteúdo extenso para testar chunking - for (int i = 1; i <= 20; i++) { - buffer.writeln('SECÇÃO $i - CONCEITO FUNDAMENTAL $i'); - buffer.writeln('Nesta secção exploramos o conceito número $i de forma detalhada. ' - 'Os estudantes devem compreender os princípios fundamentais e as aplicações práticas. ' - 'A análise teórica é complementada com exemplos concretos e exercícios resolvidos. ' - 'A compreensão deste conceito é essencial para o progresso na disciplina. ' - 'Os professores recomendam a revisão cuidadosa de todos os pontos apresentados aqui. ' - 'Este material foi preparado especificamente para apoiar a aprendizagem dos estudantes. ' - 'Qualquer dúvida deve ser esclarecida com o professor durante as aulas. '); - buffer.writeln(); - buffer.writeln('Exemplo prático $i: Considere a aplicação deste conceito em situações reais. ' - 'Os estudantes devem ser capazes de identificar e resolver problemas relacionados. ' - 'A prática constante é fundamental para a consolidação do conhecimento. '); - buffer.writeln(); + /// Keyword window search — encontra posições das keywords no texto e extrai + /// janelas de contexto em redor. Nunca aloca chunks — opera sobre a string original. + static List _extractKeywordWindows( + String text, + String userQuery, + int maxWindows, { + int windowSize = 1200, + }) { + if (text.isEmpty || userQuery.isEmpty) { + // Sem query — devolver início do texto + return [text.length > windowSize ? text.substring(0, windowSize) : text]; } - - buffer.writeln('CONCLUSÃO'); - buffer.writeln('Este documento cobre todos os aspetos essenciais do tema. ' - 'Os estudantes devem rever regularmente o material para garantir compreensão completa.'); - - return buffer.toString(); + + // Extrair keywords: palavras com >3 chars + nomes próprios (palavras com maiúscula, >2 chars) + // Os nomes próprios são invariantes entre línguas (ex: "Claire", "Rae", "François") + final properNouns = RegExp(r'\b[A-ZÁÉÍÓÚÀÂÊÔÃÕÇ][a-záéíóúàâêôãõç]{2,}\b') + .allMatches(userQuery) + .map((m) => m.group(0)!.toLowerCase()) + .toSet(); + final generalKeywords = userQuery + .toLowerCase() + .split(RegExp(r'[^\w]')) + .where((w) => w.length > 3) + .toSet(); + final keywords = {...properNouns, ...generalKeywords}; + + if (keywords.isEmpty) { + return [text.length > windowSize ? text.substring(0, windowSize) : text]; + } + + final textLower = text.toLowerCase(); + // Recolher posições únicas onde alguma keyword aparece + final positions = {}; + for (final kw in keywords) { + int idx = textLower.indexOf(kw); + while (idx != -1) { + positions.add(idx); + idx = textLower.indexOf(kw, idx + 1); + } + } + + if (positions.isEmpty) { + // Sem matches — retornar porção do início do conteúdo real (saltar ~10% de índice/capa) + final skip = (text.length * 0.05).toInt().clamp(0, 2000); + final end = (skip + windowSize * maxWindows).clamp(0, text.length); + return [text.substring(skip, end).trim()]; + } + + // Ordenar posições e fundir janelas sobrepostas + final sorted = positions.toList()..sort(); + final windows = []; + int lastEnd = -1; + + for (final pos in sorted) { + if (windows.length >= maxWindows) break; + final start = (pos - windowSize ~/ 2).clamp(0, text.length); + final end = (pos + windowSize ~/ 2).clamp(0, text.length); + if (start < lastEnd) continue; // Janela sobreposta — saltar + windows.add(text.substring(start, end).trim()); + lastEnd = end; + } + + Logger.info('Keyword windows found: ${windows.length} for query "$userQuery"'); + return windows; } /// Dividir texto em chunks com overlap diff --git a/lib/core/services/rag_ai_service.dart b/lib/core/services/rag_ai_service.dart index b802675..3a03d03 100644 --- a/lib/core/services/rag_ai_service.dart +++ b/lib/core/services/rag_ai_service.dart @@ -493,7 +493,11 @@ Usas formatação clara e organizada.'''; } /// Simple ask method for chat UI - uses conversation memory, teacher PDFs, and O GOAT identity - static Future ask(String userQuery) async { + /// [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 @@ -509,12 +513,17 @@ 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.''', +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 - final conversationHistory = await ChatMemoryService.getRecentMessages(limit: 20); + // 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, @@ -532,18 +541,40 @@ Usas formatação Markdown clara e organizada.''', userQuery: userQuery, maxMaterials: 5, maxChunks: 5, + selectedMaterialIds: selectedMaterialIds, ); if (pdfContext.isNotEmpty) { - messages.add({ - 'role': 'system', - 'content': pdfContext, // Já vem formatado com [CHUNK 1], [CHUNK 2], etc. - }); + Logger.info('PDF context sent to model (${pdfContext.length} chars): ${pdfContext.length > 300 ? pdfContext.substring(0, 300) : pdfContext}'); + } else if (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; + } else { + // 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 — SÓ AGORA adicionar a pergunta do user + // 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': userQuery, + 'content': userContent, }); Logger.info('USING RAG AI SERVICE - Built messages array with ${messages.length} messages'); diff --git a/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart b/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart index 8ad5353..9b6cacf 100644 --- a/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart +++ b/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart @@ -4,6 +4,8 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/services/auth_service.dart'; +import '../../../../core/services/chat_memory_service.dart'; +import '../../../../core/services/materials_rag_service.dart'; import '../../../../core/services/rag_ai_service.dart'; import '../../../../core/utils/logger.dart'; @@ -23,10 +25,21 @@ class _TutorChatPageSimpleState extends State bool _isLoading = false; List> _messages = []; + List> _availableMaterials = []; + Set _selectedMaterialIds = {}; + @override void initState() { super.initState(); _addWelcomeMessage(); + _loadAvailableMaterials(); + } + + Future _loadAvailableMaterials() async { + final materials = await MaterialsRAGService.getAvailableMaterialsForStudent(); + if (mounted) { + setState(() => _availableMaterials = materials); + } } @override @@ -337,20 +350,116 @@ class _TutorChatPageSimpleState extends State ), ], ), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.surface, - border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), - width: 1, - ), - ), - child: Row( - children: [ - // Text field - Expanded( - child: TextField( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Chips dos materiais selecionados + if (_availableMaterials.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + InkWell( + onTap: _showMaterialsPicker, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: _selectedMaterialIds.isEmpty + ? Theme.of(context).colorScheme.outline.withOpacity(0.15) + : Theme.of(context).colorScheme.primary.withOpacity(0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: _selectedMaterialIds.isEmpty + ? Theme.of(context).colorScheme.outline.withOpacity(0.4) + : Theme.of(context).colorScheme.primary.withOpacity(0.5), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.attach_file, + size: 14, + color: _selectedMaterialIds.isEmpty + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 4), + Text( + _selectedMaterialIds.isEmpty + ? 'Materiais' + : '${_selectedMaterialIds.length} selecionado(s)', + style: TextStyle( + fontSize: 12, + color: _selectedMaterialIds.isEmpty + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + if (_selectedMaterialIds.isNotEmpty) ... + _selectedMaterialIds.map((id) { + final name = _availableMaterials + .firstWhere( + (m) => m['id'] == id, + orElse: () => {'id': id, 'name': id}, + )['name'] ?? + id; + final short = name.length > 18 + ? '${name.substring(0, 16)}…' + : name; + return Padding( + padding: const EdgeInsets.only(left: 6), + child: Chip( + label: Text( + short, + style: const TextStyle(fontSize: 11), + ), + deleteIcon: const Icon(Icons.close, size: 14), + onDeleted: () => setState( + () => _selectedMaterialIds.remove(id), + ), + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ); + }).toList(), + ], + ), + ), + ), + ], + ), + ), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surface, + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + width: 1, + ), + ), + child: Row( + children: [ + // Text field + Expanded( + child: TextField( controller: _messageController, style: TextStyle( fontSize: 16, @@ -379,62 +488,163 @@ class _TutorChatPageSimpleState extends State ), // Send button - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: _messageController.text.isNotEmpty - ? LinearGradient( - colors: [ - Theme.of(context).colorScheme.primary, - Theme.of( - context, - ).colorScheme.primary.withOpacity(0.8), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ) - : null, - color: _messageController.text.isNotEmpty - ? null - : Theme.of(context).colorScheme.outline.withOpacity(0.3), - borderRadius: BorderRadius.circular(22), - boxShadow: _messageController.text.isNotEmpty - ? [ - BoxShadow( - color: Theme.of( - context, - ).colorScheme.primary.withOpacity(0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ] - : null, + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: _messageController.text.isNotEmpty + ? LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary, + Theme.of( + context, + ).colorScheme.primary.withOpacity(0.8), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + color: _messageController.text.isNotEmpty + ? null + : Theme.of(context).colorScheme.outline.withOpacity(0.3), + borderRadius: BorderRadius.circular(22), + boxShadow: _messageController.text.isNotEmpty + ? [ + BoxShadow( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: IconButton( + onPressed: _messageController.text.isNotEmpty && !_isLoading + ? _handleSendMessage + : null, + icon: _isLoading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Icon(Icons.send, color: Colors.white, size: 18), + ), + ), ), - child: IconButton( - onPressed: _messageController.text.isNotEmpty && !_isLoading - ? _handleSendMessage - : null, - icon: _isLoading - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Colors.white, - ), + ], + ), + ), + ], + ), + ); + } + + void _showMaterialsPicker() { + showDialog( + context: context, + builder: (dialogContext) { + final tempSelected = Set.from(_selectedMaterialIds); + return StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text( + 'Escolher Materiais', + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Seleciona os materiais que o tutor deve analisar:', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: ListView( + shrinkWrap: true, + children: _availableMaterials.map((material) { + final id = material['id']!; + final name = material['name']!; + final isChecked = tempSelected.contains(id); + return CheckboxListTile( + value: isChecked, + onChanged: (val) { + setDialogState(() { + if (val == true) { + tempSelected.add(id); + } else { + tempSelected.remove(id); + } + }); + }, + title: Text( + name, + style: const TextStyle(fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ) - : Icon(Icons.send, color: Colors.white, size: 18), - ), + controlAffinity: ListTileControlAffinity.leading, + dense: true, + contentPadding: EdgeInsets.zero, + ); + }).toList(), + ), + ), + ], ), ), - ], - ), - ), + actions: [ + TextButton( + onPressed: () { + setDialogState(() => tempSelected.clear()); + setState(() { + _selectedMaterialIds.clear(); + _messages.clear(); + }); + ChatMemoryService.clearHistory(); + Navigator.of(dialogContext).pop(); + }, + child: const Text('Limpar'), + ), + ElevatedButton( + onPressed: () { + setState(() { + _selectedMaterialIds = tempSelected; + _messages.clear(); + }); + ChatMemoryService.clearHistory(); + Navigator.of(dialogContext).pop(); + }, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text('Confirmar'), + ), + ], + ), + ); + }, ); } @@ -490,7 +700,12 @@ Envia-me a tua pergunta sobre qualquer assunto educacional e vou usar o material // Use RAGAIService with memory, PDFs, and O GOAT identity Logger.info('USING RAG AI SERVICE'); - final replyText = await RAGAIService.ask(userMessage); + final replyText = await RAGAIService.ask( + userMessage, + selectedMaterialIds: _selectedMaterialIds.isEmpty + ? null + : _selectedMaterialIds.toList(), + ); final preview = replyText.length > 50 ? replyText.substring(0, 50) diff --git a/pubspec.lock b/pubspec.lock index 4b96d9a..2674898 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1593,6 +1593,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + syncfusion_flutter_core: + dependency: transitive + description: + name: syncfusion_flutter_core + sha256: a3fe740399b39519b4a2dfc87de1d47062171967170c8f1f47dc13692f531f86 + url: "https://pub.dev" + source: hosted + version: "33.2.6" + syncfusion_flutter_pdf: + dependency: "direct main" + description: + name: syncfusion_flutter_pdf + sha256: "2d12456a542077224d7a3951fa6694b6dd13f79563eb6995148f36218635bf55" + url: "https://pub.dev" + source: hosted + version: "33.2.6" synchronized: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ffafdf9..ea386b2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,6 +86,7 @@ dependencies: # file_picker: ^6.1.1 # Temporarily disabled due to compatibility issues file_selector: ^1.0.3 image_picker: ^1.0.4 + syncfusion_flutter_pdf: ^33.2.6 # Markdown rendering flutter_markdown: ^0.6.23