Melhorias no comportamento do chat com IA;

Adição do histórico de conversas com IA.
This commit is contained in:
2026-05-21 23:20:18 +01:00
parent 5bda59f7af
commit 7ee262f4c7
5 changed files with 825 additions and 162 deletions

View File

@@ -142,14 +142,7 @@ class AppRouter {
],
),
// AI Tutor Route (independent)
GoRoute(
path: tutor,
name: 'aiTutor',
builder: (context, state) => const TutorChatPageSimple(),
),
// AI Tutor Route with conversation ID (resume conversation)
// AI Tutor Route with conversation ID (resume conversation) - MUST come before regular /ai-tutor route
GoRoute(
path: '$tutor/:conversationId',
name: 'aiTutorConversation',
@@ -159,6 +152,13 @@ class AppRouter {
},
),
// AI Tutor Route (independent - new conversation)
GoRoute(
path: tutor,
name: 'aiTutor',
builder: (context, state) => const TutorChatPageSimple(),
),
// Chat History Route
GoRoute(
path: chatHistory,

View File

@@ -360,4 +360,114 @@ class ChatMemoryService {
Logger.error('Error clearing history: $e');
}
}
/// Generate a smart title from the first message by extracting keywords
static String generateConversationTitle(String firstMessage) {
// Remove common Portuguese stopwords and greetings
final stopwords = {
'olá',
'oi',
'bom dia',
'boa tarde',
'boa noite',
'consegues',
'podes',
'pode',
'poderia',
'poderias',
'explicar',
'explica',
'explicar-me',
'explicar-lhe',
'sobre',
'acerca de',
'a respeito de',
'relativamente a',
'o que é',
'o que são',
'qual é',
'quais são',
'como',
'onde',
'quando',
'porquê',
'por que',
'quero',
'quero que',
'gostaria',
'gostaria de',
'preciso',
'preciso de',
'ajuda',
'ajuda-me',
'me',
'te',
'lhe',
'nos',
'vos',
'um',
'uma',
'uns',
'umas',
'e',
'ou',
'mas',
'porém',
'todavia',
'para',
'de',
'em',
'a',
'o',
'as',
'os',
'que',
'quem',
'qual',
'quais',
'é',
'são',
'está',
'estão',
'foi',
'foram',
'?',
'!',
'.',
',',
';',
':',
};
// Convert to lowercase and remove punctuation
final cleaned = firstMessage
.toLowerCase()
.replaceAll(RegExp(r'[?!.;,]'), '')
.trim();
// Split into words and remove stopwords
final words = cleaned
.split(' ')
.where((word) => word.isNotEmpty && !stopwords.contains(word))
.toList();
// Take the first 2-3 meaningful words as the title
if (words.isEmpty) {
return firstMessage.length > 30
? '${firstMessage.substring(0, 30)}...'
: firstMessage;
}
final titleWords = words.take(3).join(' ');
// Capitalize first letter of each word
final title = titleWords
.split(' ')
.map((word) {
if (word.isEmpty) return '';
return word[0].toUpperCase() + word.substring(1);
})
.join(' ');
return title.length > 40 ? '${title.substring(0, 40)}...' : title;
}
}

View File

