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

533 lines
17 KiB
Dart

import 'dart:math';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../models/content_chunk.dart';
import '../utils/logger.dart';
import 'vector_service.dart';
/// Modes for AI tutoring
enum TutorMode {
explanation, // Detailed explanations
tutor, // Socratic questioning
exploration, // Discovery-based learning
}
/// Response from RAG system
class RAGResponse {
final String answer;
final List<SourceCitation> sources;
final double confidence;
final TutorMode mode;
final List<String> relatedConcepts;
final Map<String, dynamic> metadata;
RAGResponse({
required this.answer,
required this.sources,
required this.confidence,
required this.mode,
required this.relatedConcepts,
required this.metadata,
});
}
/// Source citation for RAG responses
class SourceCitation {
final String contentId;
final String chunkId;
final String title;
final String concept;
final String subject;
final String excerpt;
final double relevance;
final int? pageNumber;
SourceCitation({
required this.contentId,
required this.chunkId,
required this.title,
required this.concept,
required this.subject,
required this.excerpt,
required this.relevance,
this.pageNumber,
});
}
/// Service for Retrieval-Augmented Generation pipeline
class RAGService {
static final FirebaseFirestore _firestore = FirebaseFirestore.instance;
static const int maxContextTokens = 4000;
static const int maxChunksInContext = 5;
/// Process a user query through RAG pipeline
static Future<RAGResponse> processQuery({
required String userQuery,
required TutorMode mode,
String? subject,
String? concept,
int? grade,
double? minDifficulty,
double? maxDifficulty,
int maxSources = 5,
}) async {
try {
Logger.info(
'Processing RAG query: "${userQuery.substring(0, 50)}..." in ${mode.name} mode',
);
// 1. Generate embedding for user query
final queryEmbedding = VectorService.generateEmbedding(userQuery);
// 2. Retrieve relevant content chunks
final relevantChunks = await VectorService.searchSimilar(
queryEmbedding: queryEmbedding,
subject: subject,
concept: concept,
grade: grade,
minDifficulty: minDifficulty,
maxDifficulty: maxDifficulty,
k: maxSources + 2, // Get extra for filtering
threshold: 0.3,
);
if (relevantChunks.isEmpty) {
Logger.warning('No relevant content found for query');
return _createNoContentResponse(userQuery, mode);
}
// 3. Build context window
final context = _buildContextWindow(relevantChunks, userQuery, mode);
// 4. Generate response (this will be handled by RAGAIService)
final response = await _generateResponse(
query: userQuery,
context: context,
mode: mode,
sources: relevantChunks.take(maxSources).toList(),
);
Logger.info(
'RAG response generated with confidence: ${response.confidence}',
);
return response;
} catch (e) {
Logger.error('Error processing RAG query: $e');
return _createErrorResponse(userQuery, mode, e.toString());
}
}
/// Build context window from relevant chunks
static String _buildContextWindow(
List<ContentChunk> chunks,
String userQuery,
TutorMode mode,
) {
try {
final contextBuilder = StringBuffer();
// Add context header
contextBuilder.writeln('=== CONTEÚDO EDUCACIONAL RELEVANTE ===\n');
// Sort chunks by relevance and take top chunks
final sortedChunks = chunks.take(maxChunksInContext).toList();
for (int i = 0; i < sortedChunks.length; i++) {
final chunk = sortedChunks[i];
contextBuilder.writeln('--- Fonte ${i + 1} ---');
contextBuilder.writeln('Disciplina: ${chunk.subject}');
contextBuilder.writeln('Conceito: ${chunk.concept}');
if (chunk.subConcept != null) {
contextBuilder.writeln('Subconceito: ${chunk.subConcept}');
}
contextBuilder.writeln('Unidade: ${chunk.unit}');
contextBuilder.writeln('Dificuldade: ${chunk.difficulty}');
if (chunk.pageNumber != null) {
contextBuilder.writeln('Página: ${chunk.pageNumber}');
}
contextBuilder.writeln('\nConteúdo:\n${chunk.text}\n');
}
// Add mode-specific instructions
contextBuilder.writeln('\n=== INSTRUÇÕES DE TUTORIA ===');
contextBuilder.writeln('Modo: ${_getModeInstructions(mode)}');
contextBuilder.writeln('Pergunta do Aluno: $userQuery\n');
final contextText = contextBuilder.toString();
// Check context length and truncate if necessary
if (contextText.length > maxContextTokens) {
Logger.warning('Context too long, truncating');
return contextText.substring(0, maxContextTokens - 100) +
'...[truncated]';
}
return contextText;
} catch (e) {
Logger.error('Error building context window: $e');
return 'Erro ao construir contexto';
}
}
/// Get mode-specific instructions
static String _getModeInstructions(TutorMode mode) {
switch (mode) {
case TutorMode.explanation:
return 'Forneça explicações detalhadas e claras baseadas apenas no conteúdo fornecido. Use exemplos do material e estruture a resposta de forma lógica.';
case TutorMode.tutor:
return 'Use o método socrático - faça perguntas que guiem o aluno a descobrir a resposta. Baseie-se apenas no conteúdo fornecido e incentive o pensamento crítico.';
case TutorMode.exploration:
return 'Ajude o aluno a explorar o conceito através de descoberta. Conecte com ideias relacionadas e sugira investigações baseadas no conteúdo.';
}
}
/// Generate response (placeholder - will be implemented in RAGAIService)
static Future<RAGResponse> _generateResponse({
required String query,
required String context,
required TutorMode mode,
required List<ContentChunk> sources,
}) async {
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();
// Call Ollama API
final url = Uri.parse('http://89.114.196.110:11434/api/chat');
// Build prompt with context
final prompt = _buildPrompt(query, context, mode);
final response = await http
.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'model': 'qwen3-coder:30b',
'messages': [
{'role': 'user', 'content': prompt},
],
'stream': false,
}),
)
.timeout(const Duration(seconds: 60));
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
final answer = responseData['message']['content'] as String;
final confidence = _calculateConfidence(sources);
final relatedConcepts = _extractRelatedConcepts(sources);
return RAGResponse(
answer: answer,
sources: citations,
confidence: confidence,
mode: mode,
relatedConcepts: relatedConcepts,
metadata: {
'queryLength': query.length,
'contextLength': context.length,
'sourceCount': sources.length,
'processingTime': DateTime.now().millisecondsSinceEpoch,
'apiResponse': true,
},
);
} else {
Logger.error('Ollama API error: ${response.statusCode}');
// Fallback to mock response
final answer = _generateMockAnswer(query, context, mode);
final confidence = _calculateConfidence(sources);
final relatedConcepts = _extractRelatedConcepts(sources);
return RAGResponse(
answer: answer,
sources: citations,
confidence: confidence,
mode: mode,
relatedConcepts: relatedConcepts,
metadata: {
'queryLength': query.length,
'contextLength': context.length,
'sourceCount': sources.length,
'processingTime': DateTime.now().millisecondsSinceEpoch,
'apiResponse': false,
'apiError': response.statusCode,
},
);
}
} catch (e) {
Logger.error('Error calling Ollama API: $e');
// Fallback to mock response
final answer = _generateMockAnswer(query, context, mode);
final confidence = _calculateConfidence(sources);
final relatedConcepts = _extractRelatedConcepts(sources);
return RAGResponse(
answer: answer,
sources: 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(),
confidence: confidence,
mode: mode,
relatedConcepts: relatedConcepts,
metadata: {
'queryLength': query.length,
'contextLength': context.length,
'sourceCount': sources.length,
'processingTime': DateTime.now().millisecondsSinceEpoch,
'apiResponse': false,
'apiError': e.toString(),
},
);
}
}
/// Get excerpt from text
static String _getExcerpt(String text, {int maxLength = 200}) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
/// Build prompt for AI model with educational context
static String _buildPrompt(String query, String context, TutorMode mode) {
final modeInstructions = _getModeInstructions(mode);
return '''$modeInstructions
=== CONTEÚDO EDUCACIONAL RELEVANTE ===
$context
=== PERGUNTA DO ALUNO ===
$query
=== INSTRUÇÕES ===
- Baseie sua resposta APENAS no conteúdo educacional fornecido acima
- Se o conteúdo não for suficiente para responder, explique o que está disponível
- Use linguagem clara e educacional
- Adapte a resposta ao nível do aluno
- Forneça exemplos quando possível
- Seja conciso mas completo''';
}
/// 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;
}
/// Generate mock answer (placeholder)
static String _generateMockAnswer(
String query,
String context,
TutorMode mode,
) {
switch (mode) {
case TutorMode.explanation:
return 'Baseado no conteúdo fornecido, posso explicar que $query. Esta abordagem é fundamentada nos conceitos apresentados no material educacional. A resposta detalhada envolve os princípios teóricos e práticos descritos nas fontes relevantes.';
case TutorMode.tutor:
return 'Para entender $query, vamos explorar juntos. O que você já sabe sobre este conceito? Baseado no material, podemos investigar os aspectos fundamentais passo a passo.';
case TutorMode.exploration:
return 'Vamos descobrir mais sobre $query! O conteúdo sugere várias abordagens interessantes. Que aspecto gostaria de explorar primeiro? Podemos investigar as conexões entre os diferentes conceitos apresentados.';
}
}
/// Calculate confidence score
static double _calculateConfidence(List<ContentChunk> sources) {
if (sources.isEmpty) return 0.0;
// Base confidence on number and quality of sources
double confidence = min(sources.length * 0.2, 0.8);
// Boost confidence if sources are highly relevant
final avgRelevance =
sources.map((s) => s.difficulty).reduce((a, b) => a + b) /
sources.length;
confidence += avgRelevance * 0.2;
return min(confidence, 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 response for no content found
static RAGResponse _createNoContentResponse(String query, TutorMode mode) {
return RAGResponse(
answer:
'Desculpe, não encontrei conteúdo relevante para responder à sua pergunta sobre "$query". Tente reformular a pergunta ou verifique se o material sobre este tópico está disponível.',
sources: [],
confidence: 0.0,
mode: mode,
relatedConcepts: [],
metadata: {'error': 'no_content_found'},
);
}
/// Create error response
static RAGResponse _createErrorResponse(
String query,
TutorMode mode,
String error,
) {
return RAGResponse(
answer:
'Ocorreu um erro ao processar sua pergunta. Por favor, tente novamente mais tarde.',
sources: [],
confidence: 0.0,
mode: mode,
relatedConcepts: [],
metadata: {'error': error},
);
}
/// Get conversation history for context
static Future<List<Map<String, dynamic>>> getConversationHistory({
required String userId,
int limit = 10,
}) async {
try {
Logger.info('Getting conversation history for user: $userId');
final querySnapshot = await _firestore
.collection('conversations')
.where('userId', isEqualTo: userId)
.orderBy('createdAt', descending: true)
.limit(limit)
.get();
return querySnapshot.docs
.map((doc) => {'id': doc.id, ...doc.data() as Map<String, dynamic>})
.toList();
} catch (e) {
Logger.error('Error getting conversation history: $e');
return [];
}
}
/// Save conversation to Firestore
static Future<String> saveConversation({
required String userId,
required String query,
required RAGResponse response,
}) async {
try {
Logger.info('Saving conversation for user: $userId');
final conversationData = {
'userId': userId,
'query': query,
'answer': response.answer,
'mode': response.mode.name,
'confidence': response.confidence,
'sources': response.sources
.map(
(s) => {
'contentId': s.contentId,
'chunkId': s.chunkId,
'title': s.title,
'concept': s.concept,
'subject': s.subject,
'excerpt': s.excerpt,
'relevance': s.relevance,
'pageNumber': s.pageNumber,
},
)
.toList(),
'relatedConcepts': response.relatedConcepts,
'metadata': response.metadata,
'createdAt': FieldValue.serverTimestamp(),
};
final docRef = await _firestore
.collection('conversations')
.add(conversationData);
final conversationId = docRef.id;
Logger.info('Conversation saved: $conversationId');
return conversationId;
} catch (e) {
Logger.error('Error saving conversation: $e');
throw Exception('Failed to save conversation: $e');
}
}
/// Get popular concepts
static Future<List<Map<String, dynamic>>> getPopularConcepts({
int limit = 20,
}) async {
try {
Logger.info('Getting popular concepts');
final querySnapshot = await _firestore
.collection('contentChunks')
.where('isActive', isEqualTo: true)
.limit(100)
.get();
final conceptCounts = <String, int>{};
for (final doc in querySnapshot.docs) {
final data = doc.data();
final concept = data['concept'] as String? ?? 'Unknown';
conceptCounts[concept] = (conceptCounts[concept] ?? 0) + 1;
}
final sortedConcepts = conceptCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return sortedConcepts
.take(limit)
.map((entry) => {'concept': entry.key, 'count': entry.value})
.toList();
} catch (e) {
Logger.error('Error getting popular concepts: $e');
return [];
}
}
}