Quiz e tutor chat modificações

This commit is contained in:
2026-05-18 14:27:30 +01:00
parent ad825f47d7
commit 9b53eb06b6
3 changed files with 811 additions and 497 deletions

View File

@@ -29,11 +29,11 @@ class RAGAIService {
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO) // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO)
messages.add({ messages.add({
'role': 'system', 'role': 'system',
'content': '''Tu és "O GOAT", o Assistente IA oficial do Teach it. 'content': '''Tu és "Alt", o Assistente IA oficial do Teach 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.
Respondes sempre como o GOAT. Respondes sempre como a Alt.
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.
@@ -41,7 +41,9 @@ Usas formatação Markdown clara e organizada.''',
}); });
// PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore
final conversationHistory = await ChatMemoryService.getRecentMessages(limit: 20); final conversationHistory = await ChatMemoryService.getRecentMessages(
limit: 20,
);
for (final msg in conversationHistory) { for (final msg in conversationHistory) {
messages.add({ messages.add({
'role': msg['role'] as String, 'role': msg['role'] as String,
@@ -58,33 +60,27 @@ Usas formatação Markdown clara e organizada.''',
if (pdfContext.isNotEmpty) { if (pdfContext.isNotEmpty) {
messages.add({ messages.add({
'role': 'system', 'role': 'system',
'content': pdfContext, // Já vem formatado com [CHUNK 1], [CHUNK 2], etc. 'content':
pdfContext, // Já vem formatado com [CHUNK 1], [CHUNK 2], etc.
}); });
} }
// PASSO 5 — SÓ AGORA adicionar a pergunta do user // PASSO 5 — SÓ AGORA adicionar a pergunta do user
messages.add({ messages.add({'role': 'user', 'content': userQuery});
'role': 'user',
'content': userQuery,
});
// Log do tamanho do array para verificação // Log do tamanho do array para verificação
Logger.info('Built messages array with ${messages.length} messages for API'); Logger.info(
'Built messages array with ${messages.length} messages for API',
);
// Save user message to Firestore (after building the messages array) // Save user message to Firestore (after building the messages array)
await ChatMemoryService.saveMessage( await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
role: 'user',
content: userQuery,
);
// Call Ollama API with complete messages array // Call Ollama API with complete messages array
final response = await _callOllamaAPIWithMessages(messages); final response = await _callOllamaAPIWithMessages(messages);
// Save AI response to memory // Save AI response to memory
await ChatMemoryService.saveMessage( await ChatMemoryService.saveMessage(role: 'assistant', content: response);
role: 'assistant',
content: response,
);
// Process response and create RAGResponse // Process response and create RAGResponse
final ragResponse = _createRAGResponse( final ragResponse = _createRAGResponse(
@@ -181,11 +177,12 @@ Usas formatação Markdown clara e organizada.''',
} }
/// System message for O GOAT identity (for legacy calls) /// System message for O GOAT identity (for legacy calls)
static const String _systemMessage = '''Tu és "O GOAT", o Assistente IA oficial do Teach it. static const String _systemMessage =
'''Tu és "Alt", o Assistente IA oficial do Teach 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.
Respondes sempre como o GOAT. Respondes sempre como a Alt.
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.
@@ -485,13 +482,11 @@ Usas formatação clara e organizada.''';
final messages = <Map<String, String>>[ final messages = <Map<String, String>>[
{ {
'role': 'system', 'role': 'system',
'content': 'És um assistente educativo especializado em criar quizzes pedagógicos. ' 'content':
'És um assistente educativo especializado em criar quizzes pedagógicos. '
'Cria sempre perguntas claras, baseadas exclusivamente no contexto fornecido.', 'Cria sempre perguntas claras, baseadas exclusivamente no contexto fornecido.',
}, },
{ {'role': 'user', 'content': prompt},
'role': 'user',
'content': prompt,
},
]; ];
return await _callOllamaAPIWithMessages(messages); return await _callOllamaAPIWithMessages(messages);
} }
@@ -524,13 +519,39 @@ Usas formatação clara e organizada.''';
final words = q.split(RegExp(r'\s+')); final words = q.split(RegExp(r'\s+'));
if (words.length > 8) return false; if (words.length > 8) return false;
const followUpStarters = [ const followUpStarters = [
'e ', 'e o', 'e a', 'e os', 'e as', 'mas ', 'então ', 'e ',
'explica', 'explique', 'explica melhor', 'melhor', 'mais detalhes', 'e o',
'podes', 'pode ', 'consegues', 'e se ', 'e quando', 'e a',
'dá um exemplo', 'da um exemplo', 'um exemplo', 'exemplo', 'e os',
'como assim', 'o que significa', 'porquê', 'porque isso', 'e as',
'e o ponto', 'e a regra', 'continua', 'continua', 'mas ',
'o que mais', 'mais algum', 'e depois', 'e agora', 'então ',
'explica',
'explique',
'explica melhor',
'melhor',
'mais detalhes',
'podes',
'pode ',
'consegues',
'e se ',
'e quando',
'dá um exemplo',
'da um exemplo',
'um exemplo',
'exemplo',
'como assim',
'o que significa',
'porquê',
'porque isso',
'e o ponto',
'e a regra',
'continua',
'continua',
'o que mais',
'mais algum',
'e depois',
'e agora',
]; ];
return followUpStarters.any((s) => q.startsWith(s) || q == s.trim()); return followUpStarters.any((s) => q.startsWith(s) || q == s.trim());
} }
@@ -549,11 +570,11 @@ Usas formatação clara e organizada.''';
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO) // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO)
messages.add({ messages.add({
'role': 'system', 'role': 'system',
'content': '''Tu és "O GOAT", o Assistente IA oficial do Teach it. 'content': '''Tu és "Alt", o Assistente IA oficial do Teach 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.
Respondes sempre como o GOAT. Respondes sempre como Alt.
Tens personalidade confiante, motivadora e orgulhosa. Tens personalidade confiante, motivadora e orgulhosa.
Usas formatação Markdown clara e organizada. Usas formatação Markdown clara e organizada.
@@ -566,7 +587,9 @@ REGRAS CRÍTICAS SOBRE O CONTEXTO:
}); });
// 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)
final conversationHistory = await ChatMemoryService.getRecentMessages(limit: 4); final conversationHistory = await ChatMemoryService.getRecentMessages(
limit: 4,
);
for (final msg in conversationHistory) { for (final msg in conversationHistory) {
messages.add({ messages.add({
'role': msg['role'] as String, 'role': msg['role'] as String,
@@ -584,7 +607,9 @@ REGRAS CRÍTICAS SOBRE O CONTEXTO:
String pdfContext; String pdfContext;
if (_isFollowUp(userQuery) && _lastPdfContext.isNotEmpty) { if (_isFollowUp(userQuery) && _lastPdfContext.isNotEmpty) {
pdfContext = _lastPdfContext; pdfContext = _lastPdfContext;
Logger.info('Follow-up detected — reusing last PDF context (${pdfContext.length} chars)'); Logger.info(
'Follow-up detected — reusing last PDF context (${pdfContext.length} chars)',
);
} else { } else {
pdfContext = await MaterialsRAGService.getRelevantChunks( pdfContext = await MaterialsRAGService.getRelevantChunks(
userQuery: userQuery, userQuery: userQuery,
@@ -594,25 +619,36 @@ REGRAS CRÍTICAS SOBRE O CONTEXTO:
); );
if (pdfContext.isNotEmpty) { if (pdfContext.isNotEmpty) {
_lastPdfContext = pdfContext; _lastPdfContext = pdfContext;
Logger.info('PDF context sent to model (${pdfContext.length} chars): ${pdfContext.length > 300 ? pdfContext.substring(0, 300) : pdfContext}'); Logger.info(
'PDF context sent to model (${pdfContext.length} chars): ${pdfContext.length > 300 ? pdfContext.substring(0, 300) : pdfContext}',
);
} }
} }
if (pdfContext.isEmpty && selectedMaterialIds != null && selectedMaterialIds.isNotEmpty) { if (pdfContext.isEmpty &&
selectedMaterialIds != null &&
selectedMaterialIds.isNotEmpty) {
// Contexto vazio com materiais seleccionados — retornar resposta local imediatamente // Contexto vazio com materiais seleccionados — retornar resposta local imediatamente
const noContextReply = const noContextReply =
'Neste momento não tenho acesso ao conteúdo do ficheiro selecionado. ' 'Neste momento não tenho acesso ao conteúdo do ficheiro selecionado. '
'Tenta novamente ou faz uma pergunta geral — estou aqui para ajudar! 💪'; 'Tenta novamente ou faz uma pergunta geral — estou aqui para ajudar! 💪';
await ChatMemoryService.saveMessage(role: 'user', content: userQuery); await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
await ChatMemoryService.saveMessage(role: 'assistant', content: noContextReply); await ChatMemoryService.saveMessage(
role: 'assistant',
content: noContextReply,
);
return noContextReply; return noContextReply;
} }
if (pdfContext.isEmpty && (selectedMaterialIds == null || selectedMaterialIds.isEmpty)) { if (pdfContext.isEmpty &&
(selectedMaterialIds == null || selectedMaterialIds.isEmpty)) {
// Sem material seleccionado — pedir ao utilizador para seleccionar um // Sem material seleccionado — pedir ao utilizador para seleccionar um
const noMaterialReply = const noMaterialReply =
'Para responder a perguntas sobre conteúdo, preciso que selecciones um material primeiro. ' '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!'; '📚 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: 'user', content: userQuery);
await ChatMemoryService.saveMessage(role: 'assistant', content: noMaterialReply); await ChatMemoryService.saveMessage(
role: 'assistant',
content: noMaterialReply,
);
return noMaterialReply; return noMaterialReply;
} }
@@ -625,12 +661,11 @@ $pdfContext
Pergunta: $userQuery''' Pergunta: $userQuery'''
: userQuery; : userQuery;
messages.add({ messages.add({'role': 'user', 'content': userContent});
'role': 'user',
'content': userContent,
});
Logger.info('USING RAG AI SERVICE - Built messages array with ${messages.length} messages'); Logger.info(
'USING RAG AI SERVICE - Built messages array with ${messages.length} messages',
);
// Save user message to Firestore // Save user message to Firestore
await ChatMemoryService.saveMessage(role: 'user', content: userQuery); await ChatMemoryService.saveMessage(role: 'user', content: userQuery);

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../../../core/services/auth_service.dart'; import '../../../../core/services/auth_service.dart';
import '../../../../core/services/chat_memory_service.dart'; import '../../../../core/services/chat_memory_service.dart';
import '../../../../core/services/materials_rag_service.dart'; import '../../../../core/services/materials_rag_service.dart';
@@ -26,6 +27,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
List<Map<String, dynamic>> _messages = []; List<Map<String, dynamic>> _messages = [];
List<Map<String, String>> _availableMaterials = []; List<Map<String, String>> _availableMaterials = [];
Map<String, String> _classNames = {}; // classId → name
Set<String> _selectedMaterialIds = {}; Set<String> _selectedMaterialIds = {};
@override @override
@@ -38,8 +40,28 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
Future<void> _loadAvailableMaterials() async { Future<void> _loadAvailableMaterials() async {
final materials = final materials =
await MaterialsRAGService.getAvailableMaterialsForStudent(); await MaterialsRAGService.getAvailableMaterialsForStudent();
// Collect unique classIds that don't have a name yet
final classIds = materials
.map((m) => m['classId'])
.whereType<String>()
.toSet();
final namesMap = <String, String>{};
if (classIds.isNotEmpty) {
final docs = await Future.wait(
classIds.map(
(id) =>
FirebaseFirestore.instance.collection('classes').doc(id).get(),
),
);
for (final doc in docs.where((d) => d.exists)) {
namesMap[doc.id] = doc.data()?['name'] as String? ?? doc.id;
}
}
if (mounted) { if (mounted) {
setState(() => _availableMaterials = materials); setState(() {
_availableMaterials = materials;
_classNames = namesMap;
});
} }
} }
@@ -567,13 +589,66 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
); );
} }
// Group materials by classId; ungrouped go to '__geral__'
Map<String, List<Map<String, String>>> _groupMaterialsByClass() {
final groups = <String, List<Map<String, String>>>{};
for (final m in _availableMaterials) {
final cid = m['classId'];
final key = (cid != null && _classNames.containsKey(cid))
? cid
: '__geral__';
groups.putIfAbsent(key, () => []).add(m);
}
return groups;
}
void _showMaterialsPicker() { void _showMaterialsPicker() {
final groups = _groupMaterialsByClass();
final disciplineIds = groups.keys.where((k) => k != '__geral__').toList()
..sort((a, b) => (_classNames[a] ?? a).compareTo(_classNames[b] ?? b));
if (groups.containsKey('__geral__')) disciplineIds.add('__geral__');
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
final tempSelected = Set<String>.from(_selectedMaterialIds); final tempSelected = Set<String>.from(_selectedMaterialIds);
// Disciplines start collapsed
final expanded = <String, bool>{
for (final k in disciplineIds) k: false,
};
final searchController = TextEditingController();
String searchQuery = '';
return StatefulBuilder( return StatefulBuilder(
builder: (context, setDialogState) => AlertDialog( builder: (context, setDialogState) {
final cs = Theme.of(context).colorScheme;
// Filter groups by search query
final filteredDisciplineIds = disciplineIds.where((groupKey) {
if (searchQuery.isEmpty) return true;
final q = searchQuery.toLowerCase();
final label = groupKey == '__geral__'
? 'geral'
: (_classNames[groupKey] ?? groupKey).toLowerCase();
if (label.contains(q)) return true;
return (groups[groupKey] ?? []).any(
(m) => (m['name'] ?? '').toLowerCase().contains(q),
);
}).toList();
// Auto-expand disciplines that match by material name (not discipline name)
for (final groupKey in filteredDisciplineIds) {
if (searchQuery.isNotEmpty) {
final q = searchQuery.toLowerCase();
final label = groupKey == '__geral__'
? 'geral'
: (_classNames[groupKey] ?? groupKey).toLowerCase();
if (!label.contains(q)) expanded[groupKey] = true;
}
}
final viewInsets = MediaQuery.of(context).viewInsets;
final screenHeight = MediaQuery.of(context).size.height;
// Leave room for keyboard + dialog chrome (~360px for title/search/actions/padding)
final listMaxHeight = (screenHeight - viewInsets.bottom - 360)
.clamp(60.0, 340.0);
return AlertDialog(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
@@ -587,22 +662,189 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( // Search bar
'Seleciona os materiais que o tutor deve analisar:', TextField(
style: TextStyle( controller: searchController,
onChanged: (v) =>
setDialogState(() => searchQuery = v.trim()),
style: TextStyle(fontSize: 13, color: cs.onSurface),
decoration: InputDecoration(
hintText: 'Pesquisar disciplina ou material…',
hintStyle: TextStyle(
fontSize: 13, fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: cs.onSurfaceVariant,
),
prefixIcon: Icon(
Icons.search,
size: 18,
color: cs.onSurfaceVariant,
),
suffixIcon: searchQuery.isNotEmpty
? IconButton(
icon: Icon(
Icons.close,
size: 16,
color: cs.onSurfaceVariant,
),
onPressed: () {
searchController.clear();
setDialogState(() => searchQuery = '');
},
)
: null,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: cs.outline.withValues(alpha: 0.4),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: cs.outline.withValues(alpha: 0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: cs.primary),
),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300), constraints: BoxConstraints(maxHeight: listMaxHeight),
child: ListView( child: filteredDisciplineIds.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'Nenhum resultado para "$searchQuery"',
style: TextStyle(
fontSize: 13,
color: cs.onSurfaceVariant,
),
),
),
)
: ListView(
shrinkWrap: true, shrinkWrap: true,
children: _availableMaterials.map((material) { children: filteredDisciplineIds.map((groupKey) {
// When searching, filter materials too
final allMats = groups[groupKey]!;
final mats = searchQuery.isEmpty
? allMats
: allMats.where((m) {
final q = searchQuery.toLowerCase();
final label = groupKey == '__geral__'
? 'geral'
: (_classNames[groupKey] ?? '')
.toLowerCase();
return label.contains(q) ||
(m['name'] ?? '')
.toLowerCase()
.contains(q);
}).toList();
final label = groupKey == '__geral__'
? 'Geral'
: (_classNames[groupKey] ?? groupKey);
final isExpanded = expanded[groupKey] ?? false;
// Count how many in this group are selected
final selectedInGroup = mats
.where(
(m) => tempSelected.contains(m['id']),
)
.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Discipline header row
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => setDialogState(
() => expanded[groupKey] = !isExpanded,
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
decoration: BoxDecoration(
color: cs.primary.withValues(
alpha: 0.07,
),
borderRadius: BorderRadius.circular(
8,
),
),
child: Row(
children: [
Icon(
Icons.folder_outlined,
size: 16,
color: cs.primary,
),
const SizedBox(width: 6),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: cs.primary,
),
),
),
if (selectedInGroup > 0)
Container(
padding:
const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: cs.primary.withValues(
alpha: 0.15,
),
borderRadius:
BorderRadius.circular(10),
),
child: Text(
'$selectedInGroup',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: cs.primary,
),
),
),
const SizedBox(width: 4),
Icon(
isExpanded
? Icons.expand_less
: Icons.expand_more,
size: 18,
color: cs.onSurfaceVariant,
),
],
),
),
),
// Material items
if (isExpanded)
...mats.map((material) {
final id = material['id']!; final id = material['id']!;
final name = material['name']!; final name = material['name']!;
final isChecked = tempSelected.contains(id); final cleanName = name
.replaceAll('.pdf', '')
.replaceAll('_', ' ');
final isChecked = tempSelected.contains(
id,
);
return CheckboxListTile( return CheckboxListTile(
value: isChecked, value: isChecked,
onChanged: (val) { onChanged: (val) {
@@ -615,14 +857,23 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
}); });
}, },
title: Text( title: Text(
name, cleanName,
style: const TextStyle(fontSize: 14), style: const TextStyle(
fontSize: 13,
),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
controlAffinity: ListTileControlAffinity.leading, controlAffinity:
ListTileControlAffinity.leading,
dense: true, dense: true,
contentPadding: EdgeInsets.zero, contentPadding: const EdgeInsets.only(
left: 16,
),
);
}),
const SizedBox(height: 6),
],
); );
}).toList(), }).toList(),
), ),
@@ -662,7 +913,8 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
child: const Text('Confirmar'), child: const Text('Confirmar'),
), ),
], ],
), );
},
); );
}, },
); );

File diff suppressed because it is too large Load Diff