优化AI评分系统和答题体验

后端改进:
- 简答题AI评分改为必须成功,失败则返回错误提示
- 实现AI评分重试机制,最多重试5次,采用指数退避策略
- AI评分温度设置为0,确保评分结果更加一致和可预测
- 优化AI评分提示词,要求严格按照标准答案评分
- 添加详细的评分标准(85-100分/60-84分/40-59分/0-39分)
- 强化系统消息,要求评分客观、一致、可预测

前端改进:
- 添加自动下一题功能,答对后自动跳转(默认开启)
- 支持配置自动跳转延迟时间(1-10秒,默认2秒)
- 使用Popover组件优化设置UI,保持界面简洁
- 设置保存到localStorage,支持跨会话持久化

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yanlongqi 2025-11-05 17:12:55 +08:00
parent 24d098ae92
commit 2e526425a0
3 changed files with 151 additions and 37 deletions

View File

@ -175,21 +175,56 @@ func SubmitPracticeAnswer(c *gin.Context) {
// AI评分结果仅简答题
var aiGrading *models.AIGrading = nil
// 对简答题使用AI评分
// 对简答题使用AI评分必须成功失败重试最多5次
if question.Type == "short-answer" {
// 获取用户答案字符串
userAnswerStr, ok := submit.Answer.(string)
if ok {
if !ok {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "简答题答案格式错误",
})
return
}
// 获取标准答案字符串
standardAnswerStr, ok := correctAnswer.(string)
if ok {
// 调用AI评分服务
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "题目答案格式错误",
})
return
}
// 调用AI评分服务带重试机制
aiService := services.NewAIGradingService()
aiResult, err := aiService.GradeShortAnswer(question.Question, standardAnswerStr, userAnswerStr)
var aiResult *services.AIGradingResult
var err error
maxRetries := 5
for attempt := 1; attempt <= maxRetries; attempt++ {
log.Printf("AI评分尝试第 %d 次 (题目ID: %d)", attempt, question.ID)
aiResult, err = aiService.GradeShortAnswer(question.Question, standardAnswerStr, userAnswerStr)
if err == nil {
log.Printf("AI评分成功 (题目ID: %d, 得分: %.1f)", question.ID, aiResult.Score)
break
}
log.Printf("AI评分失败 (第 %d 次尝试): %v", attempt, err)
if attempt < maxRetries {
// 等待一小段时间后重试(指数退避)
time.Sleep(time.Second * time.Duration(attempt))
}
}
if err != nil {
// AI评分失败时记录日志但不影响主流程
log.Printf("AI评分失败: %v", err)
} else {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": fmt.Sprintf("AI评分服务暂时不可用已重试%d次请稍后再试", maxRetries),
})
return
}
// 使用AI的评分结果
correct = aiResult.IsCorrect
aiGrading = &models.AIGrading{
@ -198,9 +233,6 @@ func SubmitPracticeAnswer(c *gin.Context) {
Suggestion: aiResult.Suggestion,
}
}
}
}
}
// 记录用户答题历史
if uid, ok := userID.(uint); ok {

View File

@ -47,8 +47,8 @@ type AIGradingResult struct {
// standardAnswer: 标准答案
// userAnswer: 用户答案
func (s *AIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer string) (*AIGradingResult, error) {
// 构建评分提示词
prompt := fmt.Sprintf(`你是一位专业的阅卷老师对以下简答题进行评分
// 构建评分提示词(严格评分模式)
prompt := fmt.Sprintf(`你是一位严格的阅卷老师请严格按照标准答案对以下简答题进行评分
题目%s
@ -56,21 +56,29 @@ func (s *AIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer
学生答案%s
请按照以下要求进行评分
1. 给出一个0-100的分数
2. 判断答案是否正确60分及以上为正确
3. 给出简短的评语不超过50字
4. 如果答案不完善给出改进建议不超过50字如果答案很好可以为空
评分标准请严格遵守
1. 必须与标准答案进行逐项对比
2. 答案要点完全覆盖标准答案且表述准确的给85-100
3. 答案要点基本覆盖但有缺漏或表述不够准确的给60-84
4. 答案要点缺失较多或有明显错误的给40-59
5. 答案完全错误或离题的给0-39
6. 判断标准60分及以上为正确is_correct: true否则为错误is_correct: false
评分要求
1. 给出一个0-100的精确分数
2. 判断答案是否正确is_correct: 60分及以上为true否则为false
3. 给出简短的评语说明得分和失分原因不超过50字
4. 给出具体的改进建议如果答案满分可以为空否则必须指出具体改进方向不超过50字
请按照以下JSON格式返回结果
{
"score": 85,
"is_correct": true,
"feedback": "答案基本正确,要点全面",
"suggestion": "可以补充一些具体的例子"
"feedback": "答案覆盖了主要要点但XXX部分描述不够准确",
"suggestion": "建议补充XXX内容并完善XXX的描述"
}
注意只返回JSON格式的结果不要有其他内容`, question, standardAnswer, userAnswer)
注意只返回JSON格式的结果不要有其他内容必须严格对照标准答案评分不要过于宽松`, question, standardAnswer, userAnswer)
// 调用AI API
resp, err := s.client.CreateChatCompletion(
@ -80,14 +88,14 @@ func (s *AIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: "你是一位专业的阅卷老师,擅长对简答题进行公正、客观的评分。",
Content: "你是一位严格的阅卷老师,必须严格按照标准答案进行评分,不能过于宽松。你的评分标准是客观的、一致的、可预测的。",
},
{
Role: openai.ChatMessageRoleUser,
Content: prompt,
},
},
Temperature: 0.3, // 较低的温度以获得更稳定的评分结果
Temperature: 0, // 温度为0获得最确定、最一致的评分结果
},
)

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Button, message, Typography } from "antd";
import { ArrowLeftOutlined } from "@ant-design/icons";
import { Button, message, Typography, Switch, InputNumber, Popover, Space } from "antd";
import { ArrowLeftOutlined, SettingOutlined } from "@ant-design/icons";
import type { Question, AnswerResult } from "../types/question";
import * as questionApi from "../api/question";
import QuestionProgress from "../components/QuestionProgress";
@ -28,6 +28,33 @@ const QuestionPage: React.FC = () => {
const [wrongCount, setWrongCount] = useState(0);
const [showSummary, setShowSummary] = useState(false);
// 自动跳转开关(默认开启)
const [autoNext, setAutoNext] = useState(() => {
const saved = localStorage.getItem('autoNextEnabled');
return saved !== null ? saved === 'true' : true;
});
// 自动跳转延迟时间默认2秒
const [autoNextDelay, setAutoNextDelay] = useState(() => {
const saved = localStorage.getItem('autoNextDelay');
return saved !== null ? parseInt(saved, 10) : 2;
});
// 切换自动跳转开关
const toggleAutoNext = () => {
const newValue = !autoNext;
setAutoNext(newValue);
localStorage.setItem('autoNextEnabled', String(newValue));
};
// 修改自动跳转延迟时间
const handleDelayChange = (value: number | null) => {
if (value !== null && value >= 1 && value <= 10) {
setAutoNextDelay(value);
localStorage.setItem('autoNextDelay', String(value));
}
};
// 从localStorage恢复答题进度
const getStorageKey = () => {
const type = searchParams.get("type");
@ -180,13 +207,13 @@ const QuestionPage: React.FC = () => {
saveProgress(currentIndex, correctCount, newWrong);
}
// 如果答案正确1秒后自动进入下一题
if (res.data.correct) {
// 如果答案正确且开启自动跳转,根据设置的延迟时间后自动进入下一题
if (res.data.correct && autoNext) {
setAutoNextLoading(true);
setTimeout(() => {
setAutoNextLoading(false);
handleNext();
}, 1000);
}, autoNextDelay * 1000); // 将秒转换为毫秒
}
}
} catch (error) {
@ -279,6 +306,53 @@ const QuestionPage: React.FC = () => {
<span className={styles.statLabel}></span>
<span className={styles.statValue} style={{ color: '#ff4d4f' }}>{wrongCount}</span>
</div>
{/* 设置按钮 */}
<Popover
content={
<div style={{ width: 200 }}>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Switch
checked={autoNext}
onChange={toggleAutoNext}
size="small"
/>
<span style={{ fontSize: 14 }}>
{autoNext ? '已开启' : '已关闭'}
</span>
</div>
</div>
{autoNext && (
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Space>
<InputNumber
min={1}
max={10}
value={autoNextDelay}
onChange={handleDelayChange}
size="small"
style={{ width: 60 }}
/>
<span style={{ fontSize: 14 }}></span>
</Space>
</div>
)}
</Space>
</div>
}
title="答题设置"
trigger="click"
placement="bottomRight"
>
<Button
type="text"
icon={<SettingOutlined />}
style={{ marginLeft: 8 }}
/>
</Popover>
</div>
</div>