AnCao/internal/services/ai_grading.go
yanlongqi 24d098ae92 添加AI流式题目解析功能
实现了基于OpenAI的流式题目解析系统,支持答题后查看AI生成的详细解析。

主要功能:
- 流式输出:采用SSE (Server-Sent Events) 实现实时流式输出,用户可看到解析逐字生成
- Markdown渲染:使用react-markdown渲染解析内容,支持标题、列表、代码块等格式
- 智能提示词:根据题目类型(选择题/填空题/判断题等)动态调整提示词
- 选择题优化:对选择题提供逐项分析和记忆口诀
- 重新生成:支持重新生成解析,temperature设为0确保输出一致性
- 优化加载:加载指示器显示在内容下方,不遮挡流式输出

技术实现:
- 后端:新增ExplainQuestionStream方法支持流式响应
- 前端:使用ReadableStream API接收SSE流式数据
- UI:优化加载状态显示,避免阻塞内容展示
- 清理:删除不再使用的scripts脚本文件

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 16:04:07 +08:00

361 lines
9.5 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. 给出一个0-100的分数
2. 判断答案是否正确60分及以上为正确
3. 给出简短的评语不超过50字
4. 如果答案不完善给出改进建议不超过50字如果答案很好可以为空
请按照以下JSON格式返回结果
{
"score": 85,
"is_correct": true,
"feedback": "答案基本正确,要点全面",
"suggestion": "可以补充一些具体的例子"
}
注意只返回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.3, // 较低的温度以获得更稳定的评分结果
},
)
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]
}