实现了基于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>
361 lines
9.5 KiB
Go
361 lines
9.5 KiB
Go
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]
|
||
}
|