432 lines
14 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|
|
}
|