FUCKASS IA

This commit is contained in:
2026-05-16 17:31:06 +01:00
parent f8e3a7686f
commit 321df8bb1d
5 changed files with 651 additions and 212 deletions

View File

@@ -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

View File

@@ -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');

View File

@@ -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)