Files
AutoJudge-Flutter/lib/services/evaluation_service.dart
2025-11-13 09:14:49 +08:00

432 lines
14 KiB
Dart

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,
);
}
}
}