546 lines
17 KiB
Dart
546 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;
|
|
|
|
/// System message for Vico identity - ALWAYS first in every conversation
|
|
static const String _systemMessage =
|
|
'''Tu és "Vico", o Assistente IA oficial do Teach 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.''';
|
|
|
|
/// 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': 'system', 'content': _systemMessage},
|
|
{'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 [];
|
|
}
|
|
}
|
|
}
|