😋 初始化仓库
This commit is contained in:
431
lib/services/evaluation_service.dart
Normal file
431
lib/services/evaluation_service.dart
Normal file
@@ -0,0 +1,431 @@
|
||||
import 'dart:math';
|
||||
import '../models/course.dart';
|
||||
import '../models/questionnaire.dart';
|
||||
import '../models/evaluation_result.dart';
|
||||
import 'aufe_connection.dart';
|
||||
import 'questionnaire_parser.dart';
|
||||
import '../utils/text_generator.dart';
|
||||
|
||||
/// Service for handling course evaluation operations
|
||||
/// Manages the evaluation process including form data building,
|
||||
/// option selection, and text generation
|
||||
class EvaluationService {
|
||||
final AUFEConnection _connection;
|
||||
final QuestionnaireParser _parser;
|
||||
final TextGenerator _textGenerator;
|
||||
final Random _random = Random();
|
||||
|
||||
EvaluationService({
|
||||
required AUFEConnection connection,
|
||||
required QuestionnaireParser parser,
|
||||
required TextGenerator textGenerator,
|
||||
}) : _connection = connection,
|
||||
_parser = parser,
|
||||
_textGenerator = textGenerator;
|
||||
|
||||
/// Prepare evaluation for a course (access page, parse questionnaire, generate answers)
|
||||
///
|
||||
/// [course] - The course to evaluate
|
||||
/// [totalCourses] - Total number of courses (for count parameter)
|
||||
/// Returns form data map if successful, null otherwise
|
||||
Future<Map<String, String>?> prepareEvaluation(
|
||||
Course course, {
|
||||
int? totalCourses,
|
||||
}) async {
|
||||
try {
|
||||
// 1. Access evaluation page to get questionnaire HTML
|
||||
final html = await _connection.accessEvaluationPage(
|
||||
course,
|
||||
totalCourses: totalCourses,
|
||||
);
|
||||
if (html == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Parse questionnaire
|
||||
final questionnaire = await _parser.parseAsync(html);
|
||||
|
||||
// 3. Build form data
|
||||
final formData = _buildFormData(
|
||||
questionnaire,
|
||||
course: course,
|
||||
totalCourses: totalCourses,
|
||||
);
|
||||
|
||||
return formData;
|
||||
} catch (e, stackTrace) {
|
||||
print('❌ prepareEvaluation error: $e');
|
||||
print('❌ Stack trace: $stackTrace');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit evaluation with prepared form data
|
||||
///
|
||||
/// [course] - The course being evaluated
|
||||
/// [formData] - Prepared form data from prepareEvaluation
|
||||
/// Returns [EvaluationResult] with success status and error message if failed
|
||||
Future<EvaluationResult> submitEvaluation(
|
||||
Course course,
|
||||
Map<String, String> formData,
|
||||
) async {
|
||||
try {
|
||||
final submitResponse = await _connection.submitEvaluation(formData);
|
||||
|
||||
if (submitResponse.isSuccess) {
|
||||
return EvaluationResult(course: course, success: true);
|
||||
} else {
|
||||
return EvaluationResult(
|
||||
course: course,
|
||||
success: false,
|
||||
errorMessage: submitResponse.msg,
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
print('❌ submitEvaluation error: $e');
|
||||
print('❌ Stack trace: $stackTrace');
|
||||
return EvaluationResult(
|
||||
course: course,
|
||||
success: false,
|
||||
errorMessage: '提交评价出错: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a single course (legacy method, combines prepare and submit with countdown)
|
||||
///
|
||||
/// [course] - The course to evaluate
|
||||
/// [totalCourses] - Total number of courses (for count parameter)
|
||||
/// [onStatusChange] - Optional callback to report status changes during evaluation
|
||||
/// [onCountdown] - Optional callback to report countdown progress (seconds remaining, total seconds)
|
||||
/// Returns [EvaluationResult] with success status and error message if failed
|
||||
Future<EvaluationResult> evaluateCourse(
|
||||
Course course, {
|
||||
int? totalCourses,
|
||||
Function(String status)? onStatusChange,
|
||||
Function(int remaining, int total)? onCountdown,
|
||||
}) async {
|
||||
try {
|
||||
// 1. Prepare evaluation
|
||||
onStatusChange?.call('正在访问评价页面...');
|
||||
final formData = await prepareEvaluation(
|
||||
course,
|
||||
totalCourses: totalCourses,
|
||||
);
|
||||
if (formData == null) {
|
||||
return EvaluationResult(
|
||||
course: course,
|
||||
success: false,
|
||||
errorMessage: '无法访问评价页面',
|
||||
);
|
||||
}
|
||||
|
||||
onStatusChange?.call('正在解析问卷结构...');
|
||||
onStatusChange?.call('正在生成答案...');
|
||||
|
||||
// 2. Wait 140 seconds before submission (server anti-bot requirement)
|
||||
onStatusChange?.call('等待提交中...');
|
||||
|
||||
const totalSeconds = 140;
|
||||
for (int i = totalSeconds; i > 0; i--) {
|
||||
onCountdown?.call(i, totalSeconds);
|
||||
|
||||
// Also report to status for logging
|
||||
final progress = (totalSeconds - i) / totalSeconds;
|
||||
final barLength = 20;
|
||||
final filledLength = (progress * barLength).round();
|
||||
final bar = '█' * filledLength + '░' * (barLength - filledLength);
|
||||
final percent = (progress * 100).toInt();
|
||||
onStatusChange?.call('等待提交 [$bar] $percent% (${i}s)');
|
||||
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
// 3. Submit evaluation
|
||||
onStatusChange?.call('正在提交评价...');
|
||||
final result = await submitEvaluation(course, formData);
|
||||
|
||||
if (result.success) {
|
||||
onStatusChange?.call('提交成功');
|
||||
} else {
|
||||
onStatusChange?.call('提交失败: ${result.errorMessage}');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e, stackTrace) {
|
||||
print('❌ evaluateCourse error: $e');
|
||||
print('❌ Stack trace: $stackTrace');
|
||||
onStatusChange?.call('评教失败');
|
||||
return EvaluationResult(
|
||||
course: course,
|
||||
success: false,
|
||||
errorMessage: '评教过程出错: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build form data from questionnaire
|
||||
///
|
||||
/// Automatically selects best options for radio questions
|
||||
/// and generates appropriate text for text questions
|
||||
Map<String, String> _buildFormData(
|
||||
Questionnaire questionnaire, {
|
||||
required Course course,
|
||||
int? totalCourses,
|
||||
}) {
|
||||
final formData = <String, String>{};
|
||||
|
||||
// Add required metadata fields (matching the correct request format)
|
||||
// Use Course data as primary source, fallback to questionnaire parsed data
|
||||
formData['optType'] = 'submit';
|
||||
formData['tokenValue'] = questionnaire.tokenValue;
|
||||
formData['questionnaireCode'] = course.questionnaireCode.isNotEmpty
|
||||
? course.questionnaireCode
|
||||
: questionnaire.questionnaireCode;
|
||||
formData['evaluationContent'] = course.evaluationContentNumber.isNotEmpty
|
||||
? course.evaluationContentNumber
|
||||
: questionnaire.evaluationContent;
|
||||
formData['evaluatedPeopleNumber'] = course.evaluatedPeopleNumber.isNotEmpty
|
||||
? course.evaluatedPeopleNumber
|
||||
: questionnaire.evaluatedPeopleNumber;
|
||||
formData['count'] = totalCourses?.toString() ?? '';
|
||||
|
||||
// Process radio questions - select best option for each
|
||||
for (final question in questionnaire.radioQuestions) {
|
||||
final selectedOption = _selectBestOption(question.options);
|
||||
formData[question.key] = selectedOption.value;
|
||||
}
|
||||
|
||||
// Process text questions - generate appropriate text
|
||||
for (final question in questionnaire.textQuestions) {
|
||||
final generatedText = _generateTextAnswer(question);
|
||||
formData[question.key] = generatedText;
|
||||
}
|
||||
|
||||
print('📝 Form data keys: ${formData.keys.join(", ")}');
|
||||
print('📝 Metadata fields:');
|
||||
print(' optType = ${formData['optType']}');
|
||||
print(' tokenValue = ${formData['tokenValue']}');
|
||||
print(' questionnaireCode = ${formData['questionnaireCode']}');
|
||||
print(' evaluationContent = ${formData['evaluationContent']}');
|
||||
print(' evaluatedPeopleNumber = ${formData['evaluatedPeopleNumber']}');
|
||||
print(' count = ${formData['count']}');
|
||||
print(
|
||||
'📝 Question answers (${questionnaire.radioQuestions.length} radio + ${questionnaire.textQuestions.length} text):',
|
||||
);
|
||||
formData.forEach((key, value) {
|
||||
if (key.startsWith('0000') || key == 'zgpj') {
|
||||
print(' $key = $value');
|
||||
}
|
||||
});
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
/// Select the best option from a list of radio options
|
||||
///
|
||||
/// Strategy: Prefer weight 1.0 options (80% probability)
|
||||
/// Otherwise select second highest weight option (20% probability)
|
||||
///
|
||||
/// [options] - List of available radio options
|
||||
/// Returns the selected [RadioOption]
|
||||
RadioOption _selectBestOption(List<RadioOption> options) {
|
||||
if (options.isEmpty) {
|
||||
throw ArgumentError('Options list cannot be empty');
|
||||
}
|
||||
|
||||
// Sort options by weight in descending order
|
||||
final sortedOptions = List<RadioOption>.from(options)
|
||||
..sort((a, b) => b.weight.compareTo(a.weight));
|
||||
|
||||
// Find options with weight 1.0
|
||||
final weight1Options = sortedOptions
|
||||
.where((opt) => opt.weight == 1.0)
|
||||
.toList();
|
||||
|
||||
// If there are weight 1.0 options
|
||||
if (weight1Options.isNotEmpty) {
|
||||
// 80% chance to select weight 1.0 option
|
||||
if (_random.nextDouble() < 0.8) {
|
||||
// If multiple weight 1.0 options, randomly select one
|
||||
return weight1Options[_random.nextInt(weight1Options.length)];
|
||||
}
|
||||
}
|
||||
|
||||
// 20% chance or no weight 1.0 options: select second highest weight
|
||||
if (sortedOptions.length > 1) {
|
||||
// Find second highest weight
|
||||
final highestWeight = sortedOptions[0].weight;
|
||||
final secondHighestOptions = sortedOptions
|
||||
.where((opt) => opt.weight < highestWeight)
|
||||
.toList();
|
||||
|
||||
if (secondHighestOptions.isNotEmpty) {
|
||||
// Get all options with second highest weight
|
||||
final secondHighestWeight = secondHighestOptions[0].weight;
|
||||
final secondHighestGroup = secondHighestOptions
|
||||
.where((opt) => opt.weight == secondHighestWeight)
|
||||
.toList();
|
||||
|
||||
return secondHighestGroup[_random.nextInt(secondHighestGroup.length)];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return highest weight option
|
||||
return sortedOptions[0];
|
||||
}
|
||||
|
||||
/// Generate text answer for a text question
|
||||
///
|
||||
/// Uses TextGenerator to create appropriate text based on question type
|
||||
/// Validates the generated text meets all requirements
|
||||
///
|
||||
/// [question] - The text question to answer
|
||||
/// Returns generated text string
|
||||
String _generateTextAnswer(TextQuestion question) {
|
||||
// Generate text based on question type
|
||||
String text = _textGenerator.generate(question.type);
|
||||
|
||||
// Validate the generated text
|
||||
// If validation fails, try again (up to 3 attempts)
|
||||
int attempts = 0;
|
||||
while (!_textGenerator.validate(text) && attempts < 3) {
|
||||
text = _textGenerator.generate(question.type);
|
||||
attempts++;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/// Batch evaluate all pending courses
|
||||
///
|
||||
/// [onProgress] - Callback function to report progress with detailed status
|
||||
/// Returns [BatchEvaluationResult] with statistics and individual results
|
||||
Future<BatchEvaluationResult> batchEvaluate({
|
||||
required Function(int current, int total, Course course, String status)
|
||||
onProgress,
|
||||
}) async {
|
||||
final startTime = DateTime.now();
|
||||
final results = <EvaluationResult>[];
|
||||
|
||||
try {
|
||||
// 1. Fetch all pending courses
|
||||
final courses = await _connection.fetchCourseList();
|
||||
|
||||
// Filter out already evaluated courses
|
||||
final pendingCourses = courses.where((c) => !c.isEvaluated).toList();
|
||||
final total = pendingCourses.length;
|
||||
|
||||
if (total == 0) {
|
||||
return BatchEvaluationResult(
|
||||
total: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
results: [],
|
||||
duration: DateTime.now().difference(startTime),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Evaluate each course
|
||||
for (int i = 0; i < pendingCourses.length; i++) {
|
||||
final course = pendingCourses[i];
|
||||
|
||||
// Evaluate the course with status updates
|
||||
final result = await evaluateCourse(
|
||||
course,
|
||||
totalCourses: total,
|
||||
onStatusChange: (status) {
|
||||
// Report progress with current course and status
|
||||
// Use i (not i+1) to show completed count, current course is still in progress
|
||||
onProgress(i, total, course, status);
|
||||
},
|
||||
);
|
||||
results.add(result);
|
||||
|
||||
// Check if evaluation failed
|
||||
if (!result.success) {
|
||||
// Report failure
|
||||
onProgress(i + 1, total, course, '评教失败,任务中断');
|
||||
|
||||
// Stop batch evaluation on first error
|
||||
final successCount = results.where((r) => r.success).length;
|
||||
final failedCount = results.where((r) => !r.success).length;
|
||||
final duration = DateTime.now().difference(startTime);
|
||||
|
||||
return BatchEvaluationResult(
|
||||
total: i + 1, // Only count evaluated courses
|
||||
success: successCount,
|
||||
failed: failedCount,
|
||||
results: results,
|
||||
duration: duration,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify evaluation by checking course list
|
||||
onProgress(i + 1, total, course, '验证评教结果');
|
||||
final updatedCourses = await _connection.fetchCourseList();
|
||||
final updatedCourse = updatedCourses.firstWhere(
|
||||
(c) => c.id == course.id,
|
||||
orElse: () => course,
|
||||
);
|
||||
|
||||
if (!updatedCourse.isEvaluated) {
|
||||
// Evaluation not confirmed, treat as failure
|
||||
results[results.length - 1] = EvaluationResult(
|
||||
course: course,
|
||||
success: false,
|
||||
errorMessage: '评教未生效,服务器未确认',
|
||||
);
|
||||
|
||||
onProgress(i + 1, total, course, '评教未生效,任务中断');
|
||||
|
||||
final successCount = results.where((r) => r.success).length;
|
||||
final failedCount = results.where((r) => !r.success).length;
|
||||
final duration = DateTime.now().difference(startTime);
|
||||
|
||||
return BatchEvaluationResult(
|
||||
total: i + 1,
|
||||
success: successCount,
|
||||
failed: failedCount,
|
||||
results: results,
|
||||
duration: duration,
|
||||
);
|
||||
}
|
||||
|
||||
// Report successful completion
|
||||
onProgress(i + 1, total, course, '评教完成');
|
||||
|
||||
// Small delay between evaluations to avoid overwhelming the server
|
||||
if (i < pendingCourses.length - 1) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Calculate statistics
|
||||
final successCount = results.where((r) => r.success).length;
|
||||
final failedCount = results.where((r) => !r.success).length;
|
||||
final duration = DateTime.now().difference(startTime);
|
||||
|
||||
return BatchEvaluationResult(
|
||||
total: total,
|
||||
success: successCount,
|
||||
failed: failedCount,
|
||||
results: results,
|
||||
duration: duration,
|
||||
);
|
||||
} catch (e) {
|
||||
// If batch evaluation fails completely, return partial results
|
||||
final successCount = results.where((r) => r.success).length;
|
||||
final failedCount = results.where((r) => !r.success).length;
|
||||
final duration = DateTime.now().difference(startTime);
|
||||
|
||||
return BatchEvaluationResult(
|
||||
total: results.length,
|
||||
success: successCount,
|
||||
failed: failedCount,
|
||||
results: results,
|
||||
duration: duration,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user