@@ -860,11 +860,153 @@ ans é o índice (0-3) da opção correcta.''';
return followUpStarters.any((s) => q.startsWith(s) || q == s.trim());
}
/// Build dynamic system prompt based on selected materials and discipline
static String _buildSystemPrompt({
List<String>? selectedMaterialNames,
String? disciplineName,
bool isMathematics = false,
}) {
final buffer = StringBuffer();
buffer.writeln(
r'''Tu és "Vico", o Assistente IA oficial do Learn It — uma plataforma educativa portuguesa.''',
);
buffer.writeln();
buffer.writeln('Nunca referes o nome do modelo de linguagem.');
buffer.writeln('Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo.');
buffer.writeln('Respondes sempre como o Vico.');
buffer.writeln();
buffer.writeln('Tens personalidade simpática, confiante e motivadora.');
buffer.writeln(
'Podes responder normalmente a saudações, agradecimentos e conversa casual — sê natural e amigável.',
);
buffer.writeln();
// Material context section
if (disciplineName != null && disciplineName.isNotEmpty) {
buffer.writeln('DISCIPLINA ATUAL: $disciplineName');
}
if (selectedMaterialNames != null && selectedMaterialNames.isNotEmpty) {
buffer.writeln(
'MATERIAIS SELECIONADOS: ${selectedMaterialNames.join(", ")}',
);
}
if (disciplineName != null ||
(selectedMaterialNames != null && selectedMaterialNames.isNotEmpty)) {
buffer.writeln();
}
// LaTeX prohibition (always)
buffer.writeln(
'IMPORTANTE: NUNCA uses LaTeX ou símbolos como \$ ou \$\$ para fórmulas matemáticas.',
);
buffer.writeln(
'Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).',
);
buffer.writeln();
// Discipline-specific rules
if (isMathematics) {
buffer.writeln('REGRAS CRÍTICAS PARA MATEMÁTICA:');
buffer.writeln(
'- O material fornecido serve como REFERÊNCIA de matéria, fórmulas e métodos.',
);
buffer.writeln(
'- Podes CRIAR exercícios NOVOS que sigam a mesma lógica, fórmulas e métodos do material.',
);
buffer.writeln(
'- NÃO copies exercícios diretamente do material — cria variações com valores diferentes.',
);
buffer.writeln(
'- Se o aluno pedir exercícios, cria exercícios novos baseados nos CONCEITOS e FÓRMULAS do material.',
);
buffer.writeln(
'- Se a resposta não estiver no contexto, usa o teu conhecimento matemático geral para ajudar, mas prioriza os métodos do material.',
);
buffer.writeln(
'- SEMPRE fornece o passo a passo completo com o resultado final.',
);
} else {
buffer.writeln('REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS:');
buffer.writeln(
'- Quando te for fornecido contexto de materiais do professor (indicado com [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo.',
);
buffer.writeln(
'- NÃO inventes factos educativos, NÃO uses conhecimento externo sobre matérias escolares.',
);
buffer.writeln(
'- Se a resposta educativa não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível."',
);
}
buffer.writeln(
'- Para conversa casual e saudações não precisas de contexto — responde livremente com a tua personalidade.',
);
buffer.writeln();
// Material handling rules (always)
buffer.writeln('IMPORTANTE - COMO TRATAR MATERIAIS SELECIONADOS:');
buffer.writeln(
'- Quando o aluno selecionar materiais (PDFs, fichas, exames), ASSUME que o aluno quer ajuda com esses materiais.',
);
buffer.writeln(
'- NUNCA perguntes "que ficha pretendo resolver" ou "o que pretendo resolver".',
);
buffer.writeln(
'- NUNCA perguntes "em que posso ajudar" quando materiais estão selecionados.',
);
buffer.writeln(
'- ASSUME automaticamente que o aluno quer explicação, resolução ou ajuda com os materiais selecionados.',
);
if (selectedMaterialNames != null && selectedMaterialNames.isNotEmpty) {
buffer.writeln(
'- Se a pergunta do aluno for vaga (ex: "ajuda"), oferece ajuda específica sobre os materiais selecionados.',
);
}
buffer.writeln();
// Greeting rules (always)
buffer.writeln('IMPORTANTE - NÃO REPITAS SAUDAÇÕES:');
buffer.writeln(
'- NUNCA começes uma resposta com "Olá", "Olá de novo", "Oi", "Bom dia", etc., a menos que seja a PRIMEIRA mensagem da conversa.',
);
buffer.writeln(
'- Nas respostas subsequentes, vai DIRETO ao assunto sem saudar novamente.',
);
buffer.writeln(
'- Se já saudaste o utilizador uma vez na conversa, NUNCA o faças de novo.',
);
buffer.writeln();
// Complete response rules (always)
buffer.writeln('IMPORTANTE - RESPOSTAS COMPLETAS:');
buffer.writeln('- NUNCA termines respostas com dois pontos (:).');
buffer.writeln(
'- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".',
);
buffer.writeln(
'- SEMPRE completa as frases e fornece a resposta completa.',
);
buffer.writeln(
'- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.',
);
buffer.writeln(
'- Se precisares de definir algo, fornece a definição completa.',
);
return buffer.toString();
}
/// Simple ask method for chat UI - uses conversation memory, teacher PDFs, and Vico identity
/// [selectedMaterialIds] — se fornecido, limita o RAG apenas aos materiais escolhidos pelo aluno
/// [selectedMaterialNames] — nomes dos materiais selecionados (para contextualizar a IA)
/// [disciplineName] — nome da disciplina (ex: "Matemática A", "Português")
/// [isMathematics] — true se for matemática ou disciplina quantitativa
static Future<String> ask(
String userQuery, {
List<String>? selectedMaterialIds,
List<String>? selectedMaterialNames,
String? disciplineName,
bool isMathematics = false,
}) async {
Logger.info('USING RAG AI SERVICE');
@@ -872,50 +1014,21 @@ ans é o índice (0-3) da opção correcta.''';
List<Map<String, String>> messages = [];
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO)
messages.add({
'role': 'system',
'content':
r'''Tu és "Vico", o Assistente IA oficial do Learn It — uma plataforma educativa portuguesa.
final systemPrompt = _buildSystemPrompt(
selectedMaterialNames: selectedMaterialNames,
disciplineName: disciplineName,
isMathematics: isMathematics,
);
messages.add({'role': 'system', 'content': systemPrompt});
Nunca referes o nome do modelo de linguagem.
Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo.
Respondes sempre como o Vico.
Tens personalidade simpática, confiante e motivadora.
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:
- 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.
- 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.
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 10 para manter contexto)
final conversationId = ChatMemoryService.currentConversationId;
var lastHistoryMessageIsDuplicate = false;
if (conversationId != null) {
final conversationHistory =
await ChatMemoryService.getConversationMessages(
conversationId: conversationId,
limit: 4,
limit: 10,
);
for (final msg in conversationHistory) {
messages.add({
@@ -923,6 +1036,17 @@ IMPORTANTE - RESPOSTAS COMPLETAS:
'content': msg['content'] as String,
});
}
// Verificar se a última mensagem do histórico já é a pergunta atual
// (evita duplicação quando a UI guardou a mensagem antes de chamar ask())
if (conversationHistory.isNotEmpty) {
final lastMsg = conversationHistory.last;
if (lastMsg['role'] == 'user' && lastMsg['content'] == userQuery) {
lastHistoryMessageIsDuplicate = true;
Logger.info(
'Last history message matches current query — skipping duplicate user message',
);
}
}
}
// Log de confirmação de ordem do histórico
@@ -934,8 +1058,13 @@ IMPORTANTE - RESPOSTAS COMPLETAS:
// Small talk: skip PDF lookup entirely and go straight to model
if (_isSmallTalk(userQuery)) {
Logger.info('Small talk detected — skipping PDF lookup');
messages.add({'role': 'user', 'content': userQuery});
await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
if (!lastHistoryMessageIsDuplicate) {
messages.add({'role': 'user', 'content': userQuery});
}
// Only save to Firestore if not already saved by UI
if (!lastHistoryMessageIsDuplicate) {
await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
}
final response = await _callOllamaAPIWithMessages(messages);
await ChatMemoryService.saveMessage(role: 'assistant', content: response);
return response;
@@ -963,8 +1092,13 @@ IMPORTANTE - RESPOSTAS COMPLETAS:
}
if (pdfContext.isEmpty) {
// Sem contexto encontrado — responder com base na personalidade mas sem inventar conteúdo
messages.add({'role': 'user', 'content': userQuery});
await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
if (!lastHistoryMessageIsDuplicate) {
messages.add({'role': 'user', 'content': userQuery});
}
// Only save to Firestore if not already saved by UI
if (!lastHistoryMessageIsDuplicate) {
await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
}
final response = await _callOllamaAPIWithMessages(messages);
await ChatMemoryService.saveMessage(role: 'assistant', content: response);
return response;
@@ -979,14 +1113,18 @@ $pdfContext
Pergunta: $userQuery'''
: userQuery;
messages.add({'role': 'user', 'content': userContent});
if (!lastHistoryMessageIsDuplicate) {
messages.add({'role': 'user', 'content': userContent});
}
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);
// Only save to Firestore if not already saved by UI
if (!lastHistoryMessageIsDuplicate) {
await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
}
// Call API
final response = await _callOllamaAPIWithMessages(messages);