AnCao/internal/services/ai_grading.go
yanlongqi 2e526425a0 优化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>
2025-11-05 17:12:55 +08:00

369 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package services
import (
"ankao/pkg/config"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/sashabaranov/go-openai"
)
// AIGradingService AI评分服务
type AIGradingService struct {
client *openai.Client
config *config.AIConfig
}
// NewAIGradingService 创建AI评分服务实例
func NewAIGradingService() *AIGradingService {
cfg := config.GetAIConfig()
// 创建OpenAI客户端配置
clientConfig := openai.DefaultConfig(cfg.APIKey)
clientConfig.BaseURL = cfg.BaseURL + "/v1" // 标准OpenAI API格式需要/v1后缀
client := openai.NewClientWithConfig(clientConfig)
return &AIGradingService{
client: client,
config: cfg,
}
}
// AIGradingResult AI评分结果
type AIGradingResult struct {
Score float64 `json:"score"` // 得分 (0-100)
IsCorrect bool `json:"is_correct"` // 是否正确 (Score >= 60 视为正确)
Feedback string `json:"feedback"` // 评语
Suggestion string `json:"suggestion"` // 改进建议
}
// GradeShortAnswer 对简答题进行AI评分
// question: 题目内容
// standardAnswer: 标准答案
// userAnswer: 用户答案
func (s *AIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer string) (*AIGradingResult, error) {
// 构建评分提示词(严格评分模式)
prompt := fmt.Sprintf(`你是一位严格的阅卷老师,请严格按照标准答案对以下简答题进行评分。
题目:%s
标准答案:%s
学生答案:%s
评分标准(请严格遵守):
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": "答案覆盖了主要要点但XXX部分描述不够准确",
"suggestion": "建议补充XXX内容并完善XXX的描述"
}
注意只返回JSON格式的结果不要有其他内容。必须严格对照标准答案评分不要过于宽松。`, question, standardAnswer, userAnswer)
// 调用AI API
resp, err := s.client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: s.config.Model,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: "你是一位严格的阅卷老师,必须严格按照标准答案进行评分,不能过于宽松。你的评分标准是客观的、一致的、可预测的。",
},
{
Role: openai.ChatMessageRoleUser,
Content: prompt,
},
},
Temperature: 0, // 温度为0获得最确定、最一致的评分结果
},
)
if err != nil {
return nil, fmt.Errorf("AI评分失败: %w", err)
}
if len(resp.Choices) == 0 {
return nil, fmt.Errorf("AI未返回评分结果")
}
// 解析AI返回的JSON结果
content := resp.Choices[0].Message.Content
var result AIGradingResult
if err := parseAIResponse(content, &result); err != nil {
return nil, fmt.Errorf("解析AI响应失败: %w", err)
}
return &result, nil
}
// parseAIResponse 解析AI返回的JSON响应
func parseAIResponse(content string, result interface{}) error {
// 移除可能的markdown代码块标记
jsonStr := removeMarkdownCodeBlock(content)
// 使用json包解析
if err := json.Unmarshal([]byte(jsonStr), result); err != nil {
return fmt.Errorf("JSON解析失败: %w, 原始内容: %s", err, content)
}
return nil
}
// AIExplanationResult AI解析结果
type AIExplanationResult struct {
Explanation string `json:"explanation"` // 题目解析
}
// ExplainQuestionStream 生成题目解析(流式输出)
// writer: HTTP响应写入器
// question: 题目内容
// standardAnswer: 标准答案
// questionType: 题目类型
func (s *AIGradingService) ExplainQuestionStream(writer http.ResponseWriter, question, standardAnswer, questionType string) error {
// 构建解析提示词直接输出不使用JSON格式
prompt := fmt.Sprintf(`你是一位严谨、专业的老师,请对以下题目进行详细解析。
题目类型:%s
题目内容:%s
标准答案:%s
请提供一个详细的解析,要求:
1. **必须实事求是**:只基于题目内容和标准答案进行解析,不要添加题目中没有的信息
2. **不要胡编乱造**:如果某些信息不确定或题目没有提供,请如实说明,不要编造
3. **使用Markdown格式**使用标题、列表、加粗等markdown语法使内容更清晰易读
解析内容要求:
- **知识点**:说明题目考查的核心知识点
- **解题思路**:提供清晰的解题步骤和方法
%s
示例输出格式:
## 知识点
题目考查的是...
## 解题思路
1. 首先分析...
2. 然后判断...
%s
## 总结
%s
请使用markdown格式输出解析内容。`,
questionType,
question,
standardAnswer,
// 根据题目类型添加特定要求
func() string {
if questionType == "single-selection" || questionType == "multiple-selection" {
return `- **选项分析**:对于选择题,必须逐项分析每个选项的对错及原因
- **记忆口诀**:如果适用,提供便于记忆的口诀或技巧`
}
return "- **答案解析**:详细说明为什么这个答案是正确的"
}(),
// 根据题目类型添加示例格式
func() string {
if questionType == "single-selection" || questionType == "multiple-selection" {
return `## 选项分析
- **A选项**[分析该选项]
- **B选项**[分析该选项]
- **C选项**[分析该选项]
- **D选项**[分析该选项]
## 正确答案
正确答案是... 因为...`
}
return `## 答案解析
正确答案是... 因为...`
}(),
// 根据题目类型添加总结要求
func() string {
if questionType == "single-selection" || questionType == "multiple-selection" {
return "对于选择题,可以提供记忆口诀或关键要点总结"
}
return "总结本题的关键要点和注意事项"
}(),
)
// 调用AI API流式
stream, err := s.client.CreateChatCompletionStream(
context.Background(),
openai.ChatCompletionRequest{
Model: s.config.Model,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: "你是一位严谨、专业的老师擅长深入浅出地讲解题目。你必须实事求是只基于题目和标准答案提供解析不编造任何不确定的信息。你使用Markdown格式输出让学生更容易理解。",
},
{
Role: openai.ChatMessageRoleUser,
Content: prompt,
},
},
Temperature: 0, // 温度为0获得最确定、最一致的输出
Stream: true,
},
)
if err != nil {
return fmt.Errorf("创建流式请求失败: %w", err)
}
defer stream.Close()
flusher, ok := writer.(http.Flusher)
if !ok {
return fmt.Errorf("响应写入器不支持Flush")
}
// 读取流式响应并发送给客户端
for {
response, err := stream.Recv()
if err == io.EOF {
// 流结束
fmt.Fprintf(writer, "data: [DONE]\n\n")
flusher.Flush()
break
}
if err != nil {
return fmt.Errorf("接收流式响应失败: %w", err)
}
// 获取增量内容
if len(response.Choices) > 0 {
delta := response.Choices[0].Delta.Content
if delta != "" {
// 使用SSE格式发送
fmt.Fprintf(writer, "data: %s\n\n", delta)
flusher.Flush()
}
}
}
return nil
}
// ExplainQuestion 生成题目解析
// question: 题目内容
// standardAnswer: 标准答案
// questionType: 题目类型
func (s *AIGradingService) ExplainQuestion(question, standardAnswer, questionType string) (*AIExplanationResult, error) {
// 构建解析提示词
prompt := fmt.Sprintf(`你是一位经验丰富的老师,请对以下题目进行详细解析。
题目类型:%s
题目内容:%s
标准答案:%s
请提供一个详细的解析,包括:
1. 题目考查的知识点
2. 解题思路和方法
3. 为什么选择这个答案
4. 相关的重要概念或注意事项
请按照以下JSON格式返回结果
{
"explanation": "这道题考查的是...(200字以内的详细解析)"
}
注意只返回JSON格式的结果不要有其他内容。`, questionType, question, standardAnswer)
// 调用AI API
resp, err := s.client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: s.config.Model,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: "你是一位经验丰富的老师,擅长深入浅出地讲解题目,帮助学生理解知识点。",
},
{
Role: openai.ChatMessageRoleUser,
Content: prompt,
},
},
Temperature: 0.7, // 中等温度以获得有创造性的解释
},
)
if err != nil {
return nil, fmt.Errorf("AI解析失败: %w", err)
}
if len(resp.Choices) == 0 {
return nil, fmt.Errorf("AI未返回解析结果")
}
// 解析AI返回的JSON结果
content := resp.Choices[0].Message.Content
var result AIExplanationResult
if err := parseAIResponse(content, &result); err != nil {
return nil, fmt.Errorf("解析AI响应失败: %w", err)
}
return &result, nil
}
// removeMarkdownCodeBlock 移除markdown代码块标记
func removeMarkdownCodeBlock(s string) string {
// 去除可能的```json和```标记
s = strings.TrimSpace(s)
// 移除开头的```json或```
if strings.HasPrefix(s, "```json") {
s = s[7:]
} else if strings.HasPrefix(s, "```") {
s = s[3:]
}
// 移除结尾的```
if strings.HasSuffix(s, "```") {
s = s[:len(s)-3]
}
s = strings.TrimSpace(s)
// 查找第一个{的位置
startIdx := strings.Index(s, "{")
if startIdx == -1 {
return s
}
// 查找最后一个}的位置
endIdx := strings.LastIndex(s, "}")
if endIdx == -1 || endIdx <= startIdx {
return s
}
return s[startIdx : endIdx+1]
}