FUCKASS IA
This commit is contained in:
@@ -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<String, List<String>> _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<List<Map<String, String>>> 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<String>()
|
||||
.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 = <Map<String, String>>[];
|
||||
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<String> getRelevantChunks({
|
||||
required String userQuery,
|
||||
int maxMaterials = 5,
|
||||
int maxChunks = 5,
|
||||
List<String>? 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<String> 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 = <Future<QuerySnapshot>>[];
|
||||
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<String, dynamic>;
|
||||
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<String> _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<String> _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 = <int>{};
|
||||
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 = <String>[];
|
||||
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
|
||||
|
||||
@@ -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<String> ask(String userQuery) async {
|
||||
/// [selectedMaterialIds] — se fornecido, limita o RAG apenas aos materiais escolhidos pelo aluno
|
||||
static Future<String> ask(
|
||||
String userQuery, {
|
||||
List<String>? 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');
|
||||
|
||||
@@ -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<TutorChatPageSimple>
|
||||
bool _isLoading = false;
|
||||
List<Map<String, dynamic>> _messages = [];
|
||||
|
||||
List<Map<String, String>> _availableMaterials = [];
|
||||
Set<String> _selectedMaterialIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_addWelcomeMessage();
|
||||
_loadAvailableMaterials();
|
||||
}
|
||||
|
||||
Future<void> _loadAvailableMaterials() async {
|
||||
final materials = await MaterialsRAGService.getAvailableMaterialsForStudent();
|
||||
if (mounted) {
|
||||
setState(() => _availableMaterials = materials);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -337,20 +350,116 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
|
||||
),
|
||||
],
|
||||
),
|
||||
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<TutorChatPageSimple>
|
||||
),
|
||||
|
||||
// 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<Color>(
|
||||
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<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMaterialsPicker() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
final tempSelected = Set<String>.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)
|
||||
|
||||
16
pubspec.lock
16
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user