1138 lines
40 KiB
Dart
1138 lines
40 KiB
Dart
import 'dart:convert';
|
|
import 'package:http/http.dart' as http;
|
|
import '../utils/logger.dart';
|
|
import 'rag_service.dart';
|
|
import 'chat_memory_service.dart';
|
|
import 'materials_rag_service.dart';
|
|
import '../models/content_chunk.dart';
|
|
|
|
/// Service for RAG-enhanced AI communication using Ollama API
|
|
class RAGAIService {
|
|
static const String _baseUrl = 'http://89.114.196.110:11434/api/chat';
|
|
static const String _model = 'qwen3-coder:30b';
|
|
static const int _timeoutSeconds = 60;
|
|
static const int _maxTokens = 4000;
|
|
|
|
/// Generate AI response with RAG context, conversation memory, and teacher materials
|
|
static Future<RAGResponse> generateRAGResponse({
|
|
required String userQuery,
|
|
required String context,
|
|
required TutorMode mode,
|
|
required List<ContentChunk> sources,
|
|
}) async {
|
|
try {
|
|
Logger.info('Generating RAG response with ${sources.length} sources');
|
|
|
|
// PASSO 1 — Criar a lista messages vazia
|
|
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.
|
|
|
|
Nunca referes o nome do modelo.
|
|
Nunca dizes que és Qwen ou OpenAI.
|
|
Respondes sempre como o Vico.
|
|
|
|
Tens personalidade confiante, motivadora e orgulhosa.
|
|
Ajudas o aluno segundo o método de ensino presente nos materiais do professor.
|
|
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
|
|
final conversationId = ChatMemoryService.currentConversationId;
|
|
if (conversationId != null) {
|
|
final conversationHistory =
|
|
await ChatMemoryService.getConversationMessages(
|
|
conversationId: conversationId,
|
|
limit: 20,
|
|
);
|
|
for (final msg in conversationHistory) {
|
|
messages.add({
|
|
'role': msg['role'] as String,
|
|
'content': msg['content'] as String,
|
|
});
|
|
}
|
|
}
|
|
|
|
// PASSO 4 — BUSCAR PDFs DO PROFESSOR NO Firebase Storage (RAG CHUNK RETRIEVAL)
|
|
final pdfContext = await MaterialsRAGService.getRelevantChunks(
|
|
userQuery: userQuery,
|
|
maxMaterials: 10,
|
|
maxChunks: 20,
|
|
);
|
|
if (pdfContext.isNotEmpty) {
|
|
messages.add({
|
|
'role': 'system',
|
|
'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});
|
|
|
|
// Log do tamanho do array para verificação
|
|
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);
|
|
|
|
// Call Ollama API with complete messages array
|
|
final response = await _callOllamaAPIWithMessages(messages);
|
|
|
|
// Save AI response to memory
|
|
await ChatMemoryService.saveMessage(role: 'assistant', content: response);
|
|
|
|
// Process response and create RAGResponse
|
|
final ragResponse = _createRAGResponse(
|
|
query: userQuery,
|
|
aiResponse: response,
|
|
mode: mode,
|
|
sources: sources,
|
|
);
|
|
|
|
Logger.info('RAG response generated successfully');
|
|
return ragResponse;
|
|
} catch (e) {
|
|
Logger.error('Error generating RAG response: $e');
|
|
return _createErrorResponse(userQuery, mode, e.toString());
|
|
}
|
|
}
|
|
|
|
/// Build RAG-enhanced prompt for Ollama with teacher materials
|
|
static String _buildRAGPrompt(
|
|
String userQuery,
|
|
String context,
|
|
String materialsContext,
|
|
TutorMode mode,
|
|
) {
|
|
final promptBuilder = StringBuffer();
|
|
|
|
// System prompt with role and instructions
|
|
promptBuilder.writeln(
|
|
'Você é um assistente educacional especializado da Escola Profissional de Vila do Conde.',
|
|
);
|
|
promptBuilder.writeln(
|
|
'Sua função é ajudar os alunos usando APENAS o conteúdo fornecido abaixo.',
|
|
);
|
|
promptBuilder.writeln(
|
|
'NÃO use conhecimento externo. Baseie todas as respostas exclusivamente no material educacional.',
|
|
);
|
|
promptBuilder.writeln('Seja claro, paciente e educativo.\n');
|
|
|
|
// Add teacher materials (PDFs) if available
|
|
if (materialsContext.isNotEmpty) {
|
|
promptBuilder.writeln('=== MATERIAL DO PROFESSOR ===');
|
|
promptBuilder.writeln(materialsContext);
|
|
promptBuilder.writeln('\n=== FIM DO MATERIAL DO PROFESSOR ===\n');
|
|
}
|
|
|
|
// Add context
|
|
promptBuilder.writeln('=== CONTEÚDO EDUCACIONAL DISPONÍVEL ===');
|
|
promptBuilder.writeln(context);
|
|
promptBuilder.writeln('\n=== FIM DO CONTEÚDO ===\n');
|
|
|
|
// Mode-specific instructions
|
|
promptBuilder.writeln('=== MODO DE TUTORIA ===');
|
|
switch (mode) {
|
|
case TutorMode.explanation:
|
|
promptBuilder.writeln('MODO: EXPLICAÇÃO DETALHADA');
|
|
promptBuilder.writeln(
|
|
'Forneça explicações claras e detalhadas baseadas exclusivamente no conteúdo.',
|
|
);
|
|
promptBuilder.writeln(
|
|
'Use exemplos do material e estruture a resposta de forma lógica.',
|
|
);
|
|
promptBuilder.writeln(
|
|
'Se o conteúdo não tiver informação suficiente, indique isso claramente.',
|
|
);
|
|
break;
|
|
case TutorMode.tutor:
|
|
promptBuilder.writeln('MODO: TUTORIA SOCRÁTICA');
|
|
promptBuilder.writeln(
|
|
'Use o método socrático - faça perguntas que guiem o aluno.',
|
|
);
|
|
promptBuilder.writeln('Baseie-se apenas no conteúdo fornecido.');
|
|
promptBuilder.writeln('Incentive o pensamento crítico e a descoberta.');
|
|
break;
|
|
case TutorMode.exploration:
|
|
promptBuilder.writeln('MODO: EXPLORAÇÃO E DESCOBERTA');
|
|
promptBuilder.writeln(
|
|
'Ajude o aluno a explorar o conceito através de descoberta.',
|
|
);
|
|
promptBuilder.writeln(
|
|
'Conecte ideias relacionadas presentes no conteúdo.',
|
|
);
|
|
promptBuilder.writeln(
|
|
'Sugira investigações baseadas no material disponível.',
|
|
);
|
|
break;
|
|
}
|
|
|
|
// User query
|
|
promptBuilder.writeln('\n=== PERGUNTA DO ALUNO ===');
|
|
promptBuilder.writeln(userQuery);
|
|
promptBuilder.writeln('\n=== RESPOSTA ===');
|
|
|
|
return promptBuilder.toString();
|
|
}
|
|
|
|
/// System message for Vico identity (for legacy calls)
|
|
static const String _systemMessage =
|
|
r'''Tu és "Vico", o Assistente IA oficial do Learn It.
|
|
|
|
Nunca referes o nome do modelo.
|
|
Nunca dizes que és Qwen ou OpenAI.
|
|
Respondes sempre como o Vico.
|
|
|
|
Tens personalidade confiante, motivadora e orgulhosa.
|
|
Ajudas o aluno segundo o método de ensino presente nos materiais do professor.
|
|
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
|
|
static Future<String> _callOllamaAPIWithMessages(
|
|
List<Map<String, String>> messages,
|
|
) async {
|
|
try {
|
|
Logger.info('Calling Ollama API with ${messages.length} messages');
|
|
|
|
final url = Uri.parse(_baseUrl);
|
|
|
|
final requestBody = {
|
|
'model': _model,
|
|
'messages': messages,
|
|
'stream': false,
|
|
'options': {'temperature': 0.7, 'top_p': 0.9, 'max_tokens': _maxTokens},
|
|
};
|
|
|
|
final response = await http
|
|
.post(
|
|
url,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode(requestBody),
|
|
)
|
|
.timeout(Duration(seconds: _timeoutSeconds));
|
|
|
|
if (response.statusCode == 200) {
|
|
final responseData = jsonDecode(response.body);
|
|
final message = responseData['message'];
|
|
var content = message?['content'] ?? '';
|
|
|
|
// Post-process to remove LaTeX symbols
|
|
content = _removeLaTeXSymbols(content);
|
|
|
|
Logger.info('Ollama API response received');
|
|
return content.trim();
|
|
} else {
|
|
throw Exception('API Error: ${response.statusCode} - ${response.body}');
|
|
}
|
|
} catch (e) {
|
|
Logger.error('Error calling Ollama API: $e');
|
|
throw Exception('Failed to call AI service: $e');
|
|
}
|
|
}
|
|
|
|
/// Legacy: Call Ollama API with single prompt (for backward compatibility)
|
|
static Future<String> _callOllamaAPI(String prompt) async {
|
|
return _callOllamaAPIWithMessages([
|
|
{'role': 'system', 'content': _systemMessage},
|
|
{'role': 'user', 'content': prompt},
|
|
]);
|
|
}
|
|
|
|
/// Create RAGResponse from AI response
|
|
static RAGResponse _createRAGResponse({
|
|
required String query,
|
|
required String aiResponse,
|
|
required TutorMode mode,
|
|
required List<ContentChunk> sources,
|
|
}) {
|
|
try {
|
|
// Create source citations
|
|
final citations = sources
|
|
.map(
|
|
(chunk) => SourceCitation(
|
|
contentId: chunk.contentId,
|
|
chunkId: chunk.id,
|
|
title: chunk.sourceDocument,
|
|
concept: chunk.concept,
|
|
subject: chunk.subject,
|
|
excerpt: _getExcerpt(chunk.text),
|
|
relevance: _calculateRelevance(query, chunk.text),
|
|
pageNumber: chunk.pageNumber,
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
// Calculate confidence based on sources and response quality
|
|
final confidence = _calculateResponseConfidence(aiResponse, sources);
|
|
|
|
// Extract related concepts from sources
|
|
final relatedConcepts = _extractRelatedConcepts(sources);
|
|
|
|
return RAGResponse(
|
|
answer: aiResponse,
|
|
sources: citations,
|
|
confidence: confidence,
|
|
mode: mode,
|
|
relatedConcepts: relatedConcepts,
|
|
metadata: {
|
|
'model': _model,
|
|
'queryLength': query.length,
|
|
'responseLength': aiResponse.length,
|
|
'sourceCount': sources.length,
|
|
'processingTime': DateTime.now().millisecondsSinceEpoch,
|
|
'temperature': 0.7,
|
|
},
|
|
);
|
|
} catch (e) {
|
|
Logger.error('Error creating RAG response: $e');
|
|
throw Exception('Failed to create RAG response: $e');
|
|
}
|
|
}
|
|
|
|
/// Get excerpt from text
|
|
static String _getExcerpt(String text, {int maxLength = 200}) {
|
|
if (text.length <= maxLength) return text;
|
|
return text.substring(0, maxLength - 3) + '...';
|
|
}
|
|
|
|
/// Calculate relevance score
|
|
static double _calculateRelevance(String query, String text) {
|
|
final queryWords = query.toLowerCase().split(' ');
|
|
final textLower = text.toLowerCase();
|
|
|
|
int matches = 0;
|
|
for (final word in queryWords) {
|
|
if (word.length > 2 && textLower.contains(word)) {
|
|
matches++;
|
|
}
|
|
}
|
|
|
|
return matches / queryWords.length;
|
|
}
|
|
|
|
/// Calculate response confidence
|
|
static double _calculateResponseConfidence(
|
|
String response,
|
|
List<ContentChunk> sources,
|
|
) {
|
|
double confidence = 0.0;
|
|
|
|
// Base confidence on number of sources
|
|
confidence += (sources.length / 5.0) * 0.4; // Max 0.4 for sources
|
|
|
|
// Boost confidence if response mentions concepts from sources
|
|
final sourceConcepts = sources.map((s) => s.concept.toLowerCase()).toSet();
|
|
final responseLower = response.toLowerCase();
|
|
|
|
int conceptMatches = 0;
|
|
for (final concept in sourceConcepts) {
|
|
if (responseLower.contains(concept)) {
|
|
conceptMatches++;
|
|
}
|
|
}
|
|
|
|
confidence +=
|
|
(conceptMatches / sourceConcepts.length) *
|
|
0.3; // Max 0.3 for concept matching
|
|
|
|
// Boost confidence if response is substantial
|
|
if (response.length > 100) {
|
|
confidence += 0.2;
|
|
}
|
|
|
|
// Boost confidence if response cites sources
|
|
if (responseLower.contains('fonte') ||
|
|
responseLower.contains('conteúdo') ||
|
|
responseLower.contains('material')) {
|
|
confidence += 0.1;
|
|
}
|
|
|
|
return confidence.clamp(0.0, 1.0);
|
|
}
|
|
|
|
/// Extract related concepts
|
|
static List<String> _extractRelatedConcepts(List<ContentChunk> sources) {
|
|
final concepts = <String>{};
|
|
|
|
for (final source in sources) {
|
|
concepts.add(source.concept);
|
|
if (source.subConcept != null) {
|
|
concepts.add(source.subConcept!);
|
|
}
|
|
}
|
|
|
|
return concepts.toList()..sort();
|
|
}
|
|
|
|
/// Create error response
|
|
static RAGResponse _createErrorResponse(
|
|
String query,
|
|
TutorMode mode,
|
|
String error,
|
|
) {
|
|
return RAGResponse(
|
|
answer:
|
|
'Desculpe, ocorreu um erro ao processar sua pergunta: $error. Por favor, tente novamente mais tarde.',
|
|
sources: [],
|
|
confidence: 0.0,
|
|
mode: mode,
|
|
relatedConcepts: [],
|
|
metadata: {'error': error, 'model': _model},
|
|
);
|
|
}
|
|
|
|
/// Simple chat without RAG (for fallback)
|
|
static Future<String> simpleChat(String message) async {
|
|
try {
|
|
Logger.info('Simple chat call');
|
|
|
|
final url = Uri.parse(_baseUrl);
|
|
|
|
final requestBody = {
|
|
'model': _model,
|
|
'messages': [
|
|
{
|
|
'role': 'user',
|
|
'content': 'Responda de forma curta e direta: $message',
|
|
},
|
|
],
|
|
'stream': false,
|
|
'options': {'temperature': 0.7, 'max_tokens': 500},
|
|
};
|
|
|
|
final response = await http
|
|
.post(
|
|
url,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode(requestBody),
|
|
)
|
|
.timeout(Duration(seconds: _timeoutSeconds));
|
|
|
|
if (response.statusCode == 200) {
|
|
final responseData = jsonDecode(response.body);
|
|
final message = responseData['message'];
|
|
final content = message?['content'] ?? '';
|
|
|
|
return content.trim();
|
|
} else {
|
|
throw Exception('API Error: ${response.statusCode}');
|
|
}
|
|
} catch (e) {
|
|
Logger.error('Error in simple chat: $e');
|
|
return 'Desculpe, não consegui processar sua mensagem no momento.';
|
|
}
|
|
}
|
|
|
|
/// Check if Ollama service is available
|
|
static Future<bool> isServiceAvailable() async {
|
|
try {
|
|
Logger.info('Checking Ollama service availability');
|
|
|
|
final url = Uri.parse('http://89.114.196.110:11434/api/tags');
|
|
|
|
final response = await http.get(url).timeout(Duration(seconds: 10));
|
|
|
|
if (response.statusCode == 200) {
|
|
Logger.info('Ollama service available');
|
|
return true;
|
|
} else {
|
|
Logger.warning(
|
|
'Ollama service returned status: ${response.statusCode}',
|
|
);
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
Logger.error('Ollama service not available: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Get model information
|
|
static Future<Map<String, dynamic>?> getModelInfo() async {
|
|
try {
|
|
final url = Uri.parse('http://89.114.196.110:11434/api/tags');
|
|
|
|
final response = await http.get(url).timeout(Duration(seconds: 10));
|
|
|
|
if (response.statusCode == 200) {
|
|
final responseData = jsonDecode(response.body);
|
|
final models = responseData['models'] as List? ?? [];
|
|
|
|
for (final model in models) {
|
|
if ((model['name'] as String? ?? '').contains('qwen3-coder')) {
|
|
return model as Map<String, dynamic>;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (e) {
|
|
Logger.error('Error getting model info: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
static Future<String> generateQuiz(
|
|
String prompt, {
|
|
bool isMathematics = false,
|
|
}) async {
|
|
final systemPrompt = isMathematics
|
|
? _getMathematicsSystemPrompt()
|
|
: _getTextBasedSystemPrompt();
|
|
|
|
final messages = <Map<String, String>>[
|
|
{'role': 'system', 'content': systemPrompt},
|
|
{'role': 'user', 'content': prompt},
|
|
];
|
|
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
|
|
static Future<String> testService() async {
|
|
try {
|
|
final testQuery = 'Olá, você está funcionando?';
|
|
final response = await simpleChat(testQuery);
|
|
|
|
return 'Service test successful: $response';
|
|
} catch (e) {
|
|
return 'Service test failed: $e';
|
|
}
|
|
}
|
|
|
|
/// Cache do último contexto PDF enviado ao modelo — reutilizado em follow-ups
|
|
static String _lastPdfContext = '';
|
|
|
|
/// Limpar contexto cacheado — chamar ao mudar de material
|
|
static void clearLastContext() {
|
|
_lastPdfContext = '';
|
|
Logger.info('Last PDF context cleared');
|
|
}
|
|
|
|
/// Detecta se a query é small talk (saudação, conversa casual) — sem necessidade de contexto PDF
|
|
static bool _isSmallTalk(String query) {
|
|
final q = query.trim().toLowerCase();
|
|
const triggers = [
|
|
'olá',
|
|
'ola',
|
|
'oi',
|
|
'ei',
|
|
'hey',
|
|
'hi',
|
|
'tudo bem',
|
|
'tudo bom',
|
|
'como estás',
|
|
'como estas',
|
|
'como vai',
|
|
'bom dia',
|
|
'boa tarde',
|
|
'boa noite',
|
|
'obrigado',
|
|
'obrigada',
|
|
'muito obrigado',
|
|
'muito obrigada',
|
|
'valeu',
|
|
'ok',
|
|
'okay',
|
|
'fixe',
|
|
'ótimo',
|
|
'otimo',
|
|
'perfeito',
|
|
'excelente',
|
|
'adeus',
|
|
'até logo',
|
|
'até mais',
|
|
'tchau',
|
|
'quem és',
|
|
'quem es',
|
|
'quem é o vico',
|
|
'o que és',
|
|
'o que fazes',
|
|
'apresenta-te',
|
|
'apresentate',
|
|
];
|
|
// Exact match or starts with a trigger phrase
|
|
if (triggers.any(
|
|
(t) => q == t || q.startsWith('$t ') || q.startsWith('$t,'),
|
|
)) {
|
|
return true;
|
|
}
|
|
// Very short messages with no educational keywords
|
|
final words = q.split(RegExp(r'\s+'));
|
|
if (words.length <= 3) {
|
|
const eduKeywords = [
|
|
'explica',
|
|
'define',
|
|
'o que é',
|
|
'como funciona',
|
|
'porque',
|
|
'fórmula',
|
|
'formula',
|
|
'exemplo',
|
|
'exercício',
|
|
'exercicio',
|
|
'matéria',
|
|
'materia',
|
|
'tema',
|
|
'conceito',
|
|
'resumo',
|
|
];
|
|
if (!eduKeywords.any((k) => q.contains(k))) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Detecta se a query é um follow-up (pergunta curta/vaga sem keywords de conteúdo)
|
|
static bool _isFollowUp(String query) {
|
|
final q = query.trim().toLowerCase();
|
|
// Menos de 6 palavras E começa com pronome/advérbio de follow-up
|
|
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',
|
|
];
|
|
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');
|
|
|
|
// PASSO 1 — Criar a lista messages vazia
|
|
List<Map<String, String>> messages = [];
|
|
|
|
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO)
|
|
final systemPrompt = _buildSystemPrompt(
|
|
selectedMaterialNames: selectedMaterialNames,
|
|
disciplineName: disciplineName,
|
|
isMathematics: isMathematics,
|
|
);
|
|
messages.add({'role': 'system', 'content': systemPrompt});
|
|
|
|
// 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: 10,
|
|
);
|
|
for (final msg in conversationHistory) {
|
|
messages.add({
|
|
'role': msg['role'] as String,
|
|
'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
|
|
if (messages.length > 1) {
|
|
Logger.info('History order fixed. First message: ${messages[1]}');
|
|
}
|
|
|
|
// PASSO 4 — BUSCAR PDFs DO PROFESSOR NO Firebase Storage (RAG CHUNK RETRIEVAL)
|
|
// Small talk: skip PDF lookup entirely and go straight to model
|
|
if (_isSmallTalk(userQuery)) {
|
|
Logger.info('Small talk detected — skipping PDF lookup');
|
|
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;
|
|
}
|
|
// Detectar follow-up e reutilizar contexto anterior se disponível
|
|
String pdfContext;
|
|
if (_isFollowUp(userQuery) && _lastPdfContext.isNotEmpty) {
|
|
pdfContext = _lastPdfContext;
|
|
Logger.info(
|
|
'Follow-up detected — reusing last PDF context (${pdfContext.length} chars)',
|
|
);
|
|
} else {
|
|
pdfContext = await MaterialsRAGService.getRelevantChunks(
|
|
userQuery: userQuery,
|
|
maxMaterials: 10,
|
|
maxChunks: 20,
|
|
selectedMaterialIds: selectedMaterialIds,
|
|
);
|
|
if (pdfContext.isNotEmpty) {
|
|
_lastPdfContext = pdfContext;
|
|
Logger.info(
|
|
'PDF context sent to model (${pdfContext.length} chars): ${pdfContext.length > 300 ? pdfContext.substring(0, 300) : pdfContext}',
|
|
);
|
|
}
|
|
}
|
|
if (pdfContext.isEmpty) {
|
|
// Sem contexto encontrado — responder com base na personalidade mas sem inventar conteúdo
|
|
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;
|
|
}
|
|
|
|
// PASSO 5 — adicionar a pergunta do user (com contexto embutido se disponível)
|
|
final userContent = pdfContext.isNotEmpty
|
|
? '''Usa APENAS o seguinte contexto para responder. Não uses conhecimento externo.
|
|
Se a resposta não estiver no contexto, diz: "Não encontrei essa informação no material disponível."
|
|
|
|
$pdfContext
|
|
|
|
Pergunta: $userQuery'''
|
|
: userQuery;
|
|
if (!lastHistoryMessageIsDuplicate) {
|
|
messages.add({'role': 'user', 'content': userContent});
|
|
}
|
|
|
|
Logger.info(
|
|
'USING RAG AI SERVICE - Built messages array with ${messages.length} messages',
|
|
);
|
|
|
|
// 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);
|
|
|
|
// Save AI response to memory
|
|
await ChatMemoryService.saveMessage(role: 'assistant', content: response);
|
|
|
|
return response;
|
|
}
|
|
}
|