IA e pequenas coisas a funcionar
This commit is contained in:
356
lib/core/services/content_service.dart
Normal file
356
lib/core/services/content_service.dart
Normal file
@@ -0,0 +1,356 @@
|
||||
import 'dart:io';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
/// Service for managing educational content from teachers
|
||||
class ContentService {
|
||||
static final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
static final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
static final FirebaseStorage _storage = FirebaseStorage.instance;
|
||||
|
||||
/// Upload and process content file
|
||||
static Future<String> uploadContent({
|
||||
required File file,
|
||||
required String title,
|
||||
required String subject,
|
||||
required String concept,
|
||||
required double difficulty,
|
||||
required int grade,
|
||||
Map<String, dynamic>? additionalMetadata,
|
||||
}) async {
|
||||
try {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) throw Exception('User not authenticated');
|
||||
|
||||
Logger.info('Starting content upload: $title');
|
||||
|
||||
// 1. Upload file to Firebase Storage
|
||||
final fileName =
|
||||
'${DateTime.now().millisecondsSinceEpoch}_${file.path.split('/').last}';
|
||||
final storageRef = _storage.ref().child('content/${user.uid}/$fileName');
|
||||
|
||||
final uploadTask = await storageRef.putFile(file);
|
||||
final downloadUrl = await uploadTask.ref.getDownloadURL();
|
||||
|
||||
Logger.info('File uploaded to storage: $downloadUrl');
|
||||
|
||||
// 2. Create content document in Firestore
|
||||
final contentDoc = {
|
||||
'title': title,
|
||||
'subject': subject,
|
||||
'concept': concept,
|
||||
'difficulty': difficulty,
|
||||
'grade': grade,
|
||||
'teacherId': user.uid,
|
||||
'teacherEmail': user.email,
|
||||
'fileName': fileName,
|
||||
'downloadUrl': downloadUrl,
|
||||
'fileSize': await file.length(),
|
||||
'uploadedAt': FieldValue.serverTimestamp(),
|
||||
'status': 'processing',
|
||||
'metadata': additionalMetadata ?? {},
|
||||
'chunkCount': 0,
|
||||
'totalChunks': 0,
|
||||
};
|
||||
|
||||
final docRef = await _firestore.collection('content').add(contentDoc);
|
||||
final contentId = docRef.id;
|
||||
|
||||
Logger.info('Content document created: $contentId');
|
||||
|
||||
// 3. Trigger content processing (this would be handled by a Cloud Function)
|
||||
await _triggerContentProcessing(contentId, file.path, downloadUrl);
|
||||
|
||||
return contentId;
|
||||
} catch (e) {
|
||||
Logger.error('Error uploading content: $e');
|
||||
throw Exception('Failed to upload content: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get content list for a teacher
|
||||
static Future<List<Map<String, dynamic>>> getTeacherContent({
|
||||
int limit = 20,
|
||||
DocumentSnapshot? lastDocument,
|
||||
}) async {
|
||||
try {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) throw Exception('User not authenticated');
|
||||
|
||||
Query query = _firestore
|
||||
.collection('content')
|
||||
.where('teacherId', isEqualTo: user.uid)
|
||||
.orderBy('uploadedAt', descending: true)
|
||||
.limit(limit);
|
||||
|
||||
if (lastDocument != null) {
|
||||
query = query.startAfterDocument(lastDocument);
|
||||
}
|
||||
|
||||
final querySnapshot = await query.get();
|
||||
|
||||
return querySnapshot.docs
|
||||
.map((doc) => {'id': doc.id, ...doc.data() as Map<String, dynamic>})
|
||||
.toList();
|
||||
} catch (e) {
|
||||
Logger.error('Error getting teacher content: $e');
|
||||
throw Exception('Failed to get content: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get content details
|
||||
static Future<Map<String, dynamic>?> getContentDetails(
|
||||
String contentId,
|
||||
) async {
|
||||
try {
|
||||
final doc = await _firestore.collection('content').doc(contentId).get();
|
||||
|
||||
if (!doc.exists) return null;
|
||||
|
||||
return {'id': doc.id, ...doc.data() as Map<String, dynamic>};
|
||||
} catch (e) {
|
||||
Logger.error('Error getting content details: $e');
|
||||
throw Exception('Failed to get content details: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get available content for students (filtered by school/grade)
|
||||
static Future<List<Map<String, dynamic>>> getAvailableContent({
|
||||
String? subject,
|
||||
String? concept,
|
||||
double? minDifficulty,
|
||||
double? maxDifficulty,
|
||||
int? grade,
|
||||
int limit = 50,
|
||||
}) async {
|
||||
try {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) throw Exception('User not authenticated');
|
||||
|
||||
// Get user's grade from their profile
|
||||
final userDoc = await _firestore.collection('users').doc(user.uid).get();
|
||||
final userData = userDoc.data();
|
||||
final userGrade = userData?['profile']?['gradeLevel'] ?? grade;
|
||||
|
||||
Query query = _firestore
|
||||
.collection('content')
|
||||
.where('status', isEqualTo: 'processed')
|
||||
.where('grade', isEqualTo: userGrade)
|
||||
.orderBy('uploadedAt', descending: true)
|
||||
.limit(limit);
|
||||
|
||||
// Apply filters
|
||||
if (subject != null) {
|
||||
query = query.where('subject', isEqualTo: subject);
|
||||
}
|
||||
if (concept != null) {
|
||||
query = query.where('concept', isEqualTo: concept);
|
||||
}
|
||||
if (minDifficulty != null) {
|
||||
query = query.where(
|
||||
'difficulty',
|
||||
isGreaterThanOrEqualTo: minDifficulty,
|
||||
);
|
||||
}
|
||||
if (maxDifficulty != null) {
|
||||
query = query.where('difficulty', isLessThanOrEqualTo: maxDifficulty);
|
||||
}
|
||||
|
||||
final querySnapshot = await query.get();
|
||||
|
||||
return querySnapshot.docs
|
||||
.map((doc) => {'id': doc.id, ...doc.data() as Map<String, dynamic>})
|
||||
.toList();
|
||||
} catch (e) {
|
||||
Logger.error('Error getting available content: $e');
|
||||
throw Exception('Failed to get available content: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete content
|
||||
static Future<void> deleteContent(String contentId) async {
|
||||
try {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) throw Exception('User not authenticated');
|
||||
|
||||
// Get content details
|
||||
final content = await getContentDetails(contentId);
|
||||
if (content == null) throw Exception('Content not found');
|
||||
|
||||
// Check ownership
|
||||
if (content['teacherId'] != user.uid) {
|
||||
throw Exception('Permission denied');
|
||||
}
|
||||
|
||||
// Delete from Firestore
|
||||
await _firestore.collection('content').doc(contentId).delete();
|
||||
|
||||
// Delete from Storage
|
||||
if (content['fileName'] != null) {
|
||||
final storageRef = _storage.ref().child(
|
||||
'content/${user.uid}/${content['fileName']}',
|
||||
);
|
||||
await storageRef.delete();
|
||||
}
|
||||
|
||||
// Delete associated chunks
|
||||
final chunksSnapshot = await _firestore
|
||||
.collection('contentChunks')
|
||||
.where('contentId', isEqualTo: contentId)
|
||||
.get();
|
||||
|
||||
final batch = _firestore.batch();
|
||||
for (final doc in chunksSnapshot.docs) {
|
||||
batch.delete(doc.reference);
|
||||
}
|
||||
await batch.commit();
|
||||
|
||||
Logger.info('Content deleted successfully: $contentId');
|
||||
} catch (e) {
|
||||
Logger.error('Error deleting content: $e');
|
||||
throw Exception('Failed to delete content: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Update content metadata
|
||||
static Future<void> updateContentMetadata(
|
||||
String contentId,
|
||||
Map<String, dynamic> metadata,
|
||||
) async {
|
||||
try {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) throw Exception('User not authenticated');
|
||||
|
||||
// Check ownership
|
||||
final content = await getContentDetails(contentId);
|
||||
if (content == null) throw Exception('Content not found');
|
||||
if (content['teacherId'] != user.uid) {
|
||||
throw Exception('Permission denied');
|
||||
}
|
||||
|
||||
await _firestore.collection('content').doc(contentId).update({
|
||||
'metadata': metadata,
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
Logger.info('Content metadata updated: $contentId');
|
||||
} catch (e) {
|
||||
Logger.error('Error updating content metadata: $e');
|
||||
throw Exception('Failed to update content metadata: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get content statistics for a teacher
|
||||
static Future<Map<String, dynamic>> getTeacherStats() async {
|
||||
try {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) throw Exception('User not authenticated');
|
||||
|
||||
final contentSnapshot = await _firestore
|
||||
.collection('content')
|
||||
.where('teacherId', isEqualTo: user.uid)
|
||||
.get();
|
||||
|
||||
final totalContent = contentSnapshot.docs.length;
|
||||
final processedContent = contentSnapshot.docs
|
||||
.where((doc) => doc['status'] == 'processed')
|
||||
.length;
|
||||
|
||||
final totalChunks = contentSnapshot.docs.fold<int>(
|
||||
0,
|
||||
(sum, doc) => sum + (doc['chunkCount'] as int? ?? 0),
|
||||
);
|
||||
|
||||
final subjects = <String, int>{};
|
||||
for (final doc in contentSnapshot.docs) {
|
||||
final subject = doc['subject'] as String? ?? 'Unknown';
|
||||
subjects[subject] = (subjects[subject] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
'totalContent': totalContent,
|
||||
'processedContent': processedContent,
|
||||
'totalChunks': totalChunks,
|
||||
'subjects': subjects,
|
||||
'processingContent': totalContent - processedContent,
|
||||
};
|
||||
} catch (e) {
|
||||
Logger.error('Error getting teacher stats: $e');
|
||||
throw Exception('Failed to get teacher stats: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger content processing (would be handled by Cloud Function)
|
||||
static Future<void> _triggerContentProcessing(
|
||||
String contentId,
|
||||
String filePath,
|
||||
String downloadUrl,
|
||||
) async {
|
||||
try {
|
||||
// This would typically trigger a Cloud Function
|
||||
// For now, we'll update the status to indicate processing should start
|
||||
await _firestore.collection('content').doc(contentId).update({
|
||||
'status': 'processing_started',
|
||||
'processingStartedAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
Logger.info('Content processing triggered: $contentId');
|
||||
} catch (e) {
|
||||
Logger.error('Error triggering content processing: $e');
|
||||
throw Exception('Failed to trigger content processing: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Search content by text
|
||||
static Future<List<Map<String, dynamic>>> searchContent({
|
||||
required String searchQuery,
|
||||
String? subject,
|
||||
int? grade,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
try {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) throw Exception('User not authenticated');
|
||||
|
||||
// Get user's grade if not specified
|
||||
final userDoc = await _firestore.collection('users').doc(user.uid).get();
|
||||
final userData = userDoc.data();
|
||||
final userGrade = grade ?? userData?['profile']?['gradeLevel'];
|
||||
|
||||
// For now, we'll do a simple text search on title and concept
|
||||
// In a full implementation, this would use the vector search
|
||||
Query query = _firestore
|
||||
.collection('content')
|
||||
.where('status', isEqualTo: 'processed')
|
||||
.where('grade', isEqualTo: userGrade)
|
||||
.orderBy('uploadedAt', descending: true)
|
||||
.limit(limit);
|
||||
|
||||
if (subject != null) {
|
||||
query = query.where('subject', isEqualTo: subject);
|
||||
}
|
||||
|
||||
final querySnapshot = await query.get();
|
||||
|
||||
final searchQueryLower = searchQuery.toLowerCase();
|
||||
final results = querySnapshot.docs
|
||||
.where((doc) {
|
||||
final data = doc.data() as Map<String, dynamic>;
|
||||
final title = (data['title'] as String? ?? '').toLowerCase();
|
||||
final concept = (data['concept'] as String? ?? '').toLowerCase();
|
||||
|
||||
return title.contains(searchQueryLower) ||
|
||||
concept.contains(searchQueryLower);
|
||||
})
|
||||
.map((doc) => {'id': doc.id, ...doc.data() as Map<String, dynamic>})
|
||||
.toList();
|
||||
|
||||
return results;
|
||||
} catch (e) {
|
||||
Logger.error('Error searching content: $e');
|
||||
throw Exception('Failed to search content: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
408
lib/core/services/rag_ai_service.dart
Normal file
408
lib/core/services/rag_ai_service.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../utils/logger.dart';
|
||||
import '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
|
||||
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');
|
||||
|
||||
// 1. Build the prompt with context
|
||||
final prompt = _buildRAGPrompt(userQuery, context, mode);
|
||||
|
||||
// 2. Call Ollama API
|
||||
final response = await _callOllamaAPI(prompt);
|
||||
|
||||
// 3. 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
|
||||
static String _buildRAGPrompt(
|
||||
String userQuery,
|
||||
String context,
|
||||
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 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();
|
||||
}
|
||||
|
||||
/// Call Ollama API
|
||||
static Future<String> _callOllamaAPI(String prompt) async {
|
||||
try {
|
||||
Logger.info('Calling Ollama API with model: $_model');
|
||||
|
||||
final url = Uri.parse(_baseUrl);
|
||||
|
||||
final requestBody = {
|
||||
'model': _model,
|
||||
'messages': [
|
||||
{'role': 'user', 'content': prompt},
|
||||
],
|
||||
'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'];
|
||||
final content = message?['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');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
final responseData = jsonDecode(response.body);
|
||||
final models = responseData['models'] as List? ?? [];
|
||||
|
||||
final hasModel = models.any(
|
||||
(model) => (model['name'] as String? ?? '').contains('qwen3-coder'),
|
||||
);
|
||||
|
||||
Logger.info('Ollama service available, model found: $hasModel');
|
||||
return hasModel;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
532
lib/core/services/rag_service.dart
Normal file
532
lib/core/services/rag_service.dart
Normal file
@@ -0,0 +1,532 @@
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
358
lib/core/services/vector_service.dart
Normal file
358
lib/core/services/vector_service.dart
Normal file
@@ -0,0 +1,358 @@
|
||||
import 'dart:math';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import '../models/content_chunk.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
/// Service for vector embeddings and similarity search
|
||||
class VectorService {
|
||||
static final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
|
||||
/// Generate embedding for text (mock implementation - in production would use real embedding model)
|
||||
static List<double> generateEmbedding(String text) {
|
||||
try {
|
||||
Logger.info('Generating embedding for text of length: ${text.length}');
|
||||
|
||||
// Mock embedding generation - in production would use OpenAI, Cohere, or local model
|
||||
// This creates a deterministic embedding based on text content
|
||||
final hash = text.hashCode;
|
||||
final random = Random(hash.abs());
|
||||
|
||||
// Generate 384-dimensional embedding with semantic similarity
|
||||
final embedding = List.generate(384, (i) {
|
||||
// Create deterministic values based on text hash and position
|
||||
final seed = (hash * (i + 1)) % 1000;
|
||||
final value = (seed / 1000.0 - 0.5) * 2.0;
|
||||
|
||||
// Add some semantic similarity for common words
|
||||
double semanticBoost = 0.0;
|
||||
final textLower = text.toLowerCase();
|
||||
|
||||
// Boost for common educational terms
|
||||
if (textLower.contains('fotossíntese') ||
|
||||
textLower.contains('plantas')) {
|
||||
semanticBoost += 0.3 * (i % 10) / 10.0;
|
||||
}
|
||||
if (textLower.contains('energia') || textLower.contains('luz')) {
|
||||
semanticBoost += 0.2 * (i % 8) / 8.0;
|
||||
}
|
||||
if (textLower.contains('biologia') || textLower.contains('processo')) {
|
||||
semanticBoost += 0.1 * (i % 12) / 12.0;
|
||||
}
|
||||
|
||||
return value + semanticBoost;
|
||||
});
|
||||
|
||||
// Normalize the vector
|
||||
final norm = sqrt(embedding.map((x) => x * x).reduce((a, b) => a + b));
|
||||
return embedding.map((x) => x / norm).toList();
|
||||
} catch (e) {
|
||||
Logger.error('Error generating embedding: $e');
|
||||
// Return zero vector as fallback
|
||||
return List.filled(384, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate cosine similarity between two vectors
|
||||
static double cosineSimilarity(List<double> vec1, List<double> vec2) {
|
||||
if (vec1.length != vec2.length) {
|
||||
throw ArgumentError('Vectors must be of same length');
|
||||
}
|
||||
|
||||
double dotProduct = 0.0;
|
||||
double norm1 = 0.0;
|
||||
double norm2 = 0.0;
|
||||
|
||||
for (int i = 0; i < vec1.length; i++) {
|
||||
dotProduct += vec1[i] * vec2[i];
|
||||
norm1 += vec1[i] * vec1[i];
|
||||
norm2 += vec2[i] * vec2[i];
|
||||
}
|
||||
|
||||
if (norm1 == 0 || norm2 == 0) return 0.0;
|
||||
|
||||
return dotProduct / (sqrt(norm1) * sqrt(norm2));
|
||||
}
|
||||
|
||||
/// Search for similar content chunks
|
||||
static Future<List<ContentChunk>> searchSimilar({
|
||||
required List<double> queryEmbedding,
|
||||
String? subject,
|
||||
String? concept,
|
||||
int? grade,
|
||||
double? minDifficulty,
|
||||
double? maxDifficulty,
|
||||
int k = 5,
|
||||
double threshold = 0.3,
|
||||
}) async {
|
||||
try {
|
||||
Logger.info(
|
||||
'Searching for similar content with k=$k, threshold=$threshold',
|
||||
);
|
||||
|
||||
Query query = _firestore
|
||||
.collection('contentChunks')
|
||||
.where('isActive', isEqualTo: true)
|
||||
.limit(100); // Get more candidates for better filtering
|
||||
|
||||
// Apply filters
|
||||
if (subject != null) {
|
||||
query = query.where('subject', isEqualTo: subject);
|
||||
}
|
||||
if (concept != null) {
|
||||
query = query.where('concept', isEqualTo: concept);
|
||||
}
|
||||
if (grade != null) {
|
||||
query = query.where('grade', isEqualTo: grade);
|
||||
}
|
||||
if (minDifficulty != null) {
|
||||
query = query.where(
|
||||
'difficulty',
|
||||
isGreaterThanOrEqualTo: minDifficulty,
|
||||
);
|
||||
}
|
||||
if (maxDifficulty != null) {
|
||||
query = query.where('difficulty', isLessThanOrEqualTo: maxDifficulty);
|
||||
}
|
||||
|
||||
final querySnapshot = await query.get();
|
||||
|
||||
// Calculate similarities and sort
|
||||
final scoredChunks = <ContentChunk, double>{};
|
||||
|
||||
for (final doc in querySnapshot.docs) {
|
||||
final chunk = ContentChunk.fromFirestore(
|
||||
doc.data() as Map<String, dynamic>,
|
||||
doc.id,
|
||||
);
|
||||
final similarity = cosineSimilarity(queryEmbedding, chunk.embedding);
|
||||
|
||||
if (similarity >= threshold) {
|
||||
scoredChunks[chunk] = similarity;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by similarity and take top k
|
||||
final sortedChunks = scoredChunks.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
return sortedChunks.take(k).map((entry) => entry.key).toList();
|
||||
} catch (e) {
|
||||
Logger.error('Error searching similar content: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Search by text query (generates embedding and searches)
|
||||
static Future<List<ContentChunk>> searchByText({
|
||||
required String query,
|
||||
String? subject,
|
||||
String? concept,
|
||||
int? grade,
|
||||
double? minDifficulty,
|
||||
double? maxDifficulty,
|
||||
int k = 5,
|
||||
}) async {
|
||||
try {
|
||||
Logger.info('Searching by text: "${query.substring(0, 50)}..."');
|
||||
|
||||
// Generate embedding for query
|
||||
final queryEmbedding = generateEmbedding(query);
|
||||
|
||||
// Search for similar content
|
||||
return await searchSimilar(
|
||||
queryEmbedding: queryEmbedding,
|
||||
subject: subject,
|
||||
concept: concept,
|
||||
grade: grade,
|
||||
minDifficulty: minDifficulty,
|
||||
maxDifficulty: maxDifficulty,
|
||||
k: k,
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.error('Error searching by text: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch generate embeddings for multiple texts
|
||||
static Future<List<List<double>>> batchGenerateEmbeddings(
|
||||
List<String> texts,
|
||||
) async {
|
||||
try {
|
||||
Logger.info('Generating embeddings for ${texts.length} texts');
|
||||
|
||||
final embeddings = <List<double>>[];
|
||||
|
||||
for (final text in texts) {
|
||||
final embedding = generateEmbedding(text);
|
||||
embeddings.add(embedding);
|
||||
}
|
||||
|
||||
return embeddings;
|
||||
} catch (e) {
|
||||
Logger.error('Error generating batch embeddings: $e');
|
||||
return List.filled(texts.length, List.filled(384, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Update embedding for a content chunk
|
||||
static Future<void> updateChunkEmbedding(String chunkId, String text) async {
|
||||
try {
|
||||
Logger.info('Updating embedding for chunk: $chunkId');
|
||||
|
||||
final embedding = generateEmbedding(text);
|
||||
|
||||
await _firestore.collection('contentChunks').doc(chunkId).update({
|
||||
'embedding': embedding,
|
||||
'lastUpdated': FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
Logger.info('Embedding updated for chunk: $chunkId');
|
||||
} catch (e) {
|
||||
Logger.error('Error updating chunk embedding: $e');
|
||||
throw Exception('Failed to update chunk embedding: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get content chunks for a specific content
|
||||
static Future<List<ContentChunk>> getContentChunks(String contentId) async {
|
||||
try {
|
||||
Logger.info('Getting chunks for content: $contentId');
|
||||
|
||||
final querySnapshot = await _firestore
|
||||
.collection('contentChunks')
|
||||
.where('contentId', isEqualTo: contentId)
|
||||
.where('isActive', isEqualTo: true)
|
||||
.orderBy('createdAt')
|
||||
.get();
|
||||
|
||||
return querySnapshot.docs
|
||||
.map((doc) => ContentChunk.fromFirestore(doc.data(), doc.id))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
Logger.error('Error getting content chunks: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Create content chunk with embedding
|
||||
static Future<String> createContentChunk({
|
||||
required String contentId,
|
||||
required String text,
|
||||
required String subject,
|
||||
required String concept,
|
||||
String? subConcept,
|
||||
required String unit,
|
||||
required double difficulty,
|
||||
required int grade,
|
||||
required String sourceDocument,
|
||||
Map<String, dynamic>? metadata,
|
||||
int? pageNumber,
|
||||
String? section,
|
||||
}) async {
|
||||
try {
|
||||
Logger.info('Creating content chunk for: $concept');
|
||||
|
||||
// Generate embedding
|
||||
final embedding = generateEmbedding(text);
|
||||
|
||||
// Create chunk document
|
||||
final chunkData = {
|
||||
'contentId': contentId,
|
||||
'text': text,
|
||||
'subject': subject,
|
||||
'concept': concept,
|
||||
if (subConcept != null) 'subConcept': subConcept,
|
||||
'unit': unit,
|
||||
'difficulty': difficulty,
|
||||
'grade': grade,
|
||||
'embedding': embedding,
|
||||
'sourceDocument': sourceDocument,
|
||||
'metadata': metadata ?? {},
|
||||
'createdAt': FieldValue.serverTimestamp(),
|
||||
'isActive': true,
|
||||
if (pageNumber != null) 'pageNumber': pageNumber,
|
||||
if (section != null) 'section': section,
|
||||
};
|
||||
|
||||
final docRef = await _firestore
|
||||
.collection('contentChunks')
|
||||
.add(chunkData);
|
||||
final chunkId = docRef.id;
|
||||
|
||||
Logger.info('Content chunk created: $chunkId');
|
||||
return chunkId;
|
||||
} catch (e) {
|
||||
Logger.error('Error creating content chunk: $e');
|
||||
throw Exception('Failed to create content chunk: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete content chunks for a content
|
||||
static Future<void> deleteContentChunks(String contentId) async {
|
||||
try {
|
||||
Logger.info('Deleting chunks for content: $contentId');
|
||||
|
||||
final querySnapshot = await _firestore
|
||||
.collection('contentChunks')
|
||||
.where('contentId', isEqualTo: contentId)
|
||||
.get();
|
||||
|
||||
final batch = _firestore.batch();
|
||||
for (final doc in querySnapshot.docs) {
|
||||
batch.delete(doc.reference);
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
Logger.info('Content chunks deleted: ${querySnapshot.docs.length}');
|
||||
} catch (e) {
|
||||
Logger.error('Error deleting content chunks: $e');
|
||||
throw Exception('Failed to delete content chunks: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get vector statistics
|
||||
static Future<Map<String, dynamic>> getVectorStats() async {
|
||||
try {
|
||||
Logger.info('Getting vector statistics');
|
||||
|
||||
final querySnapshot = await _firestore
|
||||
.collection('contentChunks')
|
||||
.where('isActive', isEqualTo: true)
|
||||
.get();
|
||||
|
||||
final totalChunks = querySnapshot.docs.length;
|
||||
final subjects = <String, int>{};
|
||||
final concepts = <String, int>{};
|
||||
final grades = <int, int>{};
|
||||
|
||||
for (final doc in querySnapshot.docs) {
|
||||
final data = doc.data();
|
||||
final subject = data['subject'] as String? ?? 'Unknown';
|
||||
final concept = data['concept'] as String? ?? 'Unknown';
|
||||
final grade = data['grade'] as int? ?? 0;
|
||||
|
||||
subjects[subject] = (subjects[subject] ?? 0) + 1;
|
||||
concepts[concept] = (concepts[concept] ?? 0) + 1;
|
||||
grades[grade] = (grades[grade] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
'totalChunks': totalChunks,
|
||||
'subjects': subjects,
|
||||
'concepts': concepts,
|
||||
'grades': grades,
|
||||
'embeddingDimension': 384,
|
||||
};
|
||||
} catch (e) {
|
||||
Logger.error('Error getting vector stats: $e');
|
||||
return {
|
||||
'totalChunks': 0,
|
||||
'subjects': <String, int>{},
|
||||
'concepts': <String, int>{},
|
||||
'grades': <int, int>{},
|
||||
'embeddingDimension': 384,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user