Historico de quizzes e inicio de atualização da IA para leitura de pdfs de matemática (incompleto)

This commit is contained in:
2026-05-20 01:32:37 +01:00
parent 80ed2b1346
commit 98dcd621c7
12 changed files with 1539 additions and 271 deletions

View File

@@ -86,11 +86,13 @@ class MaterialsRAGService {
/// RAG CHUNK RETRIEVAL - Versão correta /// RAG CHUNK RETRIEVAL - Versão correta
/// Busca chunks relevantes dos PDFs com base na query do usuário /// Busca chunks relevantes dos PDFs com base na query do usuário
/// Se [selectedMaterialIds] for fornecido e não vazio, filtra apenas esses materiais /// Se [selectedMaterialIds] for fornecido e não vazio, filtra apenas esses materiais
/// Se [filterTableData] for true, remove dados de tabelas/gráficos do conteúdo
static Future<String> getRelevantChunks({ static Future<String> getRelevantChunks({
required String userQuery, required String userQuery,
int maxMaterials = 5, int maxMaterials = 5,
int maxChunks = 5, int maxChunks = 5,
List<String>? selectedMaterialIds, List<String>? selectedMaterialIds,
bool filterTableData = false,
}) async { }) async {
try { try {
final user = _auth.currentUser; final user = _auth.currentUser;
@@ -187,7 +189,7 @@ class MaterialsRAGService {
// PDFs pequenos: enviar texto completo (formulários, notas, etc.) // PDFs pequenos: enviar texto completo (formulários, notas, etc.)
// PDFs grandes: keyword window search para não sobrecarregar o modelo // PDFs grandes: keyword window search para não sobrecarregar o modelo
final String context; String context;
if (fullText.length <= 10000) { if (fullText.length <= 10000) {
context = fullText; context = fullText;
Logger.info( Logger.info(
@@ -202,6 +204,13 @@ class MaterialsRAGService {
context = windows.join('\n\n---\n\n'); context = windows.join('\n\n---\n\n');
Logger.info('Large PDF — keyword windows: ${windows.length}'); Logger.info('Large PDF — keyword windows: ${windows.length}');
} }
// Filter table data if requested (for math subjects)
if (filterTableData) {
context = _filterTableData(context);
Logger.info('Filtered table data from content');
}
if (context.isNotEmpty) { if (context.isNotEmpty) {
contextBuffer.writeln('\n[MATERIAL: $fileName]'); contextBuffer.writeln('\n[MATERIAL: $fileName]');
contextBuffer.writeln(context); contextBuffer.writeln(context);
@@ -409,11 +418,6 @@ class MaterialsRAGService {
int maxWindows, { int maxWindows, {
int windowSize = 1200, int windowSize = 1200,
}) { }) {
if (text.isEmpty || userQuery.isEmpty) {
// Sem query — devolver início do texto
return [text.length > windowSize ? text.substring(0, windowSize) : text];
}
// Extrair keywords: palavras com >3 chars + nomes próprios (palavras com maiúscula, >2 chars) // 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") // Os nomes próprios são invariantes entre línguas (ex: "Claire", "Rae", "François")
final properNouns = RegExp( final properNouns = RegExp(
@@ -645,4 +649,36 @@ class MaterialsRAGService {
_chunksCache.clear(); _chunksCache.clear();
Logger.info('Materials chunks cache cleared'); Logger.info('Materials chunks cache cleared');
} }
/// Filter out table data from text (for math subjects)
/// Removes lines that look like tabular data with multiple numbers
static String _filterTableData(String text) {
final lines = text.split('\n');
final filtered = <String>[];
for (final line in lines) {
final trimmed = line.trim();
// Skip lines that look like table data
// Pattern: multiple numbers separated by spaces/tabs
final numberPattern = RegExp(r'\d+\s+\d+');
final matches = numberPattern.allMatches(trimmed);
// If a line has 2+ number pairs separated by spaces, it's likely table data
if (matches.length >= 2) {
continue;
}
// Skip lines with specific date patterns (table data)
if (RegExp(r'\d{1,2}/\d{1,2}/\d{4}').hasMatch(trimmed) &&
RegExp(r'\d+').allMatches(trimmed).length > 2) {
continue;
}
// Keep the line
filtered.add(line);
}
return filtered.join('\n');
}
} }

View File

@@ -29,7 +29,7 @@ class RAGAIService {
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO) // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO)
messages.add({ messages.add({
'role': 'system', 'role': 'system',
'content': '''Tu és "Vico", o Assistente IA oficial do Teach it. 'content': r'''Tu és "Vico", o Assistente IA oficial do Learn It.
Nunca referes o nome do modelo. Nunca referes o nome do modelo.
Nunca dizes que és Qwen ou OpenAI. Nunca dizes que és Qwen ou OpenAI.
@@ -37,7 +37,17 @@ Respondes sempre como o Vico.
Tens personalidade confiante, motivadora e orgulhosa. Tens personalidade confiante, motivadora e orgulhosa.
Ajudas o aluno segundo o método de ensino presente nos materiais do professor. 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.
IMPORTANTE: NUNCA uses LaTeX ou símbolos como $ ou $$ para fórmulas matemáticas.
Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).
IMPORTANTE - RESPOSTAS COMPLETAS:
- NUNCA termines respostas com dois pontos (:).
- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".
- SEMPRE completa as frases e fornece a resposta completa.
- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.
- Se precisares de definir algo, fornece a definição completa.''',
}); });
// PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore
@@ -54,8 +64,8 @@ Usas formatação Markdown clara e organizada.''',
// PASSO 4 — BUSCAR PDFs DO PROFESSOR NO Firebase Storage (RAG CHUNK RETRIEVAL) // PASSO 4 — BUSCAR PDFs DO PROFESSOR NO Firebase Storage (RAG CHUNK RETRIEVAL)
final pdfContext = await MaterialsRAGService.getRelevantChunks( final pdfContext = await MaterialsRAGService.getRelevantChunks(
userQuery: userQuery, userQuery: userQuery,
maxMaterials: 5, maxMaterials: 10,
maxChunks: 5, maxChunks: 20,
); );
if (pdfContext.isNotEmpty) { if (pdfContext.isNotEmpty) {
messages.add({ messages.add({
@@ -178,7 +188,7 @@ Usas formatação Markdown clara e organizada.''',
/// System message for Vico identity (for legacy calls) /// System message for Vico identity (for legacy calls)
static const String _systemMessage = static const String _systemMessage =
'''Tu és "Vico", o Assistente IA oficial do Teach it. r'''Tu és "Vico", o Assistente IA oficial do Learn It.
Nunca referes o nome do modelo. Nunca referes o nome do modelo.
Nunca dizes que és Qwen ou OpenAI. Nunca dizes que és Qwen ou OpenAI.
@@ -186,7 +196,17 @@ Respondes sempre como o Vico.
Tens personalidade confiante, motivadora e orgulhosa. Tens personalidade confiante, motivadora e orgulhosa.
Ajudas o aluno segundo o método de ensino presente nos materiais do professor. Ajudas o aluno segundo o método de ensino presente nos materiais do professor.
Usas formatação clara e organizada.'''; Usas formatação clara e organizada.
IMPORTANTE: NUNCA uses LaTeX ou símbolos como $ ou $$ para fórmulas matemáticas.
Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).
IMPORTANTE - RESPOSTAS COMPLETAS:
- NUNCA termines respostas com dois pontos (:).
- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".
- SEMPRE completa as frases e fornece a resposta completa.
- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.
- Se precisares de definir algo, fornece a definição completa.''';
/// Call Ollama API with complete messages array /// Call Ollama API with complete messages array
static Future<String> _callOllamaAPIWithMessages( static Future<String> _callOllamaAPIWithMessages(
@@ -215,7 +235,10 @@ Usas formatação clara e organizada.''';
if (response.statusCode == 200) { if (response.statusCode == 200) {
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
final message = responseData['message']; final message = responseData['message'];
final content = message?['content'] ?? ''; var content = message?['content'] ?? '';
// Post-process to remove LaTeX symbols
content = _removeLaTeXSymbols(content);
Logger.info('Ollama API response received'); Logger.info('Ollama API response received');
return content.trim(); return content.trim();
@@ -431,15 +454,8 @@ Usas formatação clara e organizada.''';
final response = await http.get(url).timeout(Duration(seconds: 10)); final response = await http.get(url).timeout(Duration(seconds: 10));
if (response.statusCode == 200) { if (response.statusCode == 200) {
final responseData = jsonDecode(response.body); Logger.info('Ollama service available');
final models = responseData['models'] as List? ?? []; return true;
final hasModel = models.any(
(model) => (model['name'] as String? ?? '').contains('qwen3-coder'),
);
Logger.info('Ollama service available, model found: $hasModel');
return hasModel;
} else { } else {
Logger.warning( Logger.warning(
'Ollama service returned status: ${response.statusCode}', 'Ollama service returned status: ${response.statusCode}',
@@ -477,18 +493,228 @@ Usas formatação clara e organizada.''';
} }
} }
/// Remove LaTeX symbols from AI response
static String _removeLaTeXSymbols(String text) {
// Remove patterns like $$...$$ (display math)
var cleaned = text.replaceAll(RegExp(r'\$\$[^$]*\$\$'), '');
// Remove patterns like $...$ (inline math) - be more careful
// Only remove when properly closed
cleaned = cleaned.replaceAllMapped(
RegExp(r'\$[^$\n]+?\$'),
(match) => match.group(0)!.replaceAll(r'$', ''),
);
// Remove any remaining standalone $ symbols
cleaned = cleaned.replaceAll(RegExp(r'(?<!\\)\$'), '');
return cleaned;
}
/// Gerar quiz a partir de um prompt com contexto PDF embutido — sem histórico de conversa /// Gerar quiz a partir de um prompt com contexto PDF embutido — sem histórico de conversa
static Future<String> generateQuiz(String prompt) async { static Future<String> generateQuiz(
String prompt, {
bool isMathematics = false,
}) async {
final systemPrompt = isMathematics
? _getMathematicsSystemPrompt()
: _getTextBasedSystemPrompt();
final messages = <Map<String, String>>[ final messages = <Map<String, String>>[
{ {'role': 'system', 'content': systemPrompt},
'role': 'system',
'content':
'És um assistente educativo especializado em criar quizzes pedagógicos. '
'Cria sempre perguntas claras, baseadas exclusivamente no contexto fornecido.',
},
{'role': 'user', 'content': prompt}, {'role': 'user', 'content': prompt},
]; ];
return await _callOllamaAPIWithMessages(messages); final raw = await _callOllamaAPIWithMessages(messages);
// Filter out table questions for mathematics
if (isMathematics) {
return _filterTableQuestions(raw);
}
return raw;
}
/// Filter out questions that reference tables, graphs, or specific dates
static String _filterTableQuestions(String json) {
try {
final List<dynamic> questions = jsonDecode(json);
final List<Map<String, dynamic>> filtered = [];
// Keywords that indicate table/graph dependence
final tableKeywords = [
'tabela',
'gráfico',
'dia 1/',
'início de',
'final de',
'tendência',
'evolução',
'ao longo do tempo',
'percentagem no início',
'percentagem no final',
'ano foi de',
'dia específico',
'data específica',
];
for (final q in questions) {
if (q is Map<String, dynamic>) {
final questionText = (q['q'] as String? ?? '').toLowerCase();
// Check if question contains table keywords
final hasTableKeyword = tableKeywords.any(
(keyword) => questionText.contains(keyword.toLowerCase()),
);
// Skip questions with table keywords
if (!hasTableKeyword) {
filtered.add(q);
}
}
}
// If filtered list is empty, return original to avoid empty response
if (filtered.isEmpty) {
Logger.warning(
'All questions filtered out as table questions, returning original',
);
return json;
}
return jsonEncode(filtered);
} catch (e) {
Logger.error('Error filtering table questions: $e');
return json;
}
}
/// System prompt for mathematics quizzes
static String _getMathematicsSystemPrompt() {
return '''És um assistente educativo especializado em criar EXERCÍCIOS DE MATEMÁTICA.
REGRAS CRÍTICAS:
1. ANALISA TODO O CONTEÚDO fornecido:
- Lê TODO o documento do início ao fim
- Identifica TODOS os tópicos e tipos de exercícios presentes
- NÃO te limites apenas aos primeiros exercícios
- Cria perguntas sobre TODOS os tópicos encontrados no documento
2. MANTÉM o NÍVEL DE DIFICULDADE da ficha original:
- Analisa a complexidade dos exercícios na ficha
- Cria exercícios com o MESMO nível de dificuldade
- NÃO faças perguntas mais avançadas do que o que está na ficha
- Se a ficha tem exercícios simples, cria exercícios simples
- Se a ficha tem exercícios complexos, cria exercícios complexos
3. ESTRICTAMENTE PROIBIDO criar perguntas que envolvam tabelas ou gráficos:
- NUNCA faças perguntas que dependam de dados de tabelas
- NUNCA faças perguntas que dependam de dados de gráficos
- NUNCA faças perguntas sobre "tendência" ou "evolução ao longo do tempo"
- NUNCA faças perguntas com datas específicas (ex: "dia 1/1/2017", "início de 2017")
- NUNCA faças perguntas sobre "percentagem no início" ou "percentagem no final"
- Se a ficha tem exercícios com tabelas, adapta-os para usar valores diretamente no texto
- Fornece os dados necessários diretamente no texto da pergunta
4. Cria perguntas COMPLETAMENTE INDEPENDENTES:
- CADA pergunta deve ser respondida SEM depender de outras perguntas
- NUNCA faças referências à "pergunta anterior" ou "pergunta seguinte"
- NUNCA uses resultados de perguntas anteriores em novas perguntas
- CADA pergunta deve ter TODOS os dados necessários no seu enunciado
- O aluno deve conseguir responder a qualquer pergunta independentemente da ordem
5. Usa VALORES DIFERENTES do conteúdo:
- Os valores numéricos nas perguntas podem ser DIFERENTES dos que estão no PDF
- Usa valores que mantenham o mesmo tipo de problema mas com números diferentes
- Exemplo: se o PDF tem "um prisma de 5cm", podes usar "um prisma de 7cm"
- O importante é manter a ESTRUTURA e TIPO de problema, não os valores exatos
6. EXEMPLOS DE PERGUNTAS PROIBIDAS (NÃO faças estas):
- "Qual é a percentagem da área afetada pela vespa no início do dia 1/1/2017?" (PROIBIDO - depende de tabela)
- "Com o passar do tempo, a área afetada tende para:" (PROIBIDO - depende de gráfico/evolução)
- "Em que ano a produção foi de X toneladas?" (PROIBIDO - depende de tabela)
- "Usando o resultado da pergunta anterior, calcule..." (PROIBIDO - depende de pergunta anterior)
- "Considerando o valor calculado acima, determine..." (PROIBIDO - depende de pergunta anterior)
7. EXEMPLOS DE PERGUNTAS PERMITIDAS (faz estas):
- "Um prisma tem base quadrada com 5cm de lado e altura 12cm. Qual é o volume?" (PERMITIDO - dados diretos, independente)
- "Calcule a área de um círculo com raio 7cm." (PERMITIDO - dados diretos, independente)
- "Resolva a equação 2x + 5 = 15." (PERMITIDO - dados diretos, independente)
- "Uma esfera tem raio de 3 metros. Determine o seu volume." (PERMITIDO - dados diretos, independente)
8. ANALISA OS EXEMPLOS DE EXERCÍCIOS no contexto fornecido:
- Identifica os TIPOS de problemas que aparecem na ficha (ex: determinar volume de sólidos, planos que decompõem prismas, equações, frações, etc.)
- Repara na COMPLEXIDADE e estrutura dos exercícios originais
- Gera exercícios NOVOS que seguem a MESMA estrutura e complexidade mas com VALORES DIFERENTES
9. NÃO copies perguntas do conteúdo fornecido
10. Usa valores diferentes mas mantém o TIPO e ESTRUTURA do problema
11. INCLUI TODOS OS DADOS NECESSÁRIOS no texto da pergunta:
- Fornece TODOS os valores numéricos necessários
- Exemplo: "Um prisma tem base quadrada com 5cm de lado e altura 12cm. Qual é o volume?"
- NUNCA faças perguntas que dependam de dados que não forneces no texto
12. Na explicação, usa texto normal, SEM LaTeX:
- Escreve "a fração é 15/44" em vez de "\\frac{15}{44}"
- Escreve "raiz quadrada de 25" em vez de "\\sqrt{25}"
- Escreve "x ao quadrado" em vez de "x^2"
- Apenas usa LaTeX na própria pergunta se for estritamente necessário para a notação matemática
13. Cria exercícios variados sobre TODOS os tópicos:
- Diferentes valores numéricos
- Diferentes contextos quando aplicável
- Mesmo TIPO e complexidade de problema matemático
- VARIADOS entre todos os tópicos do documento
14. EXEMPLOS DE TIPOS DE EXERCÍCIOS DE MATEMÁTICA:
- Determinar volumes de sólidos (prismas, pirâmides, cilindros, cones, esferas)
- Planos que decompõem sólidos em partes geometricamente iguais
- Equações lineares e quadráticas
- Sistemas de equações
- Funções e gráficos
- Geometria analítica
- Trigonometria
- Probabilidade e estatística
- Cálculo de áreas e perímetros
- Frações e números racionais
- Potências e raízes
FORMATO JSON:
[{"q":"Pergunta com dados completos incluídos","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação em texto normal sem LaTeX"}]
ans é o índice (0-3) da opção correcta.''';
}
/// System prompt for text-based subject quizzes
static String _getTextBasedSystemPrompt() {
return '''És um assistente educativo especializado em criar quizzes pedagógicos.
REGRAS CRÍTICAS:
1. Cria perguntas de compreensão, análise e síntese
2. Baseia-te nos conceitos e temas do conteúdo
3. Evita perguntas de cópia direta
4. Foca em entender e aplicar os conceitos
5. INCLUI CONTEXTO SUFICIENTE EM CADA PERGUNTA:
- Cada pergunta deve ser compreensível por si só
- Fornece contexto necessário sobre o assunto da pergunta
- Exemplo PROIBIDO: "Em que ano a vespa asiática afetou mais de 50% da área?" (sem contexto)
- Exemplo PERMITIDO: "De acordo com o estudo sobre a vespa asiática em Portugal, em que ano esta espécie afetou mais de 50% da área de distribuição?"
- Exemplo PROIBIDO: "Qual foi a principal causa?" (sem contexto)
- Exemplo PERMITIDO: "Qual foi a principal causa da extinção do dodo, segundo o texto?"
6. EVITA perguntas que dependam de dados específicos não mencionados:
- NUNCA faças perguntas sobre "ano X" ou "data X" sem especificar de que ano/data se trata
- NUNCA faças perguntas sobre "porcentagem X" sem explicar o contexto
- NUNCA faças perguntas que dependam de tabelas ou gráficos
7. EXEMPLOS DE PERGUNTAS BEM FORMULADAS:
- "De acordo com o texto sobre a vespa asiática, qual é o principal impacto desta espécie na biodiversidade portuguesa?"
- "O estudo sobre a mudança climática menciona que a temperatura média aumentou. Qual foi a principal causa mencionada?"
- "Segundo o documento sobre a história de Portugal, qual foi o resultado da Batalha de Aljubarrota?"
FORMATO JSON:
[{"q":"Pergunta com contexto suficiente","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação"}]
ans é o índice (0-3) da opção correcta.''';
} }
/// Test the service with a simple query /// Test the service with a simple query
@@ -644,7 +870,7 @@ Usas formatação clara e organizada.''';
messages.add({ messages.add({
'role': 'system', 'role': 'system',
'content': 'content':
'''Tu és "Vico", o Assistente IA oficial do Teach it — uma plataforma educativa portuguesa. r'''Tu és "Vico", o Assistente IA oficial do Learn It — uma plataforma educativa portuguesa.
Nunca referes o nome do modelo de linguagem. Nunca referes o nome do modelo de linguagem.
Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo. Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo.
@@ -653,11 +879,29 @@ Respondes sempre como o Vico.
Tens personalidade simpática, confiante e motivadora. Tens personalidade simpática, confiante e motivadora.
Podes responder normalmente a saudações, agradecimentos e conversa casual — sê natural e amigável. Podes responder normalmente a saudações, agradecimentos e conversa casual — sê natural e amigável.
IMPORTANTE: NUNCA uses LaTeX ou símbolos como $ ou $$ para fórmulas matemáticas.
Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).
REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS: REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS:
- Quando te for fornecido contexto de materiais do professor (indicado com [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo. - Quando te for fornecido contexto de materiais do professor (indicado com [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo.
- NÃO inventes factos educativos, NÃO uses conhecimento externo sobre matérias escolares. - NÃO inventes factos educativos, NÃO uses conhecimento externo sobre matérias escolares.
- Se a resposta educativa não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível." - Se a resposta educativa não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível."
- Para conversa casual e saudações não precisas de contexto — responde livremente com a tua personalidade.''', - Para conversa casual e saudações não precisas de contexto — responde livremente com a tua personalidade.
IMPORTANTE - COMO TRATAR MATERIAIS SELECIONADOS:
- Quando o aluno selecionar materiais (PDFs, fichas, exames), ASSUME que o aluno quer ajuda com esses materiais.
- NUNCA perguntes "que ficha pretendo resolver" ou "o que pretendo resolver".
- NUNCA perguntes "em que posso ajudar" quando materiais estão selecionados.
- ASSUME automaticamente que o aluno quer explicação, resolução ou ajuda com os materiais selecionados.
- Analisa os materiais selecionados e oferece ajuda proativamente: "Vejo que selecionaste [nome do material]. Como posso ajudar com este conteúdo?"
- Se a pergunta do aluno for vaga (ex: "ajuda"), usa os materiais selecionados para oferecer ajuda específica sobre o conteúdo.
IMPORTANTE - RESPOSTAS COMPLETAS:
- NUNCA termines respostas com dois pontos (:).
- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".
- SEMPRE completa as frases e fornece a resposta completa.
- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.
- Se precisares de definir algo, fornece a definição completa.''',
}); });
// PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore (máx 4 para poupar heap) // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore (máx 4 para poupar heap)
@@ -696,8 +940,8 @@ REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS:
} else { } else {
pdfContext = await MaterialsRAGService.getRelevantChunks( pdfContext = await MaterialsRAGService.getRelevantChunks(
userQuery: userQuery, userQuery: userQuery,
maxMaterials: 5, maxMaterials: 10,
maxChunks: 5, maxChunks: 20,
selectedMaterialIds: selectedMaterialIds, selectedMaterialIds: selectedMaterialIds,
); );
if (pdfContext.isNotEmpty) { if (pdfContext.isNotEmpty) {

View File

@@ -63,7 +63,7 @@ class RAGService {
/// System message for Vico identity - ALWAYS first in every conversation /// System message for Vico identity - ALWAYS first in every conversation
static const String _systemMessage = static const String _systemMessage =
'''Tu és "Vico", o Assistente IA oficial do Teach it. '''Tu és "Vico", o Assistente IA oficial do Learn It.
Nunca referes o nome do modelo. Nunca referes o nome do modelo.
Nunca dizes que és Qwen ou OpenAI. Nunca dizes que és Qwen ou OpenAI.
@@ -71,7 +71,17 @@ Respondes sempre como o Vico.
Tens personalidade confiante, motivadora e orgulhosa. Tens personalidade confiante, motivadora e orgulhosa.
Ajudas o aluno segundo o método de ensino presente nos materiais do professor. Ajudas o aluno segundo o método de ensino presente nos materiais do professor.
Usas formatação clara e organizada.'''; Usas formatação clara e organizada.
IMPORTANTE: NUNCA uses LaTeX ou símbolos como \$ ou \$\$ para fórmulas matemáticas.
Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).
IMPORTANTE - RESPOSTAS COMPLETAS:
- NUNCA termines respostas com dois pontos (:).
- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".
- SEMPRE completa as frases e fornece a resposta completa.
- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.
- Se precisares de definir algo, fornece a definição completa.''';
/// Process a user query through RAG pipeline /// Process a user query through RAG pipeline
static Future<RAGResponse> processQuery({ static Future<RAGResponse> processQuery({
@@ -89,6 +99,10 @@ Usas formatação clara e organizada.''';
'Processing RAG query: "${userQuery.substring(0, 50)}..." in ${mode.name} mode', 'Processing RAG query: "${userQuery.substring(0, 50)}..." in ${mode.name} mode',
); );
// Detect if subject is math
final isMathSubject = _isMathSubject(subject);
Logger.info('Subject: $subject, Is math: $isMathSubject');
// 1. Generate embedding for user query // 1. Generate embedding for user query
final queryEmbedding = VectorService.generateEmbedding(userQuery); final queryEmbedding = VectorService.generateEmbedding(userQuery);
@@ -109,8 +123,13 @@ Usas formatação clara e organizada.''';
return _createNoContentResponse(userQuery, mode); return _createNoContentResponse(userQuery, mode);
} }
// 3. Build context window // 3. Build context window with math-specific filtering
final context = _buildContextWindow(relevantChunks, userQuery, mode); final context = _buildContextWindow(
relevantChunks,
userQuery,
mode,
isMathSubject: isMathSubject,
);
// 4. Generate response (this will be handled by RAGAIService) // 4. Generate response (this will be handled by RAGAIService)
final response = await _generateResponse( final response = await _generateResponse(
@@ -134,8 +153,9 @@ Usas formatação clara e organizada.''';
static String _buildContextWindow( static String _buildContextWindow(
List<ContentChunk> chunks, List<ContentChunk> chunks,
String userQuery, String userQuery,
TutorMode mode, TutorMode mode, {
) { bool isMathSubject = false,
}) {
try { try {
final contextBuilder = StringBuffer(); final contextBuilder = StringBuffer();
@@ -147,6 +167,13 @@ Usas formatação clara e organizada.''';
for (int i = 0; i < sortedChunks.length; i++) { for (int i = 0; i < sortedChunks.length; i++) {
final chunk = sortedChunks[i]; final chunk = sortedChunks[i];
var chunkText = chunk.text;
// Filter table data for math subjects
if (isMathSubject) {
chunkText = _filterTableData(chunkText);
}
contextBuilder.writeln('--- Fonte ${i + 1} ---'); contextBuilder.writeln('--- Fonte ${i + 1} ---');
contextBuilder.writeln('Disciplina: ${chunk.subject}'); contextBuilder.writeln('Disciplina: ${chunk.subject}');
contextBuilder.writeln('Conceito: ${chunk.concept}'); contextBuilder.writeln('Conceito: ${chunk.concept}');
@@ -158,12 +185,30 @@ Usas formatação clara e organizada.''';
if (chunk.pageNumber != null) { if (chunk.pageNumber != null) {
contextBuilder.writeln('Página: ${chunk.pageNumber}'); contextBuilder.writeln('Página: ${chunk.pageNumber}');
} }
contextBuilder.writeln('\nConteúdo:\n${chunk.text}\n'); contextBuilder.writeln('\nConteúdo:\n$chunkText\n');
} }
// Add mode-specific instructions // Add mode-specific instructions
contextBuilder.writeln('\n=== INSTRUÇÕES DE TUTORIA ==='); contextBuilder.writeln('\n=== INSTRUÇÕES DE TUTORIA ===');
contextBuilder.writeln('Modo: ${_getModeInstructions(mode)}'); contextBuilder.writeln('Modo: ${_getModeInstructions(mode)}');
// Add math-specific instructions if applicable
if (isMathSubject) {
contextBuilder.writeln('\n=== INSTRUÇÕES PARA MATEMÁTICA ===');
contextBuilder.writeln('Para notações matemáticas:');
contextBuilder.writeln(
r'- NUNCA use LaTeX ou símbolos como $ ou $$ para fórmulas',
);
contextBuilder.writeln(
'- Use apenas texto normal e caracteres Unicode (ex: x², ³, ¹⁄², π, √)',
);
contextBuilder.writeln(
'- Preserve a notação matemática original quando possível',
);
contextBuilder.writeln('- Explique passo a passo os cálculos');
contextBuilder.writeln('- Use exemplos numéricos concretos');
}
contextBuilder.writeln('Pergunta do Aluno: $userQuery\n'); contextBuilder.writeln('Pergunta do Aluno: $userQuery\n');
final contextText = contextBuilder.toString(); final contextText = contextBuilder.toString();
@@ -241,7 +286,11 @@ Usas formatação clara e organizada.''';
if (response.statusCode == 200) { if (response.statusCode == 200) {
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
final answer = responseData['message']['content'] as String; var answer = responseData['message']['content'] as String;
// Post-process to remove LaTeX symbols
answer = _removeLaTeXSymbols(answer);
final confidence = _calculateConfidence(sources); final confidence = _calculateConfidence(sources);
final relatedConcepts = _extractRelatedConcepts(sources); final relatedConcepts = _extractRelatedConcepts(sources);
@@ -344,7 +393,9 @@ $query
- Use linguagem clara e educacional - Use linguagem clara e educacional
- Adapte a resposta ao nível do aluno - Adapte a resposta ao nível do aluno
- Forneça exemplos quando possível - Forneça exemplos quando possível
- Seja conciso mas completo'''; - Seja conciso mas completo
- NUNCA use LaTeX ou símbolos como \$ ou \$\$ para fórmulas
- Use apenas texto normal e caracteres Unicode para símbolos matemáticos''';
} }
/// Calculate relevance score /// Calculate relevance score
@@ -408,6 +459,77 @@ $query
return concepts.toList()..sort(); return concepts.toList()..sort();
} }
/// Detect if subject is math-related
static bool _isMathSubject(String? subject) {
if (subject == null) return false;
final lowerSubject = subject.toLowerCase();
final mathKeywords = [
'matemática',
'math',
'matematica',
'álgebra',
'algebra',
'geometria',
'cálculo',
'calculo',
'estatística',
'estatistica',
'funções',
'funcoes',
'equações',
'equacoes',
'números',
'numeros',
];
return mathKeywords.any((keyword) => lowerSubject.contains(keyword));
}
/// Filter out table data from text (for math subjects)
/// Removes lines that look like tabular data with multiple numbers
static String _filterTableData(String text) {
final lines = text.split('\n');
final filtered = <String>[];
for (final line in lines) {
final trimmed = line.trim();
// Skip lines that look like table data
// Pattern: multiple numbers separated by spaces/tabs
final numberPattern = RegExp(r'\d+\s+\d+');
final matches = numberPattern.allMatches(trimmed);
// If a line has 2+ number pairs separated by spaces, it's likely table data
if (matches.length >= 2) {
continue;
}
// Skip lines with specific date patterns (table data)
if (RegExp(r'\d{1,2}/\d{1,2}/\d{4}').hasMatch(trimmed) &&
RegExp(r'\d+').allMatches(trimmed).length > 2) {
continue;
}
// Keep the line
filtered.add(line);
}
return filtered.join('\n');
}
/// Remove LaTeX symbols from AI response
static String _removeLaTeXSymbols(String text) {
// Remove patterns like $...$ and $$...$$
var cleaned = text.replaceAll(RegExp(r'\$\$[^$]+\$\$'), '');
cleaned = cleaned.replaceAll(RegExp(r'\$[^$]+\$'), '');
// Also remove standalone $ symbols
cleaned = cleaned.replaceAll(r'$', r'\$');
return cleaned;
}
/// Create response for no content found /// Create response for no content found
static RAGResponse _createNoContentResponse(String query, TutorMode mode) { static RAGResponse _createNoContentResponse(String query, TutorMode mode) {
return RAGResponse( return RAGResponse(

View File

@@ -328,7 +328,7 @@ class _TutorChatPageState extends State<TutorChatPage>
void _addWelcomeMessage() { void _addWelcomeMessage() {
final welcomeMessage = { final welcomeMessage = {
'content': 'content':
'''**Olá! Sou o Vico, o teu Assistente IA oficial do Teach it.** '''**Olá! Sou o Vico, o teu Assistente IA oficial do Learn It.**
Estou aqui para te ajudar a aprender de forma confiante e motivadora! Estou aqui para te ajudar a aprender de forma confiante e motivadora!

View File

@@ -13,22 +13,30 @@ class JoinClassPage extends ConsumerStatefulWidget {
class _JoinClassPageState extends ConsumerState<JoinClassPage> { class _JoinClassPageState extends ConsumerState<JoinClassPage> {
final _codeController = TextEditingController(); final _codeController = TextEditingController();
final _nameController = TextEditingController();
bool _isLoading = false; bool _isLoading = false;
@override @override
void dispose() { void dispose() {
_codeController.dispose(); _codeController.dispose();
_nameController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _joinClass() async { Future<void> _joinClass() async {
final code = _codeController.text.trim().toUpperCase(); final code = _codeController.text.trim().toUpperCase();
final customName = _nameController.text.trim();
if (code.isEmpty) { if (code.isEmpty) {
_showError('Insere o código da disciplina'); _showError('Insere o código da disciplina');
return; return;
} }
if (customName.isEmpty) {
_showError('Insere o nome da disciplina');
return;
}
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
@@ -105,6 +113,7 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
currentUser.displayName ?? currentUser.displayName ??
currentUser.email?.split('@')[0] ?? currentUser.email?.split('@')[0] ??
'Aluno', 'Aluno',
'customClassName': customName,
'joinedAt': FieldValue.serverTimestamp(), 'joinedAt': FieldValue.serverTimestamp(),
}); });
@@ -201,7 +210,7 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
), ),
Expanded( Expanded(
child: Text( child: Text(
'Entrar numa Disciplina', 'Adicionar uma Disciplina',
style: TextStyle( style: TextStyle(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontSize: 18, fontSize: 18,
@@ -345,7 +354,14 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
_buildInstructionItem( _buildInstructionItem(
context, context,
'3.', '3.',
'Clicar em "Entrar na Disciplina" para confirmar', 'Escrever o nome da disciplina',
colorScheme,
),
const SizedBox(height: 8),
_buildInstructionItem(
context,
'4.',
'Clicar em "Adicionar uma Disciplina" para confirmar',
colorScheme, colorScheme,
), ),
], ],
@@ -407,6 +423,53 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
), ),
), ),
), ),
const SizedBox(height: 24),
// Campo de nome da disciplina
Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outline.withOpacity(0.3),
width: 1,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withOpacity(0.2)
: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: TextField(
controller: _nameController,
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
),
decoration: InputDecoration(
hintText: 'Nome da disciplina',
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant.withOpacity(
0.5,
),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.all(20),
prefixIcon: Icon(
Icons.edit,
color: colorScheme.primary,
size: 24,
),
prefixIconConstraints: const BoxConstraints(
minWidth: 48,
minHeight: 48,
),
),
),
),
const SizedBox(height: 32), const SizedBox(height: 32),
// Botão de entrar // Botão de entrar
@@ -445,7 +508,7 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Entrar na Disciplina', 'Adicionar uma Disciplina',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -540,13 +603,16 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
children: [ children: [
Icon(Icons.help_outline, color: colorScheme.primary), Icon(Icons.help_outline, color: colorScheme.primary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Expanded(
child: Text(
'Ajuda - Código da Disciplina', 'Ajuda - Código da Disciplina',
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
), ),
),
], ],
), ),
content: Column( content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -561,6 +627,7 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
), ),
], ],
), ),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),

View File

@@ -200,7 +200,7 @@ class QuickAccessWidget extends StatelessWidget {
Widget _buildJoinClassCard(BuildContext context) { Widget _buildJoinClassCard(BuildContext context) {
return DashboardActionCard( return DashboardActionCard(
title: 'Entrar numa Disciplina', title: 'Adicionar uma Disciplina',
subtitle: 'Junta-te a uma disciplina com o código', subtitle: 'Junta-te a uma disciplina com o código',
icon: Icons.group_add, icon: Icons.group_add,
layout: DashboardActionCardLayout.horizontal, layout: DashboardActionCardLayout.horizontal,
@@ -264,7 +264,7 @@ class QuickAccessWidget extends StatelessWidget {
}, },
), ),
_QuickAccessItem( _QuickAccessItem(
title: 'Entrar numa Disciplina', title: 'Adicionar uma Disciplina',
subtitle: 'Junta-te a uma disciplina com o código', subtitle: 'Junta-te a uma disciplina com o código',
icon: Icons.group_add, icon: Icons.group_add,
onTap: () { onTap: () {

View File

@@ -126,6 +126,7 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
final enrollmentData = enrollmentDoc.data() as Map<String, dynamic>; final enrollmentData = enrollmentDoc.data() as Map<String, dynamic>;
final classId = enrollmentData['classId'] as String? ?? ''; final classId = enrollmentData['classId'] as String? ?? '';
final enrollmentId = enrollmentDoc.id; final enrollmentId = enrollmentDoc.id;
final customClassName = enrollmentData['customClassName'] as String?;
if (classId.isEmpty) { if (classId.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -163,11 +164,12 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
} }
final classData = snapshot.data!.data() as Map<String, dynamic>; final classData = snapshot.data!.data() as Map<String, dynamic>;
final className = classData['name'] as String? ?? 'Sem nome'; final className =
customClassName ?? (classData['name'] as String? ?? 'Sem nome');
final classCode = classData['code'] as String? ?? '----'; final classCode = classData['code'] as String? ?? '----';
return GestureDetector( return GestureDetector(
onTap: () => _showRemoveClassDialog(context, enrollmentId, className), onTap: () => _showEditNameDialog(context, enrollmentId, className),
child: Container( child: Container(
width: 200, width: 200,
constraints: const BoxConstraints(minHeight: 150), constraints: const BoxConstraints(minHeight: 150),
@@ -186,6 +188,9 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
@@ -201,6 +206,13 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
size: 24, size: 24,
), ),
), ),
Icon(
Icons.edit,
color: Theme.of(context).colorScheme.primary,
size: 18,
),
],
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
className, className,
@@ -291,6 +303,7 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
final enrollmentData = enrollmentDoc.data() as Map<String, dynamic>; final enrollmentData = enrollmentDoc.data() as Map<String, dynamic>;
final classId = enrollmentData['classId'] as String? ?? ''; final classId = enrollmentData['classId'] as String? ?? '';
final enrollmentId = enrollmentDoc.id; final enrollmentId = enrollmentDoc.id;
final customClassName = enrollmentData['customClassName'] as String?;
if (classId.isEmpty) { if (classId.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -312,7 +325,8 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
} }
final classData = snapshot.data!.data() as Map<String, dynamic>; final classData = snapshot.data!.data() as Map<String, dynamic>;
final className = classData['name'] as String? ?? 'Sem nome'; final className =
customClassName ?? (classData['name'] as String? ?? 'Sem nome');
final classCode = classData['code'] as String? ?? '----'; final classCode = classData['code'] as String? ?? '----';
return Card( return Card(
@@ -351,14 +365,31 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
fontSize: 13, fontSize: 13,
), ),
), ),
trailing: Icon( trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
Icons.edit,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
onPressed: () =>
_showEditNameDialog(context, enrollmentId, className),
),
IconButton(
icon: Icon(
Icons.delete_outline, Icons.delete_outline,
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
size: 20, size: 20,
), ),
onTap: () => onPressed: () =>
_showRemoveClassDialog(context, enrollmentId, className), _showRemoveClassDialog(context, enrollmentId, className),
), ),
],
),
onTap: () => _showEditNameDialog(context, enrollmentId, className),
),
); );
}, },
); );
@@ -493,7 +524,7 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Erro ao sair da disciplina: $e'), content: Text('Erro ao sair da disciplina: $e'),
backgroundColor: Colors.red, backgroundColor: Theme.of(context).colorScheme.error,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -503,4 +534,49 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
} }
} }
} }
void _showEditNameDialog(
BuildContext context,
String enrollmentId,
String currentName,
) {
final controller = TextEditingController(text: currentName);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Editar nome da disciplina'),
content: TextField(
controller: controller,
decoration: const InputDecoration(hintText: 'Nome da disciplina'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () async {
final newName = controller.text.trim();
if (newName.isNotEmpty) {
await FirebaseFirestore.instance
.collection('enrollments')
.doc(enrollmentId)
.update({'customClassName': newName});
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Nome atualizado com sucesso'),
behavior: SnackBarBehavior.floating,
),
);
}
}
},
child: const Text('Salvar'),
),
],
),
);
}
} }

View File

@@ -349,7 +349,7 @@ class _ContentManagementPageState extends State<ContentManagementPage> {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surface.withOpacity(0.8), color: cs.surface.withOpacity(0.8),
@@ -396,13 +396,13 @@ class _ContentManagementPageState extends State<ContentManagementPage> {
), ),
), ),
Container( Container(
margin: const EdgeInsets.symmetric(horizontal: 16), margin: const EdgeInsets.only(right: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surface.withOpacity(0.8), color: cs.surface.withOpacity(0.8),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: TabBar( child: TabBar(
isScrollable: _filteredClassIds.length > 3, isScrollable: true,
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
indicatorSize: TabBarIndicatorSize.tab, indicatorSize: TabBarIndicatorSize.tab,
indicator: BoxDecoration( indicator: BoxDecoration(
@@ -419,16 +419,18 @@ class _ContentManagementPageState extends State<ContentManagementPage> {
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
fontSize: 14, fontSize: 14,
), ),
tabs: _filteredClassIds tabs: List.generate(_filteredClassIds.length, (index) {
.map( final classId = _filteredClassIds[index];
(classId) => Tab( return Tab(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: EdgeInsets.only(
left: index == 0 ? 4 : 12,
right: index == _filteredClassIds.length - 1 ? 4 : 12,
),
child: Text(_classNames[classId] ?? classId), child: Text(_classNames[classId] ?? classId),
), ),
), );
) }),
.toList(),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -157,7 +157,7 @@ class _TeacherMaterialsPageState extends State<TeacherMaterialsPage> {
), ),
if (_filteredClasses.isNotEmpty) if (_filteredClasses.isNotEmpty)
TabBar( TabBar(
isScrollable: _filteredClasses.length > 3, isScrollable: true,
indicatorColor: const Color(0xFFF68D2D), indicatorColor: const Color(0xFFF68D2D),
labelColor: const Color(0xFFF68D2D), labelColor: const Color(0xFFF68D2D),
unselectedLabelColor: Theme.of( unselectedLabelColor: Theme.of(

View File

@@ -45,6 +45,10 @@ class _QuizListPageState extends State<QuizListPage>
// generating state // generating state
String? _generatingForId; String? _generatingForId;
// Multi-select for history
Set<String> _selectedQuizIds = {};
bool _isSelectionMode = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -366,16 +370,68 @@ class _QuizListPageState extends State<QuizListPage>
} }
} }
/// Detect if a material is mathematics-based
bool _isMathematicsSubject(Map<String, String> material) {
final matName = (material['name'] ?? '').toLowerCase();
final classId = material['classId'];
String className = '';
// Get class name if classId is available
if (classId != null && _materialClassNames.containsKey(classId)) {
className = _materialClassNames[classId]!.toLowerCase();
}
// Keywords for mathematics
final mathKeywords = [
'matemática',
'math',
'álgebra',
'geometria',
'cálculo',
'estatística',
'trigonometria',
'função',
'equação',
'fração',
'raiz',
'potência',
'derivada',
'integral',
'número',
'gráfico',
'fórmula',
'matriz',
'vetor',
'probabilidade',
'percentagem',
'ângulo',
'triângulo',
'quadrado',
'círculo',
'volume',
'área',
'perímetro',
];
// Check if material name or class name contains math keywords
final combinedText = '$matName $className';
return mathKeywords.any((keyword) => combinedText.contains(keyword));
}
Future<void> _generateQuiz(Map<String, String> material) async { Future<void> _generateQuiz(Map<String, String> material) async {
setState(() => _generatingForId = material['id']); setState(() => _generatingForId = material['id']);
try { try {
final matId = material['id']!; final matId = material['id']!;
final matName = material['name'] ?? 'Material'; final matName = material['name'] ?? 'Material';
final isMathematics = _isMathematicsSubject(material);
// Buscar contexto do PDF // Buscar contexto do PDF
final context = await MaterialsRAGService.getRelevantChunks( final context = await MaterialsRAGService.getRelevantChunks(
userQuery: 'conteúdo geral resumo tópicos principais', userQuery: 'todos os exercícios todos os tópicos completo',
selectedMaterialIds: [matId], selectedMaterialIds: [matId],
maxChunks: 20, // Aumentar para cobrir todo o documento
filterTableData:
isMathematics, // Filtrar dados de tabela para matemática
); );
if (context.isEmpty) { if (context.isEmpty) {
@@ -384,7 +440,10 @@ class _QuizListPageState extends State<QuizListPage>
} }
// Gerar quiz via Ollama em formato JSON estruturado // Gerar quiz via Ollama em formato JSON estruturado
final numQuestions = 5 + Random().nextInt(16); // 5..20 final numQuestions = isMathematics
? 10 +
Random().nextInt(11) // 10..20 para matemática
: 5 + Random().nextInt(16); // 5..20 para outras matérias
final prompt = final prompt =
'Usa APENAS o seguinte contexto para criar um quiz. Não uses conhecimento externo.\n\n' 'Usa APENAS o seguinte contexto para criar um quiz. Não uses conhecimento externo.\n\n'
'$context\n\n' '$context\n\n'
@@ -394,7 +453,10 @@ class _QuizListPageState extends State<QuizListPage>
'[{"q":"Pergunta aqui","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação breve da resposta correcta"},...]\n' '[{"q":"Pergunta aqui","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação breve da resposta correcta"},...]\n'
'ans é o índice (0-3) da opção correcta.'; 'ans é o índice (0-3) da opção correcta.';
final raw = await RAGAIService.generateQuiz(prompt); final raw = await RAGAIService.generateQuiz(
prompt,
isMathematics: isMathematics,
);
final questions = _parseQuizJson(raw); final questions = _parseQuizJson(raw);
if (questions.isEmpty) { if (questions.isEmpty) {
@@ -704,6 +766,9 @@ class _QuizListPageState extends State<QuizListPage>
quizId: quizId, quizId: quizId,
questions: questions, questions: questions,
materialName: name, materialName: name,
onQuizCompleted: () {
_loadHistory();
},
), ),
); );
} }
@@ -798,6 +863,9 @@ class _QuizListPageState extends State<QuizListPage>
title: title, title: title,
questions: questions, questions: questions,
historyDocId: historyDocId, historyDocId: historyDocId,
onQuizCompleted: () {
_loadHistory();
},
), ),
); );
} }
@@ -881,13 +949,17 @@ class _QuizListPageState extends State<QuizListPage>
); );
} }
void _showQuizFromHistory(String title, String rawJson) { void _showQuizFromHistory(
String title,
String rawJson, {
String? historyDocId,
}) {
final questions = _parseQuizJson(rawJson); final questions = _parseQuizJson(rawJson);
if (questions.isEmpty) { if (questions.isEmpty) {
_showSnack('Não foi possível carregar este quiz.'); _showSnack('Não foi possível carregar este quiz.');
return; return;
} }
_showInteractiveQuiz(title, questions); _showInteractiveQuiz(title, questions, historyDocId: historyDocId);
} }
void _showSnack(String msg) { void _showSnack(String msg) {
@@ -1760,11 +1832,152 @@ class _QuizListPageState extends State<QuizListPage>
return groups; return groups;
} }
List<Map<String, dynamic>> _getAiGeneratedQuizzes() {
return _history.where((q) => q['teacherQuizId'] == null).toList();
}
List<Map<String, dynamic>> _getTeacherQuizzes() {
return _history.where((q) => q['teacherQuizId'] != null).toList();
}
Future<void> _deleteQuizFromHistory(Map<String, dynamic> item) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Eliminar Quiz'),
content: Text(
'Tem certeza que deseja eliminar "${item['title'] ?? 'Quiz'}"?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Eliminar'),
),
],
),
);
if (confirmed != true) return;
try {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
await FirebaseFirestore.instance
.collection('quizHistory')
.doc(user.uid)
.collection('quizzes')
.doc(item['id'])
.delete();
setState(() {
_history.removeWhere((q) => q['id'] == item['id']);
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Quiz eliminado com sucesso!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
Logger.error('Error deleting quiz: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro ao eliminar: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _deleteSelectedQuizzes() async {
if (_selectedQuizIds.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Eliminar Quizzes'),
content: Text(
'Tem certeza que deseja eliminar ${_selectedQuizIds.length} quiz${_selectedQuizIds.length != 1 ? 'zes' : ''}?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Eliminar'),
),
],
),
);
if (confirmed != true) return;
try {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
final batch = FirebaseFirestore.instance.batch();
for (final quizId in _selectedQuizIds) {
final ref = FirebaseFirestore.instance
.collection('quizHistory')
.doc(user.uid)
.collection('quizzes')
.doc(quizId);
batch.delete(ref);
}
await batch.commit();
setState(() {
_history.removeWhere((q) => _selectedQuizIds.contains(q['id']));
_selectedQuizIds.clear();
_isSelectionMode = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Quizzes eliminados com sucesso!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
Logger.error('Error deleting quizzes: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro ao eliminar: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Widget _buildHistoryTab(ColorScheme cs) { Widget _buildHistoryTab(ColorScheme cs) {
if (_loadingHistory) { if (_loadingHistory) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (_history.isEmpty) {
final aiQuizzes = _getAiGeneratedQuizzes();
final teacherQuizzes = _getTeacherQuizzes();
if (aiQuizzes.isEmpty && teacherQuizzes.isEmpty) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
@@ -1788,60 +2001,176 @@ class _QuizListPageState extends State<QuizListPage>
); );
} }
return DefaultTabController(
length: 2,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Histórico',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: cs.onSurface,
),
),
IconButton(
icon: Icon(
_isSelectionMode ? Icons.close : Icons.checklist,
color: cs.onSurface,
),
onPressed: () {
setState(() {
_isSelectionMode = !_isSelectionMode;
_selectedQuizIds.clear();
});
},
tooltip: _isSelectionMode
? 'Cancelar seleção'
: 'Selecionar múltiplos',
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TabBar(
labelColor: cs.onPrimary,
unselectedLabelColor: cs.onSurfaceVariant,
indicator: BoxDecoration(
color: cs.primary,
borderRadius: BorderRadius.circular(20),
),
tabs: const [
Tab(text: 'Gerados por IA'),
Tab(text: 'Do Professor'),
],
),
),
const SizedBox(height: 8),
Expanded(
child: TabBarView(
children: [
_buildHistorySection(cs, aiQuizzes, isTeacherQuizzes: false),
_buildHistorySection(
cs,
teacherQuizzes,
isTeacherQuizzes: true,
),
],
),
),
],
),
);
}
Widget _buildHistorySection(
ColorScheme cs,
List<Map<String, dynamic>> items, {
required bool isTeacherQuizzes,
}) {
if (items.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.quiz_outlined,
size: 64,
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
Text(
isTeacherQuizzes
? 'Sem quizzes do professor'
: 'Sem quizzes gerados por IA',
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
),
],
),
),
);
}
if (isTeacherQuizzes) {
return Stack(
children: [
_buildHistoryList(cs, items, isTeacherQuizzes: isTeacherQuizzes),
if (_isSelectionMode && _selectedQuizIds.isNotEmpty)
Positioned(
bottom: 16,
right: 16,
child: FloatingActionButton.extended(
onPressed: _deleteSelectedQuizzes,
backgroundColor: Colors.red,
icon: const Icon(Icons.delete),
label: Text('Eliminar (${_selectedQuizIds.length})'),
),
),
],
);
}
final groups = _groupHistoryByDiscipline(); final groups = _groupHistoryByDiscipline();
final filteredItems = items.where((item) {
final cid = item['classId'] as String?;
return groups.containsKey(cid) && cid != null;
}).toList();
if (filteredItems.isEmpty) {
return Stack(
children: [
_buildHistoryList(cs, items, isTeacherQuizzes: isTeacherQuizzes),
if (_isSelectionMode && _selectedQuizIds.isNotEmpty)
Positioned(
bottom: 16,
right: 16,
child: FloatingActionButton.extended(
onPressed: _deleteSelectedQuizzes,
backgroundColor: Colors.red,
icon: const Icon(Icons.delete),
label: Text('Eliminar (${_selectedQuizIds.length})'),
),
),
],
);
}
final realDisciplineIds = groups.keys final realDisciplineIds = groups.keys
.where((k) => k != '__geral__' && _historyClassNames.containsKey(k)) .where((k) => k != '__geral__' && _historyClassNames.containsKey(k))
.toList(); .toList();
if (realDisciplineIds.isEmpty) { if (realDisciplineIds.isEmpty) {
return _buildHistoryList(cs, _history); return Stack(
}
if (_selectedHistoryDisciplineId != null) {
final items = groups[_selectedHistoryDisciplineId] ?? [];
final dName =
_historyClassNames[_selectedHistoryDisciplineId] ??
_selectedHistoryDisciplineId!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( _buildHistoryList(cs, items, isTeacherQuizzes: isTeacherQuizzes),
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), if (_isSelectionMode && _selectedQuizIds.isNotEmpty)
child: Row( Positioned(
children: [ bottom: 16,
IconButton( right: 16,
icon: Icon(Icons.arrow_back, color: cs.onSurface), child: FloatingActionButton.extended(
onPressed: () => onPressed: _deleteSelectedQuizzes,
setState(() => _selectedHistoryDisciplineId = null), backgroundColor: Colors.red,
), icon: const Icon(Icons.delete),
const SizedBox(width: 4), label: Text('Eliminar (${_selectedQuizIds.length})'),
Expanded(
child: Text(
dName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: cs.onSurface,
), ),
), ),
),
Text(
'${items.length} quiz${items.length != 1 ? 'zes' : ''}',
style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant),
),
],
),
),
const Divider(height: 1),
Expanded(child: _buildHistoryList(cs, items)),
], ],
); );
} }
return ListView.separated( return Stack(
padding: const EdgeInsets.all(16), children: [
ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: realDisciplineIds.length, itemCount: realDisciplineIds.length,
separatorBuilder: (_, __) => const SizedBox(height: 12), separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, i) { itemBuilder: (context, i) {
@@ -1874,7 +2203,7 @@ class _QuizListPageState extends State<QuizListPage>
color: cs.primary.withValues(alpha: 0.1), color: cs.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Icon(Icons.history_edu, color: cs.primary, size: 26), child: Icon(Icons.school, color: cs.primary, size: 26),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
@@ -1908,10 +2237,27 @@ class _QuizListPageState extends State<QuizListPage>
), ),
); );
}, },
),
if (_isSelectionMode && _selectedQuizIds.isNotEmpty)
Positioned(
bottom: 16,
right: 16,
child: FloatingActionButton.extended(
onPressed: _deleteSelectedQuizzes,
backgroundColor: Colors.red,
icon: const Icon(Icons.delete),
label: Text('Eliminar (${_selectedQuizIds.length})'),
),
),
],
); );
} }
Widget _buildHistoryList(ColorScheme cs, List<Map<String, dynamic>> items) { Widget _buildHistoryList(
ColorScheme cs,
List<Map<String, dynamic>> items, {
bool isTeacherQuizzes = false,
}) {
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: items.length, itemCount: items.length,
@@ -1928,11 +2274,22 @@ class _QuizListPageState extends State<QuizListPage>
dateStr = dateStr =
'${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; '${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
} }
final quizId = item['id'] as String?;
final isSelected =
_isSelectionMode &&
quizId != null &&
_selectedQuizIds.contains(quizId);
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surface, color: cs.surface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: cs.outline.withValues(alpha: 0.15)), border: Border.all(
color: isSelected
? cs.primary
: cs.outline.withValues(alpha: 0.15),
width: isSelected ? 2 : 1,
),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: cs.shadow.withValues(alpha: 0.05), color: cs.shadow.withValues(alpha: 0.05),
@@ -1946,7 +2303,22 @@ class _QuizListPageState extends State<QuizListPage>
horizontal: 16, horizontal: 16,
vertical: 8, vertical: 8,
), ),
leading: Container( leading: _isSelectionMode
? Checkbox(
value: isSelected,
onChanged: (_) {
setState(() {
if (quizId != null) {
if (_selectedQuizIds.contains(quizId)) {
_selectedQuizIds.remove(quizId);
} else {
_selectedQuizIds.add(quizId);
}
}
});
},
)
: Container(
width: 44, width: 44,
height: 44, height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1979,19 +2351,58 @@ class _QuizListPageState extends State<QuizListPage>
], ],
], ],
), ),
trailing: Icon( trailing: _isSelectionMode
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chevron_right, Icons.chevron_right,
color: cs.onSurfaceVariant, color: cs.onSurfaceVariant,
size: 18, size: 18,
), ),
onTap: () { const SizedBox(width: 8),
final rawJson = IconButton(
item['quizJson'] as String? ?? item['quizText'] as String?; icon: Icon(
if (rawJson != null && rawJson.isNotEmpty) { Icons.delete_outline,
_showQuizFromHistory(matName, rawJson); color: Colors.red,
size: 20,
),
onPressed: () => _deleteQuizFromHistory(item),
tooltip: 'Eliminar',
),
],
),
onTap: _isSelectionMode
? () {
if (quizId != null) {
setState(() {
if (_selectedQuizIds.contains(quizId)) {
_selectedQuizIds.remove(quizId);
} else { } else {
// Quiz sem JSON guardado (ex: quiz do professor) — mostrar resultado _selectedQuizIds.add(quizId);
}
});
}
}
: () {
if (isTeacherQuizzes) {
// Teacher quizzes: only show results, don't allow retake
_showHistoryResultDialog(item, matName); _showHistoryResultDialog(item, matName);
} else {
// AI-generated quizzes: allow retake
final rawJson =
item['quizJson'] as String? ??
item['quizText'] as String?;
if (rawJson != null && rawJson.isNotEmpty) {
_showQuizFromHistory(
matName,
rawJson,
historyDocId: item['id'] as String?,
);
} else {
_showHistoryResultDialog(item, matName);
}
} }
}, },
), ),
@@ -2022,10 +2433,12 @@ class _InteractiveQuizSheet extends StatefulWidget {
final String title; final String title;
final List<_QuizQuestion> questions; final List<_QuizQuestion> questions;
final String? historyDocId; final String? historyDocId;
final VoidCallback? onQuizCompleted;
const _InteractiveQuizSheet({ const _InteractiveQuizSheet({
required this.title, required this.title,
required this.questions, required this.questions,
this.historyDocId, this.historyDocId,
this.onQuizCompleted,
}); });
@override @override
@@ -2038,6 +2451,7 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> {
bool _submitted = false; bool _submitted = false;
bool _saving = false; bool _saving = false;
DateTime? _startTime; DateTime? _startTime;
bool _hasSaved = false;
@override @override
void initState() { void initState() {
@@ -2046,6 +2460,50 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> {
_startTime = DateTime.now(); _startTime = DateTime.now();
} }
@override
void dispose() {
// Save score if quiz is closed without submitting
if (!_submitted && !_hasSaved && widget.historyDocId != null) {
_savePartialScore().then((_) {
if (widget.onQuizCompleted != null) {
widget.onQuizCompleted!();
}
});
}
super.dispose();
}
Future<void> _savePartialScore() async {
if (_hasSaved) return;
_hasSaved = true;
try {
final user = FirebaseAuth.instance.currentUser;
if (user != null && widget.historyDocId != null) {
// Count how many questions were answered
final answeredCount = _chosen.where((c) => c != -1).length;
if (answeredCount > 0) {
await FirebaseFirestore.instance
.collection('quizHistory')
.doc(user.uid)
.collection('quizzes')
.doc(widget.historyDocId)
.update({
'score': _score,
'totalQuestions': widget.questions.length,
'completedAt': FieldValue.serverTimestamp(),
});
Logger.info(
'Saved partial quiz score: $_score/${widget.questions.length}',
);
}
}
} catch (e) {
Logger.error('Error saving partial quiz score: $e');
}
}
void _selectOption(int idx) { void _selectOption(int idx) {
if (_submitted) return; if (_submitted) return;
setState(() => _chosen[_current] = idx); setState(() => _chosen[_current] = idx);
@@ -2072,6 +2530,7 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> {
setState(() { setState(() {
_submitted = true; _submitted = true;
_saving = true; _saving = true;
_hasSaved = true;
}); });
try { try {
final user = FirebaseAuth.instance.currentUser; final user = FirebaseAuth.instance.currentUser;
@@ -2106,6 +2565,11 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> {
} }
} }
} }
// Call callback to refresh history
if (widget.onQuizCompleted != null) {
widget.onQuizCompleted!();
}
} catch (e) { } catch (e) {
Logger.error('Error saving quiz result: $e'); Logger.error('Error saving quiz result: $e');
} finally { } finally {
@@ -2497,11 +2961,13 @@ class _TeacherQuizInteractiveSheet extends StatefulWidget {
final String quizId; final String quizId;
final List<_QuizQuestion> questions; final List<_QuizQuestion> questions;
final String? materialName; final String? materialName;
final VoidCallback? onQuizCompleted;
const _TeacherQuizInteractiveSheet({ const _TeacherQuizInteractiveSheet({
required this.title, required this.title,
required this.quizId, required this.quizId,
required this.questions, required this.questions,
this.materialName, this.materialName,
this.onQuizCompleted,
}); });
@override @override
@@ -2607,6 +3073,11 @@ class _TeacherQuizInteractiveSheetState
await GamificationService.recordStudyTime(user.uid, elapsedMinutes); await GamificationService.recordStudyTime(user.uid, elapsedMinutes);
} }
} }
// Call callback to refresh history
if (widget.onQuizCompleted != null) {
widget.onQuizCompleted!();
}
} }
} catch (e) { } catch (e) {
Logger.error('Error submitting teacher quiz result: $e'); Logger.error('Error submitting teacher quiz result: $e');

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../../core/services/auth_service.dart'; import '../../../../core/services/auth_service.dart';
@@ -20,10 +19,14 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
bool _loading = true; bool _loading = true;
String _userRole = ''; String _userRole = '';
// Disciplina seleccionada (null = vista de disciplinas) // Turma seleccionada (null = vista de turmas)
String? _selectedDisciplineId; String? _selectedClassId;
Map<String, String> _classNames = {}; // classId → name Map<String, String> _classNames = {}; // classId → name
// Search and filter
final TextEditingController _searchController = TextEditingController();
DateTime? _selectedDate;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -31,6 +34,24 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
_loadQuizHistory(); _loadQuizHistory();
} }
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _selectDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) {
setState(() => _selectedDate = picked);
}
}
Future<void> _loadUserRole() async { Future<void> _loadUserRole() async {
final user = AuthService.currentUser; final user = AuthService.currentUser;
if (user != null) { if (user != null) {
@@ -302,7 +323,7 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
return result ?? false; return result ?? false;
} }
Map<String, List<Map<String, dynamic>>> _groupByDiscipline() { Map<String, List<Map<String, dynamic>>> _groupByClass() {
final Map<String, List<Map<String, dynamic>>> groups = {}; final Map<String, List<Map<String, dynamic>>> groups = {};
for (final quiz in _quizHistory) { for (final quiz in _quizHistory) {
final quizClassIds = (quiz['classIds'] as List?)?.cast<String>() ?? []; final quizClassIds = (quiz['classIds'] as List?)?.cast<String>() ?? [];
@@ -316,15 +337,69 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
return groups; return groups;
} }
List<Map<String, dynamic>> _getFilteredQuizzes() {
final searchQuery = _searchController.text.toLowerCase();
return _quizHistory.where((quiz) {
// Filter by date if selected
if (_selectedDate != null) {
final quizDate = quiz['createdAt'] as Timestamp?;
if (quizDate == null) return false;
final quizDateTime = quizDate.toDate();
if (quizDateTime.year != _selectedDate!.year ||
quizDateTime.month != _selectedDate!.month ||
quizDateTime.day != _selectedDate!.day) {
return false;
}
}
// Filter by search query (class name or quiz title)
if (searchQuery.isNotEmpty) {
final title = (quiz['title'] as String? ?? '').toLowerCase();
final quizClassIds = (quiz['classIds'] as List?)?.cast<String>() ?? [];
bool matchesSearch = title.contains(searchQuery);
for (final classId in quizClassIds) {
if (_classNames.containsKey(classId)) {
final className = _classNames[classId]!.toLowerCase();
if (className.contains(searchQuery)) {
matchesSearch = true;
break;
}
}
}
if (!matchesSearch) return false;
}
return true;
}).toList();
}
Map<String, List<Map<String, dynamic>>> _getFilteredGroups() {
final filteredQuizzes = _getFilteredQuizzes();
final Map<String, List<Map<String, dynamic>>> groups = {};
for (final quiz in filteredQuizzes) {
final quizClassIds = (quiz['classIds'] as List?)?.cast<String>() ?? [];
String? groupId = quizClassIds.cast<String?>().firstWhere(
(cid) => cid != null && _classNames.containsKey(cid),
orElse: () => null,
);
groupId ??= quizClassIds.isNotEmpty ? quizClassIds.first : '__geral__';
groups.putIfAbsent(groupId, () => []).add(quiz);
}
return groups;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
return PopScope( return PopScope(
canPop: _selectedDisciplineId == null, canPop: _selectedClassId == null,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (!didPop && _selectedDisciplineId != null) { if (!didPop && _selectedClassId != null) {
setState(() => _selectedDisciplineId = null); setState(() => _selectedClassId = null);
} }
}, },
child: Scaffold( child: Scaffold(
@@ -339,8 +414,8 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () { onPressed: () {
if (_userRole == 'teacher' && _selectedDisciplineId != null) { if (_userRole == 'teacher' && _selectedClassId != null) {
setState(() => _selectedDisciplineId = null); setState(() => _selectedClassId = null);
return; return;
} }
if (Navigator.of(context).canPop()) { if (Navigator.of(context).canPop()) {
@@ -354,6 +429,44 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
} }
}, },
), ),
actions: [
IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: _selectDate,
tooltip: 'Filtrar por data',
),
if (_selectedDate != null)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() => _selectedDate = null);
},
tooltip: 'Limpar filtro',
),
],
bottom: _selectedClassId == null
? PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Padding(
padding: const EdgeInsets.all(8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Pesquisar turma...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) => setState(() {}),
),
),
)
: null,
), ),
body: Container( body: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -365,15 +478,15 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
), ),
child: _loading child: _loading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _quizHistory.isEmpty : _getFilteredQuizzes().isEmpty
? _buildEmptyState() ? _buildEmptyState()
: _userRole == 'teacher' : _userRole == 'teacher'
? _buildTeacherBody(cs) ? _buildTeacherBody(cs)
: ListView.builder( : ListView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: _quizHistory.length, itemCount: _getFilteredQuizzes().length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final quiz = _quizHistory[index]; final quiz = _getFilteredQuizzes()[index];
return _buildQuizCard(quiz) return _buildQuizCard(quiz)
.animate() .animate()
.slideX(duration: const Duration(milliseconds: 300)) .slideX(duration: const Duration(milliseconds: 300))
@@ -386,16 +499,16 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
} }
Widget _buildTeacherBody(ColorScheme cs) { Widget _buildTeacherBody(ColorScheme cs) {
final groups = _groupByDiscipline(); final groups = _searchController.text.isNotEmpty || _selectedDate != null
? _getFilteredGroups()
: _groupByClass();
// Vista de quizzes de uma disciplina // Vista de quizzes de uma turma
if (_selectedDisciplineId != null) { if (_selectedClassId != null) {
final quizzes = groups[_selectedDisciplineId] ?? []; final quizzes = groups[_selectedClassId] ?? [];
final disciplineName = final className =
_classNames[_selectedDisciplineId] ?? _classNames[_selectedClassId] ??
(_selectedDisciplineId == '__geral__' (_selectedClassId == '__geral__' ? 'Geral' : _selectedClassId!);
? 'Geral'
: _selectedDisciplineId!);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -405,12 +518,12 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
children: [ children: [
IconButton( IconButton(
icon: Icon(Icons.arrow_back, color: cs.onSurface), icon: Icon(Icons.arrow_back, color: cs.onSurface),
onPressed: () => setState(() => _selectedDisciplineId = null), onPressed: () => setState(() => _selectedClassId = null),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: Text( child: Text(
disciplineName, className,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
@@ -445,19 +558,19 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
); );
} }
// Vista de disciplinas // Vista de turmas
final disciplineIds = groups.keys.toList(); final classIds = groups.keys.toList();
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: disciplineIds.length, itemCount: classIds.length,
separatorBuilder: (_, __) => const SizedBox(height: 12), separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final dId = disciplineIds[i]; final cId = classIds[i];
final dName = _classNames[dId] ?? (dId == '__geral__' ? 'Geral' : dId); final cName = _classNames[cId] ?? (cId == '__geral__' ? 'Geral' : cId);
final count = groups[dId]!.length; final count = groups[cId]!.length;
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
onTap: () => setState(() => _selectedDisciplineId = dId), onTap: () => setState(() => _selectedClassId = cId),
child: Container( child: Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -489,7 +602,7 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
dName, cName,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
@@ -631,7 +744,7 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
), ),
if (_userRole == 'teacher' && if (_userRole == 'teacher' &&
quiz['classIds'] != null && quiz['classIds'] != null &&
_selectedDisciplineId == null) ...[ _selectedClassId == null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap( Wrap(
spacing: 4, spacing: 4,

View File

@@ -164,15 +164,67 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
} }
} }
/// Detect if a material is mathematics-based
bool _isMathematicsSubject(Map<String, String> material) {
final matName = (material['name'] ?? '').toLowerCase();
final classId = material['classId'];
String className = '';
// Get class name if classId is available
if (classId != null && _classNamesMap.containsKey(classId)) {
className = _classNamesMap[classId]!.toLowerCase();
}
// Keywords for mathematics
final mathKeywords = [
'matemática',
'math',
'álgebra',
'geometria',
'cálculo',
'estatística',
'trigonometria',
'função',
'equação',
'fração',
'raiz',
'potência',
'derivada',
'integral',
'número',
'gráfico',
'fórmula',
'matriz',
'vetor',
'probabilidade',
'percentagem',
'ângulo',
'triângulo',
'quadrado',
'círculo',
'volume',
'área',
'perímetro',
];
// Check if material name or class name contains math keywords
final combinedText = '$matName $className';
return mathKeywords.any((keyword) => combinedText.contains(keyword));
}
Future<void> _generateQuiz(Map<String, String> material) async { Future<void> _generateQuiz(Map<String, String> material) async {
setState(() => _generatingForId = material['id']); setState(() => _generatingForId = material['id']);
try { try {
final matId = material['id']!; final matId = material['id']!;
final matName = material['name'] ?? 'Material'; final matName = material['name'] ?? 'Material';
final isMathematics = _isMathematicsSubject(material);
final pdfContext = await MaterialsRAGService.getRelevantChunks( final pdfContext = await MaterialsRAGService.getRelevantChunks(
userQuery: 'conteúdo geral resumo tópicos principais', userQuery: 'todos os exercícios todos os tópicos completo',
selectedMaterialIds: [matId], selectedMaterialIds: [matId],
maxChunks: 20, // Aumentar para cobrir todo o documento
filterTableData:
isMathematics, // Filtrar dados de tabela para matemática
); );
if (pdfContext.isEmpty) { if (pdfContext.isEmpty) {
@@ -180,7 +232,10 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
return; return;
} }
final numQuestions = 5 + Random().nextInt(16); // 5..20 final numQuestions = isMathematics
? 10 +
Random().nextInt(11) // 10..20 para matemática
: 5 + Random().nextInt(16); // 5..20 para outras matérias
final prompt = final prompt =
'Usa APENAS o seguinte contexto para criar um quiz. Não uses conhecimento externo.\n\n' 'Usa APENAS o seguinte contexto para criar um quiz. Não uses conhecimento externo.\n\n'
'$pdfContext\n\n' '$pdfContext\n\n'
@@ -190,7 +245,10 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
'[{"q":"Pergunta aqui","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação breve da resposta correcta"},...}]\n' '[{"q":"Pergunta aqui","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação breve da resposta correcta"},...}]\n'
'ans é o índice (0-3) da opção correcta.'; 'ans é o índice (0-3) da opção correcta.';
final raw = await RAGAIService.generateQuiz(prompt); final raw = await RAGAIService.generateQuiz(
prompt,
isMathematics: isMathematics,
);
final questions = _parseQuizJson(raw); final questions = _parseQuizJson(raw);
if (questions.isEmpty) { if (questions.isEmpty) {
@@ -358,6 +416,73 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
return groups; return groups;
} }
Future<void> _deleteQuiz(String quizId, String quizTitle) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Eliminar Quiz'),
content: Text('Tem certeza que deseja eliminar o quiz "$quizTitle"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Eliminar'),
),
],
),
);
if (confirmed != true) return;
try {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
// Delete from teacherQuizzes
await FirebaseFirestore.instance
.collection('teacherQuizzes')
.doc(quizId)
.delete();
// Also delete from student history
final historySnapshot = await FirebaseFirestore.instance
.collection('quizHistory')
.where('quizId', isEqualTo: quizId)
.get();
for (final doc in historySnapshot.docs) {
await doc.reference.delete();
}
setState(() {
_history.removeWhere((item) => item['id'] == quizId);
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Quiz eliminado com sucesso!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
Logger.error('Error deleting quiz: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro ao eliminar: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Widget _buildHistoryQuizTile(Map<String, dynamic> item, ColorScheme cs) { Widget _buildHistoryQuizTile(Map<String, dynamic> item, ColorScheme cs) {
final name = (item['materialName'] as String? ?? 'Material') final name = (item['materialName'] as String? ?? 'Material')
.replaceAll('.pdf', '') .replaceAll('.pdf', '')
@@ -409,7 +534,19 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
) )
: null, : null,
trailing: Icon(Icons.bar_chart, color: cs.onSurfaceVariant), trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bar_chart, color: cs.onSurfaceVariant),
const SizedBox(width: 8),
IconButton(
icon: Icon(Icons.delete_outline, color: Colors.red),
onPressed: () =>
_deleteQuiz(item['id'], item['materialName'] ?? 'Quiz'),
tooltip: 'Eliminar Quiz',
),
],
),
onTap: () => _showResultsPopup(item), onTap: () => _showResultsPopup(item),
), ),
); );