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)
messages.add({
'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 dizes que és Qwen ou OpenAI.
Respondes sempre como o GOAT.
Respondes sempre como a Alt.
Tens personalidade confiante, motivadora e orgulhosa.
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
final conversationHistory = await ChatMemoryService.getRecentMessages(limit: 20);
final conversationHistory = await ChatMemoryService.getRecentMessages(
limit: 20,
);
for (final msg in conversationHistory) {
messages.add({
'role': msg['role'] as String,
@@ -58,33 +60,27 @@ Usas formatação Markdown clara e organizada.''',
if (pdfContext.isNotEmpty) {
messages.add({
'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
messages.add({
'role': 'user',
'content': userQuery,
});
messages.add({'role': 'user', 'content': userQuery});
// 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)
await ChatMemoryService.saveMessage(
role: 'user',
content: userQuery,
);
await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
// Call Ollama API with complete messages array
final response = await _callOllamaAPIWithMessages(messages);
// Save AI response to memory
await ChatMemoryService.saveMessage(
role: 'assistant',
content: response,
);
await ChatMemoryService.saveMessage(role: 'assistant', content: response);
// Process response and create RAGResponse
final ragResponse = _createRAGResponse(
@@ -181,11 +177,12 @@ Usas formatação Markdown clara e organizada.''',
}
/// 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 dizes que és Qwen ou OpenAI.
Respondes sempre como o GOAT.
Respondes sempre como a Alt.
Tens personalidade confiante, motivadora e orgulhosa.
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>>[
{
'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.',
},
{
'role': 'user',
'content': prompt,
},
{'role': 'user', 'content': prompt},
];
return await _callOllamaAPIWithMessages(messages);
}
@@ -524,13 +519,39 @@ Usas formatação clara e organizada.''';
final words = q.split(RegExp(r'\s+'));
if (words.length > 8) return false;
const followUpStarters = [
'e ', 'e o', 'e a', 'e os', 'e as', 'mas ', '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',
'e ',
'e o',
'e a',
'e os',
'e as',
'mas ',
'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());
}
@@ -549,11 +570,11 @@ Usas formatação clara e organizada.''';
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO)
messages.add({
'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 dizes que és Qwen ou OpenAI.
Respondes sempre como o GOAT.
Respondes sempre como Alt.
Tens personalidade confiante, motivadora e orgulhosa.
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)
final conversationHistory = await ChatMemoryService.getRecentMessages(limit: 4);
final conversationHistory = await ChatMemoryService.getRecentMessages(
limit: 4,
);
for (final msg in conversationHistory) {
messages.add({
'role': msg['role'] as String,
@@ -584,7 +607,9 @@ REGRAS CRÍTICAS SOBRE O CONTEXTO:
String pdfContext;
if (_isFollowUp(userQuery) && _lastPdfContext.isNotEmpty) {
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 {
pdfContext = await MaterialsRAGService.getRelevantChunks(
userQuery: userQuery,
@@ -594,25 +619,36 @@ REGRAS CRÍTICAS SOBRE O CONTEXTO:
);
if (pdfContext.isNotEmpty) {
_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
const noContextReply =
'Neste momento não tenho acesso ao conteúdo do ficheiro selecionado. '
'Tenta novamente ou faz uma pergunta geral — estou aqui para ajudar! 💪';
await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
await ChatMemoryService.saveMessage(role: 'assistant', content: noContextReply);
await ChatMemoryService.saveMessage(
role: 'assistant',
content: 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
const noMaterialReply =
'Para responder a perguntas sobre conteúdo, preciso que selecciones um material primeiro. '
'📚 Usa o botão de materiais para escolher um PDF e depois faz a tua pergunta!';
await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
await ChatMemoryService.saveMessage(role: 'assistant', content: noMaterialReply);
await ChatMemoryService.saveMessage(
role: 'assistant',
content: noMaterialReply,
);
return noMaterialReply;
}
@@ -625,12 +661,11 @@ $pdfContext
Pergunta: $userQuery'''
: userQuery;
messages.add({
'role': 'user',
'content': userContent,
});
messages.add({'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
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_markdown/flutter_markdown.dart';
import 'package:go_router/go_router.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/services/chat_memory_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, String>> _availableMaterials = [];
Map<String, String> _classNames = {}; // classId → name
Set<String> _selectedMaterialIds = {};
@override
@@ -38,8 +40,28 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
Future<void> _loadAvailableMaterials() async {
final materials =
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) {
setState(() => _availableMaterials = materials);
setState(() {
_availableMaterials = materials;
_classNames = namesMap;
});
}
}
@@ -567,102 +589,332 @@ 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() {
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(
context: context,
builder: (dialogContext) {
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(
builder: (context, setDialogState) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text(
'Escolher Materiais',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Seleciona os materiais que o tutor deve analisar:',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: ListView(
shrinkWrap: true,
children: _availableMaterials.map((material) {
final id = material['id']!;
final name = material['name']!;
final isChecked = tempSelected.contains(id);
return CheckboxListTile(
value: isChecked,
onChanged: (val) {
setDialogState(() {
if (val == true) {
tempSelected.add(id);
} else {
tempSelected.remove(id);
}
});
},
title: Text(
name,
style: const TextStyle(fontSize: 14),
maxLines: 2,
overflow: TextOverflow.ellipsis,
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(
borderRadius: BorderRadius.circular(16),
),
title: const Text(
'Escolher Materiais',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Search bar
TextField(
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,
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),
),
controlAffinity: ListTileControlAffinity.leading,
dense: true,
contentPadding: EdgeInsets.zero,
);
}).toList(),
),
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),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: listMaxHeight),
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,
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 name = material['name']!;
final cleanName = name
.replaceAll('.pdf', '')
.replaceAll('_', ' ');
final isChecked = tempSelected.contains(
id,
);
return CheckboxListTile(
value: isChecked,
onChanged: (val) {
setDialogState(() {
if (val == true) {
tempSelected.add(id);
} else {
tempSelected.remove(id);
}
});
},
title: Text(
cleanName,
style: const TextStyle(
fontSize: 13,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
controlAffinity:
ListTileControlAffinity.leading,
dense: true,
contentPadding: const EdgeInsets.only(
left: 16,
),
);
}),
const SizedBox(height: 6),
],
);
}).toList(),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
setDialogState(() => tempSelected.clear());
setState(() {
_selectedMaterialIds.clear();
_messages.clear();
});
ChatMemoryService.clearHistory();
RAGAIService.clearLastContext();
Navigator.of(dialogContext).pop();
},
child: const Text('Limpar'),
),
ElevatedButton(
onPressed: () {
setState(() {
_selectedMaterialIds = tempSelected;
_messages.clear();
});
ChatMemoryService.clearHistory();
RAGAIService.clearLastContext();
Navigator.of(dialogContext).pop();
},
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
setDialogState(() => tempSelected.clear());
setState(() {
_selectedMaterialIds.clear();
_messages.clear();
});
ChatMemoryService.clearHistory();
RAGAIService.clearLastContext();
Navigator.of(dialogContext).pop();
},
child: const Text('Limpar'),
),
ElevatedButton(
onPressed: () {
setState(() {
_selectedMaterialIds = tempSelected;
_messages.clear();
});
ChatMemoryService.clearHistory();
RAGAIService.clearLastContext();
Navigator.of(dialogContext).pop();
},
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: const Text('Confirmar'),
),
child: const Text('Confirmar'),
),
],
),
],
);
},
);
},
);

File diff suppressed because it is too large Load Diff