409 lines
12 KiB
Dart
409 lines
12 KiB
Dart
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';
|
|
}
|
|
}
|
|
}
|