Files
LearnIT/lib/core/services/rag_ai_service.dart

990 lines
35 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 conversationHistory = await ChatMemoryService.getRecentMessages(
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());
}
/// 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
static Future<String> ask(
String userQuery, {
List<String>? selectedMaterialIds,
}) 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)
messages.add({
'role': 'system',
'content':
r'''Tu és "Vico", o Assistente IA oficial do Learn It — uma plataforma educativa portuguesa.
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)
final conversationHistory = await ChatMemoryService.getRecentMessages(
limit: 4,
);
for (final msg in conversationHistory) {
messages.add({
'role': msg['role'] as String,
'content': msg['content'] as String,
});
}
// 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');
messages.add({'role': 'user', 'content': userQuery});
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
messages.add({'role': 'user', 'content': userQuery});
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;
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);
// Call API
final response = await _callOllamaAPIWithMessages(messages);
// Save AI response to memory
await ChatMemoryService.saveMessage(role: 'assistant', content: response);
return response;
}
}