Mudanças na aba de quiz

This commit is contained in:
2026-05-17 22:21:23 +01:00
parent 2a2194699b
commit c979692fd9
5 changed files with 1289 additions and 429 deletions

View File

@@ -21,7 +21,8 @@ class MaterialsRAGService {
/// 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 {
static Future<List<Map<String, String>>>
getAvailableMaterialsForStudent() async {
try {
final user = _auth.currentUser;
if (user == null) return [];
@@ -62,7 +63,13 @@ class MaterialsRAGService {
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});
final teacherId = data['teacherId'] as String?;
result.add({
'id': doc.id,
'name': fileName,
if (classId != null) 'classId': classId,
if (teacherId != null) 'teacherId': teacherId,
});
}
}
@@ -120,9 +127,12 @@ class MaterialsRAGService {
// 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) {
if (_chunksCache.containsKey(cacheKey) &&
_chunksCache[cacheKey]!.isNotEmpty) {
fullText = _chunksCache[cacheKey]!.first;
Logger.info('Using cached text for $fileName: ${fullText.length} chars');
Logger.info(
'Using cached text for $fileName: ${fullText.length} chars',
);
} else {
try {
final teacherId = data['teacherId'] as String?;
@@ -164,7 +174,9 @@ class MaterialsRAGService {
fullText = cleaned;
// Guardar texto completo no cache com key versionada
_chunksCache[cacheKey] = [fullText];
Logger.info('PDF "$fileName" -> ${fullText.length} chars extracted');
Logger.info(
'PDF "$fileName" -> ${fullText.length} chars extracted',
);
} catch (e) {
Logger.error('Error extracting text from $fileName: $e');
continue;
@@ -176,9 +188,15 @@ class MaterialsRAGService {
final String context;
if (fullText.length <= 10000) {
context = fullText;
Logger.info('Small PDF — sending full text (${fullText.length} chars)');
Logger.info(
'Small PDF — sending full text (${fullText.length} chars)',
);
} else {
final windows = _extractKeywordWindows(fullText, userQuery, _maxRelevantChunks);
final windows = _extractKeywordWindows(
fullText,
userQuery,
_maxRelevantChunks,
);
context = windows.join('\n\n---\n\n');
Logger.info('Large PDF — keyword windows: ${windows.length}');
}
@@ -205,7 +223,11 @@ class MaterialsRAGService {
/// Método legacy - mantido para compatibilidade mas usa chunk retrieval
@Deprecated('Use getRelevantChunks with userQuery instead')
static Future<String> getMaterialsContext({int maxMaterials = 5}) async {
return getRelevantChunks(userQuery: '', maxMaterials: maxMaterials, maxChunks: 3);
return getRelevantChunks(
userQuery: '',
maxMaterials: maxMaterials,
maxChunks: 3,
);
}
/// Get teacher IDs from student's enrolled classes
@@ -239,11 +261,11 @@ class MaterialsRAGService {
// 3. Buscar turmas e extrair teacherIds
final Set<String> teacherIds = {};
// Firestore whereIn limit is 10, so process in batches if needed
for (int i = 0; i < classIds.length; i += 10) {
final batch = classIds.skip(i).take(10).toList();
final classSnapshot = await _firestore
.collection('classes')
.where(FieldPath.documentId, whereIn: batch)
@@ -273,7 +295,10 @@ class MaterialsRAGService {
/// 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 {
static Future<String> _extractFullText(
String fileName,
String teacherId,
) async {
PdfDocument? document;
try {
final ref = _storage
@@ -306,12 +331,16 @@ class MaterialsRAGService {
for (int i = startPage; i < totalPages; i++) {
if (buffer.length >= _maxExtractedChars) break;
try {
final pageText = extractor.extractText(startPageIndex: i, endPageIndex: i).trim();
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) ||
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') ||
@@ -355,8 +384,12 @@ class MaterialsRAGService {
? 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}');
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 text from $fileName: $e');
@@ -381,10 +414,9 @@ class MaterialsRAGService {
// 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 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]'))
@@ -428,7 +460,9 @@ class MaterialsRAGService {
lastEnd = end;
}
Logger.info('Keyword windows found: ${windows.length} for query "$userQuery"');
Logger.info(
'Keyword windows found: ${windows.length} for query "$userQuery"',
);
return windows;
}
@@ -436,15 +470,15 @@ class MaterialsRAGService {
static List<String> _chunkText(String text, int chunkSize, int overlap) {
final List<String> chunks = [];
final int textLength = text.length;
if (textLength <= chunkSize) {
return [text];
}
int start = 0;
while (start < textLength) {
int end = start + chunkSize;
if (end >= textLength) {
end = textLength;
} else {
@@ -456,68 +490,70 @@ class MaterialsRAGService {
end = start + chunkSize; // Forçar quebra se não encontrar espaço
}
}
chunks.add(text.substring(start, end).trim());
// Avançar com overlap
start = end - overlap;
if (start >= end) break; // Prevenir loop infinito
}
return chunks;
}
/// Selecionar chunks mais relevantes usando keyword matching simples
static List<String> _selectRelevantChunks(
List<String> chunks,
String userQuery,
List<String> chunks,
String userQuery,
int maxChunks,
) {
if (userQuery.isEmpty || chunks.isEmpty) {
// Se não há query, retornar primeiros chunks
return chunks.take(maxChunks).toList();
}
// Extrair keywords da query (palavras com mais de 3 caracteres)
final queryWords = userQuery
.toLowerCase()
.split(RegExp(r'[^\w]'))
.where((w) => w.length > 3)
.toSet();
if (queryWords.isEmpty) {
return chunks.take(maxChunks).toList();
}
// Calcular score para cada chunk
final List<MapEntry<String, int>> scoredChunks = [];
for (final chunk in chunks) {
final chunkLower = chunk.toLowerCase();
int score = 0;
for (final word in queryWords) {
// Contar ocorrências da palavra no chunk
final matches = word.allMatches(chunkLower).length;
score += matches * 10; // Peso por ocorrência
// Bonus se a palavra estiver no início do chunk
if (chunkLower.startsWith(word)) {
score += 5;
}
}
// Bonus por tamanho do chunk (preferir chunks mais completos)
score += (chunk.length / 100).floor();
scoredChunks.add(MapEntry(chunk, score));
}
// Ordenar por score decrescente
scoredChunks.sort((a, b) => b.value.compareTo(a.value));
Logger.info('Top chunk scores: ${scoredChunks.take(3).map((e) => e.value).toList()}');
Logger.info(
'Top chunk scores: ${scoredChunks.take(3).map((e) => e.value).toList()}',
);
// Retornar os N chunks mais relevantes
return scoredChunks.take(maxChunks).map((e) => e.key).toList();
}