From ea051e9380c9045623002fe379659326f2f2c64c Mon Sep 17 00:00:00 2001 From: yanlongqi Date: Wed, 5 Nov 2025 13:36:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0AI=E8=AF=84=E5=88=86=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=92=8C=E9=A2=98=E7=9B=AE=E5=88=97=E8=A1=A8=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: 1. AI智能评分系统 - 集成OpenAI兼容API进行简答题评分 - 提供分数、评语和改进建议 - 支持自定义AI服务配置(BaseURL、APIKey、Model) 2. 题目列表页面 - 展示所有题目和答案 - Tab标签页形式的题型筛选(选择题、多选题、判断题、填空题、简答题) - 关键词搜索功能(支持题目内容和编号搜索) - 填空题特殊渲染:****显示为下划线 - 判断题不显示选项,界面更简洁 3. UI优化 - 答题结果组件重构,支持AI评分显示 - 首页新增"题目列表"快速入口 - 响应式设计,适配移动端和PC端 技术改进: - 添加AI评分服务层(internal/services/ai_grading.go) - 扩展题目模型支持AI评分结果 - 更新配置管理支持AI服务配置 - 添加AI评分测试脚本和文档 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 119 ++++++++++- README.md | 42 +++- go.mod | 1 + go.sum | 2 + internal/handlers/practice_handler.go | 32 +++ internal/models/practice_question.go | 8 + internal/services/ai_grading.go | 156 ++++++++++++++ pkg/config/config.go | 21 ++ test_ai_grading.md | 204 ++++++++++++++++++ test_ai_grading.ps1 | 107 ++++++++++ test_ai_grading.sh | 59 ++++++ web/src/App.tsx | 2 + web/src/components/AnswerResult.tsx | 162 +++++++++++--- web/src/pages/Home.tsx | 19 ++ web/src/pages/QuestionList.module.less | 133 ++++++++++++ web/src/pages/QuestionList.tsx | 283 +++++++++++++++++++++++++ web/src/types/question.ts | 9 + 17 files changed, 1321 insertions(+), 38 deletions(-) create mode 100644 internal/services/ai_grading.go create mode 100644 test_ai_grading.md create mode 100644 test_ai_grading.ps1 create mode 100644 test_ai_grading.sh create mode 100644 web/src/pages/QuestionList.module.less create mode 100644 web/src/pages/QuestionList.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 776fa97..f62a357 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,8 +62,10 @@ func MiddlewareName() gin.HandlerFunc { - **internal/models/** - 数据模型 - [user.go](internal/models/user.go) - 用户模型 - [practice_question.go](internal/models/practice_question.go) - 练习题模型 +- **internal/services/** - 业务服务 + - [ai_grading.go](internal/services/ai_grading.go) - AI评分服务 - **internal/database/** - 数据库连接和初始化 -- **pkg/config/** - 配置管理(数据库配置等) +- **pkg/config/** - 配置管理(数据库配置、AI配置等) - **scripts/** - 工具脚本 - [import_questions.go](scripts/import_questions.go) - 题目数据导入脚本 @@ -119,6 +121,7 @@ go test -v ./... - **框架**: 使用 Gin v1.11.0 - **ORM**: 使用 GORM v1.31.1 - **数据库**: PostgreSQL (配置在 [pkg/config/config.go](pkg/config/config.go)) +- **AI服务**: 使用 go-openai SDK v1.41.2,配置在 [pkg/config/config.go](pkg/config/config.go) - **服务器端口**: :8080 (在 [main.go:42](main.go#L42) 中配置) - **处理器签名**: 所有处理器使用 `func(c *gin.Context)` 模式 - **JSON 响应**: 使用 `c.JSON()` 方法配合 `gin.H{}` 或结构体 @@ -126,6 +129,7 @@ go test -v ./... - **路由注册**: 路由在 [main.go](main.go) 中使用 `r.GET()`、`r.POST()` 等注册 - **中间件**: 使用 `r.Use()` 全局应用或通过路由分组应用到特定路由 - **密码加密**: 使用 bcrypt 加密存储用户密码 +- **AI评分**: 简答题使用AI智能评分,提供分数、评语和改进建议 ## 添加新功能 @@ -180,6 +184,119 @@ go test -v ./... - 使用唯一索引防止重复导入 - 大批量导入建议使用事务提高性能 +## AI评分系统 + +项目集成了AI智能评分功能,专门用于对简答题进行评分和反馈。 + +### AI评分配置 + +AI服务配置位于 [pkg/config/config.go](pkg/config/config.go): + +```go +type AIConfig struct { + BaseURL string // AI API地址 + APIKey string // API密钥 + Model string // 使用的模型名称 +} +``` + +**配置方式**: +1. **默认配置**: 直接在代码中设置默认值 +2. **环境变量**: 通过环境变量覆盖默认配置 + ```bash + export AI_BASE_URL="https://ai.yuchat.top" + export AI_API_KEY="你的API密钥" + export AI_MODEL="deepseek-v3" + ``` + +### AI评分服务 + +AI评分服务实现位于 [internal/services/ai_grading.go](internal/services/ai_grading.go): + +**主要功能**: +- `NewAIGradingService()` - 创建AI评分服务实例 +- `GradeShortAnswer(question, standardAnswer, userAnswer)` - 对简答题进行AI评分 + +**返回结果**: +```go +type AIGradingResult struct { + Score float64 // 得分 (0-100) + IsCorrect bool // 是否正确 (Score >= 60 视为正确) + Feedback string // 评语 + Suggestion string // 改进建议 +} +``` + +### 集成方式 + +在 [practice_handler.go](internal/handlers/practice_handler.go) 的 `SubmitPracticeAnswer` 函数中: + +```go +// 对简答题使用AI评分 +if question.Type == "short-answer" { + aiService := services.NewAIGradingService() + aiResult, err := aiService.GradeShortAnswer( + question.Question, + standardAnswerStr, + userAnswerStr, + ) + // 使用AI评分结果 + correct = aiResult.IsCorrect + aiGrading = &models.AIGrading{ + Score: aiResult.Score, + Feedback: aiResult.Feedback, + Suggestion: aiResult.Suggestion, + } +} +``` + +### API响应格式 + +简答题提交后,响应中会包含 `ai_grading` 字段: + +```json +{ + "success": true, + "data": { + "correct": true, + "user_answer": "用户的答案", + "correct_answer": "标准答案", + "ai_grading": { + "score": 85, + "feedback": "答案基本正确,要点全面", + "suggestion": "可以补充一些具体的例子" + } + } +} +``` + +### 注意事项 + +- AI评分仅对 `short-answer` 类型的题目生效 +- 其他题型(填空题、判断题、选择题)仍使用传统的精确匹配方式 +- AI评分失败时不影响主流程,会记录日志并使用传统评分方式 +- 评分采用温度参数 0.3,确保评分结果稳定可靠 + +### 自定义AI评分提示词 + +如需调整评分标准,修改 [ai_grading.go](internal/services/ai_grading.go) 中的 `prompt` 变量: + +```go +prompt := fmt.Sprintf(`你是一位专业的阅卷老师,请对以下简答题进行评分。 + +题目:%s +标准答案:%s +学生答案:%s + +请按照以下要求进行评分: +1. 给出一个0-100的分数 +2. 判断答案是否正确(60分及以上为正确) +3. 给出简短的评语(不超过50字) +4. 如果答案不完善,给出改进建议(不超过50字) +... +`, question, standardAnswer, userAnswer) +``` + ## 前端开发规范 ### 包管理和开发 diff --git a/README.md b/README.md index 379528d..55f7453 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ yarn dev - `GET /api/practice/questions` - 获取练习题目列表 (支持分页和类型过滤) - `GET /api/practice/questions/random` - 获取随机练习题目 - `GET /api/practice/questions/:id` - 获取指定练习题目 -- `POST /api/practice/submit` - 提交练习答案 +- `POST /api/practice/submit` - 提交练习答案 (简答题自动AI评分) - `GET /api/practice/types` - 获取题型列表 #### 其他 @@ -105,6 +105,7 @@ go build -o bin/server.exe main.go - 用户登录系统(基于PostgreSQL数据库) - 题目练习功能 - 答题统计功能 +- **AI智能评分** - 简答题使用AI进行智能评分和反馈 - React + TypeScript + Vite 前端 - Ant Design Mobile UI组件库 @@ -158,7 +159,42 @@ go run scripts/import_questions.go - 判断题 (80道) - 单选题 (40道) - 多选题 (30道) -- 简答题 (6道) +- 简答题 (6道) - **支持AI智能评分** + +## AI评分配置 + +项目使用AI对简答题进行智能评分。AI服务配置位于 [pkg/config/config.go](pkg/config/config.go): + +### 默认配置 +- **API地址**: https://ai.yuchat.top +- **模型**: deepseek-v3 +- **评分方式**: 基于题目和标准答案,AI会给出分数(0-100)、评语和改进建议 + +### 环境变量配置(可选) +可以通过环境变量覆盖默认配置: +```bash +export AI_BASE_URL="你的API地址" +export AI_API_KEY="你的API密钥" +export AI_MODEL="你的模型名称" +``` + +### AI评分返回格式 +对简答题提交答案时,响应会包含 `ai_grading` 字段: +```json +{ + "success": true, + "data": { + "correct": true, + "user_answer": "用户的答案", + "correct_answer": "标准答案", + "ai_grading": { + "score": 85, + "feedback": "答案基本正确,要点全面", + "suggestion": "可以补充一些具体的例子" + } + } +} +``` ## 前端开发 @@ -203,6 +239,7 @@ yarn build - 练习题管理系统(236道练习题,5种题型) - 支持分页查询和题型筛选 - 随机题目推送功能 +- **AI智能评分系统** - 使用deepseek-v3对简答题进行智能评分和反馈 ### 前端特性 - React + TypeScript + Vite 技术栈 @@ -220,6 +257,7 @@ yarn build - **GORM** v1.31.1 - ORM框架 - **PostgreSQL** - 数据库 - **bcrypt** - 密码加密 +- **go-openai** v1.41.2 - OpenAI SDK (用于AI评分) ### 前端 - **React** 18 - UI框架 diff --git a/go.mod b/go.mod index 9521dfa..ef44eee 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.55.0 // indirect + github.com/sashabaranov/go-openai v1.41.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.uber.org/mock v0.6.0 // indirect diff --git a/go.sum b/go.sum index 7138b0a..8f07e88 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= +github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= +github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/internal/handlers/practice_handler.go b/internal/handlers/practice_handler.go index 7c1e5ab..13e0395 100644 --- a/internal/handlers/practice_handler.go +++ b/internal/handlers/practice_handler.go @@ -3,6 +3,7 @@ package handlers import ( "ankao/internal/database" "ankao/internal/models" + "ankao/internal/services" "encoding/json" "log" "net/http" @@ -170,6 +171,36 @@ func SubmitPracticeAnswer(c *gin.Context) { // 验证答案 correct := checkPracticeAnswer(question.Type, submit.Answer, correctAnswer) + // AI评分结果(仅简答题) + var aiGrading *models.AIGrading = nil + + // 对简答题使用AI评分 + if question.Type == "short-answer" { + // 获取用户答案字符串 + userAnswerStr, ok := submit.Answer.(string) + if ok { + // 获取标准答案字符串 + standardAnswerStr, ok := correctAnswer.(string) + if ok { + // 调用AI评分服务 + aiService := services.NewAIGradingService() + aiResult, err := aiService.GradeShortAnswer(question.Question, standardAnswerStr, userAnswerStr) + if err != nil { + // AI评分失败时记录日志,但不影响主流程 + log.Printf("AI评分失败: %v", err) + } else { + // 使用AI的评分结果 + correct = aiResult.IsCorrect + aiGrading = &models.AIGrading{ + Score: aiResult.Score, + Feedback: aiResult.Feedback, + Suggestion: aiResult.Suggestion, + } + } + } + } + } + // 记录用户答题历史 if uid, ok := userID.(uint); ok { record := models.UserAnswerRecord{ @@ -199,6 +230,7 @@ func SubmitPracticeAnswer(c *gin.Context) { Correct: correct, UserAnswer: submit.Answer, CorrectAnswer: correctAnswer, // 始终返回正确答案 + AIGrading: aiGrading, // AI评分结果(仅简答题有值) } c.JSON(http.StatusOK, gin.H{ diff --git a/internal/models/practice_question.go b/internal/models/practice_question.go index 9083a82..b885b56 100644 --- a/internal/models/practice_question.go +++ b/internal/models/practice_question.go @@ -40,4 +40,12 @@ type PracticeAnswerResult struct { Correct bool `json:"correct"` // 是否正确 UserAnswer interface{} `json:"user_answer"` // 用户答案 CorrectAnswer interface{} `json:"correct_answer,omitempty"` // 正确答案(仅在错误时返回) + AIGrading *AIGrading `json:"ai_grading,omitempty"` // AI评分结果(仅简答题) +} + +// AIGrading AI评分结果 +type AIGrading struct { + Score float64 `json:"score"` // 得分 (0-100) + Feedback string `json:"feedback"` // 评语 + Suggestion string `json:"suggestion"` // 改进建议 } diff --git a/internal/services/ai_grading.go b/internal/services/ai_grading.go new file mode 100644 index 0000000..4c2a5fc --- /dev/null +++ b/internal/services/ai_grading.go @@ -0,0 +1,156 @@ +package services + +import ( + "ankao/pkg/config" + "context" + "encoding/json" + "fmt" + "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 *AIGradingResult) 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 +} + +// 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] +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 04f5694..fa3d2d5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,6 +16,13 @@ type DatabaseConfig struct { SSLMode string } +// AIConfig AI服务配置结构 +type AIConfig struct { + BaseURL string + APIKey string + Model string +} + // GetDatabaseConfig 获取数据库配置 // 优先使用环境变量,如果没有设置则使用默认值 func GetDatabaseConfig() *DatabaseConfig { @@ -67,3 +74,17 @@ func (c *DatabaseConfig) GetDSN() string { c.SSLMode, ) } + +// GetAIConfig 获取AI服务配置 +// 优先使用环境变量,如果没有设置则使用默认值 +func GetAIConfig() *AIConfig { + baseURL := getEnv("AI_BASE_URL", "https://ai.yuchat.top") + apiKey := getEnv("AI_API_KEY", "sk-OKBmOpJx855juSOPU14cWG6Iz87tZQuv3Xg9PiaJYXdHoKcN") + model := getEnv("AI_MODEL", "deepseek-v3") + + return &AIConfig{ + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + } +} diff --git a/test_ai_grading.md b/test_ai_grading.md new file mode 100644 index 0000000..704e98d --- /dev/null +++ b/test_ai_grading.md @@ -0,0 +1,204 @@ +# AI 评分功能测试指南 + +## 测试前提 + +1. 确保后端服务器运行在 `http://localhost:8080` +2. 确保数据库中有简答题数据 +3. 需要先登录获取 token + +## 测试步骤 + +### 1. 登录获取 Token + +```bash +curl -X POST http://localhost:8080/api/login \ + -H "Content-Type: application/json" \ + -d '{ + "username": "你的用户名", + "password": "你的密码" + }' +``` + +返回示例: +```json +{ + "success": true, + "data": { + "token": "your-token-here", + "user": {...} + } +} +``` + +### 2. 获取简答题列表 + +```bash +curl -X GET "http://localhost:8080/api/practice/questions?type=short-answer" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +找到一个简答题的 ID(例如:123) + +### 3. 提交简答题答案测试 AI 评分 + +**测试用例 1:提交一个接近标准答案的回答** + +```bash +curl -X POST http://localhost:8080/api/practice/submit \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ + "question_id": 123, + "answer": "网络安全是保护计算机网络系统及其数据的完整性、保密性和可用性的措施" + }' +``` + +**预期返回**: +```json +{ + "success": true, + "data": { + "correct": true, + "user_answer": "网络安全是保护计算机网络系统及其数据的完整性、保密性和可用性的措施", + "correct_answer": "标准答案...", + "ai_grading": { + "score": 85, + "feedback": "答案基本正确,要点全面", + "suggestion": "可以补充一些具体的例子" + } + } +} +``` + +**测试用例 2:提交一个不完整的回答** + +```bash +curl -X POST http://localhost:8080/api/practice/submit \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ + "question_id": 123, + "answer": "保护网络安全" + }' +``` + +**预期返回**: +```json +{ + "success": true, + "data": { + "correct": false, + "user_answer": "保护网络安全", + "correct_answer": "标准答案...", + "ai_grading": { + "score": 45, + "feedback": "答案过于简单,缺少要点", + "suggestion": "需要补充完整性、保密性和可用性等关键概念" + } + } +} +``` + +## 验证要点 + +1. **响应结构**:检查响应中是否包含 `ai_grading` 字段 +2. **评分合理性**:AI 给出的分数是否在 0-100 之间 +3. **correct 字段**:分数 >= 60 时,correct 应该为 true +4. **反馈信息**:feedback 和 suggestion 是否有意义 +5. **非简答题**:对其他题型(填空题、判断题等),`ai_grading` 字段应该为 null + +## 使用 Postman 测试 + +1. 创建新请求,方法为 POST +2. URL:`http://localhost:8080/api/practice/submit` +3. Headers 添加: + - `Content-Type: application/json` + - `Authorization: Bearer YOUR_TOKEN` +4. Body 选择 raw 和 JSON,输入: + ```json + { + "question_id": 123, + "answer": "你的答案" + } + ``` +5. 点击 Send 发送请求 +6. 查看响应中的 `ai_grading` 字段 + +## 前端测试 + +如果你已经实现了前端界面: + +1. 登录系统 +2. 进入练习页面 +3. 筛选简答题类型 +4. 随机获取一道简答题 +5. 输入答案并提交 +6. 查看是否显示 AI 评分结果(分数、评语、建议) + +## 故障排查 + +### 问题 1:没有返回 ai_grading 字段 + +**可能原因**: +- 题目类型不是 `short-answer` +- 服务器未重启,使用的是旧代码 + +**解决方法**: +- 确认题目类型:`GET /api/practice/questions/:id` +- 重新编译并启动服务器: + ```bash + go build -o bin/server.exe main.go + ./bin/server.exe + ``` + +### 问题 2:AI 评分失败 + +**可能原因**: +- API 密钥无效 +- 网络连接问题 +- AI 服务不可用 + +**解决方法**: +- 检查后端日志中的错误信息 +- 验证 API 配置:查看 [pkg/config/config.go](pkg/config/config.go) +- 尝试直接调用 AI API 测试连通性 + +### 问题 3:评分结果不合理 + +**可能原因**: +- 提示词需要优化 +- AI 模型理解偏差 + +**解决方法**: +- 调整 [internal/services/ai_grading.go](internal/services/ai_grading.go) 中的提示词 +- 增加更详细的评分标准说明 + +## 测试数据示例 + +以下是一些可以用于测试的简答题和答案: + +### 题目:什么是网络安全? + +**优秀答案(应得高分)**: +"网络安全是指保护计算机网络系统及其数据免受未经授权的访问、使用、披露、破坏、修改或破坏的技术、策略和实践。它包括保护网络的完整性、保密性和可用性,确保数据在传输和存储过程中的安全。" + +**一般答案(应得中等分)**: +"网络安全是保护计算机和网络不被黑客攻击,保护数据安全。" + +**较差答案(应得低分)**: +"网络安全就是网络很安全。" + +## 日志查看 + +查看服务器日志中的 AI 评分相关信息: + +```bash +# Windows (如果使用 PowerShell) +Get-Content .\logs\server.log -Wait + +# 或直接查看控制台输出 +``` + +关注以下日志: +- `AI评分失败: ...` - AI 评分出错 +- 没有错误日志 - AI 评分成功 diff --git a/test_ai_grading.ps1 b/test_ai_grading.ps1 new file mode 100644 index 0000000..aa2a7af --- /dev/null +++ b/test_ai_grading.ps1 @@ -0,0 +1,107 @@ +# AI评分功能测试脚本 (PowerShell版本) +# 请先登录获取token,并将token替换下面的$Token变量 + +# 配置 +$BaseUrl = "http://localhost:8080" +$Token = "YOUR_TOKEN" # 请替换为你的实际token +$QuestionId = 465 # 简答题ID(第一道题) + +Write-Host "=========================================" -ForegroundColor Green +Write-Host "AI评分功能测试" -ForegroundColor Green +Write-Host "=========================================" -ForegroundColor Green +Write-Host "" + +# 测试1: 提交一个完整的答案 +Write-Host "测试1: 提交完整答案(应该得高分)" -ForegroundColor Yellow +Write-Host "-----------------------------------------" -ForegroundColor Yellow + +$Body1 = @{ + question_id = 465 + answer = "《中华人民共和国保守国家秘密法》第四十八条列举了十二种违法行为,主要包括:非法获取、持有国家秘密载体;买卖、转送或私自销毁秘密载体;通过无保密措施渠道传递秘密;未经批准携带秘密载体出境;非法复制、记录、存储国家秘密;在私人交往中涉及秘密;在互联网传递秘密;将涉密计算机接入公网;在涉密系统与公网间交换信息;使用非涉密设备处理秘密;擅自修改安全程序;以及将退出使用的涉密设备改作他用等。" +} | ConvertTo-Json + +$Headers = @{ + "Content-Type" = "application/json" + "Authorization" = "Bearer $Token" +} + +try { + $Response1 = Invoke-RestMethod -Uri "$BaseUrl/api/practice/submit" -Method Post -Headers $Headers -Body $Body1 + Write-Host "响应结果:" -ForegroundColor Cyan + $Response1 | ConvertTo-Json -Depth 10 | Write-Host + + if ($Response1.data.ai_grading) { + Write-Host "" + Write-Host "AI评分结果:" -ForegroundColor Green + Write-Host " 分数: $($Response1.data.ai_grading.score)" -ForegroundColor Green + Write-Host " 是否正确: $($Response1.data.correct)" -ForegroundColor Green + Write-Host " 评语: $($Response1.data.ai_grading.feedback)" -ForegroundColor Green + Write-Host " 建议: $($Response1.data.ai_grading.suggestion)" -ForegroundColor Green + } +} catch { + Write-Host "错误: $_" -ForegroundColor Red +} + +Write-Host "" +Write-Host "" + +# 测试2: 提交一个简短的答案 +Write-Host "测试2: 提交简短答案(应该得中等分数)" -ForegroundColor Yellow +Write-Host "-----------------------------------------" -ForegroundColor Yellow + +$Body2 = @{ + question_id = 465 + answer = "主要包括非法获取国家秘密、买卖秘密载体、通过互联网传递秘密、将涉密设备接入公网等违法行为。" +} | ConvertTo-Json + +try { + $Response2 = Invoke-RestMethod -Uri "$BaseUrl/api/practice/submit" -Method Post -Headers $Headers -Body $Body2 + Write-Host "响应结果:" -ForegroundColor Cyan + $Response2 | ConvertTo-Json -Depth 10 | Write-Host + + if ($Response2.data.ai_grading) { + Write-Host "" + Write-Host "AI评分结果:" -ForegroundColor Green + Write-Host " 分数: $($Response2.data.ai_grading.score)" -ForegroundColor Green + Write-Host " 是否正确: $($Response2.data.correct)" -ForegroundColor Green + Write-Host " 评语: $($Response2.data.ai_grading.feedback)" -ForegroundColor Green + Write-Host " 建议: $($Response2.data.ai_grading.suggestion)" -ForegroundColor Green + } +} catch { + Write-Host "错误: $_" -ForegroundColor Red +} + +Write-Host "" +Write-Host "" + +# 测试3: 提交一个不完整的答案 +Write-Host "测试3: 提交不完整答案(应该得低分)" -ForegroundColor Yellow +Write-Host "-----------------------------------------" -ForegroundColor Yellow + +$Body3 = @{ + question_id = 465 + answer = "不能泄露国家秘密,不能在网上传播秘密信息。" +} | ConvertTo-Json + +try { + $Response3 = Invoke-RestMethod -Uri "$BaseUrl/api/practice/submit" -Method Post -Headers $Headers -Body $Body3 + Write-Host "响应结果:" -ForegroundColor Cyan + $Response3 | ConvertTo-Json -Depth 10 | Write-Host + + if ($Response3.data.ai_grading) { + Write-Host "" + Write-Host "AI评分结果:" -ForegroundColor Green + Write-Host " 分数: $($Response3.data.ai_grading.score)" -ForegroundColor Green + Write-Host " 是否正确: $($Response3.data.correct)" -ForegroundColor Green + Write-Host " 评语: $($Response3.data.ai_grading.feedback)" -ForegroundColor Green + Write-Host " 建议: $($Response3.data.ai_grading.suggestion)" -ForegroundColor Green + } +} catch { + Write-Host "错误: $_" -ForegroundColor Red +} + +Write-Host "" +Write-Host "" +Write-Host "=========================================" -ForegroundColor Green +Write-Host "测试完成" -ForegroundColor Green +Write-Host "=========================================" -ForegroundColor Green diff --git a/test_ai_grading.sh b/test_ai_grading.sh new file mode 100644 index 0000000..8aa5a29 --- /dev/null +++ b/test_ai_grading.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# AI评分功能测试脚本 +# 请先登录获取token,并将token替换下面的YOUR_TOKEN + +# 配置 +BASE_URL="http://localhost:8080" +TOKEN="YOUR_TOKEN" # 请替换为你的实际token +QUESTION_ID=465 # 简答题ID(第一道题) + +echo "=========================================" +echo "AI评分功能测试" +echo "=========================================" +echo "" + +# 测试1: 提交一个完整的答案 +echo "测试1: 提交完整答案(应该得高分)" +echo "-----------------------------------------" +curl -X POST "${BASE_URL}/api/practice/submit" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${TOKEN}" \ + -d '{ + "question_id": 465, + "answer": "《中华人民共和国保守国家秘密法》第四十八条列举了十二种违法行为,主要包括:非法获取、持有国家秘密载体;买卖、转送或私自销毁秘密载体;通过无保密措施渠道传递秘密;未经批准携带秘密载体出境;非法复制、记录、存储国家秘密;在私人交往中涉及秘密;在互联网传递秘密;将涉密计算机接入公网;在涉密系统与公网间交换信息;使用非涉密设备处理秘密;擅自修改安全程序;以及将退出使用的涉密设备改作他用等。" + }' | python -m json.tool + +echo "" +echo "" + +# 测试2: 提交一个简短的答案 +echo "测试2: 提交简短答案(应该得中等分数)" +echo "-----------------------------------------" +curl -X POST "${BASE_URL}/api/practice/submit" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${TOKEN}" \ + -d '{ + "question_id": 465, + "answer": "主要包括非法获取国家秘密、买卖秘密载体、通过互联网传递秘密、将涉密设备接入公网等违法行为。" + }' | python -m json.tool + +echo "" +echo "" + +# 测试3: 提交一个不完整的答案 +echo "测试3: 提交不完整答案(应该得低分)" +echo "-----------------------------------------" +curl -X POST "${BASE_URL}/api/practice/submit" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${TOKEN}" \ + -d '{ + "question_id": 465, + "answer": "不能泄露国家秘密,不能在网上传播秘密信息。" + }' | python -m json.tool + +echo "" +echo "" +echo "=========================================" +echo "测试完成" +echo "=========================================" diff --git a/web/src/App.tsx b/web/src/App.tsx index a3964c2..280f701 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import Home from './pages/Home' import About from './pages/About' import WrongQuestions from './pages/WrongQuestions' import QuestionManagement from './pages/QuestionManagement' +import QuestionList from './pages/QuestionList' const App: React.FC = () => { return ( @@ -22,6 +23,7 @@ const App: React.FC = () => { {/* 不带TabBar的页面,但需要登录保护 */} } /> + } /> {/* 题库管理页面,需要管理员权限 */} = ({ return String(answer) } + // 获取评分等级颜色 + const getScoreColor = (score: number) => { + if (score >= 90) return '#52c41a' // 优秀 - 绿色 + if (score >= 80) return '#1890ff' // 良好 - 蓝色 + if (score >= 60) return '#faad14' // 及格 - 橙色 + return '#ff4d4f' // 不及格 - 红色 + } + + // 获取评分等级 + const getScoreLevel = (score: number) => { + if (score >= 90) return '优秀' + if (score >= 80) return '良好' + if (score >= 60) return '及格' + return '不及格' + } + return ( - : } - message={ -
- {answerResult.correct ? '回答正确!' : '回答错误'} -
- } - description={ -
-
- 你的答案: - - {formatAnswer(selectedAnswer)} - +
+ : } + message={ +
+ {answerResult.correct ? '回答正确!' : '回答错误'}
-
- - 正确答案: - - - {formatAnswer( - answerResult.correct_answer || (answerResult.correct ? selectedAnswer : '暂无') - )} - + } + description={ +
+
+ 你的答案: + + {formatAnswer(answerResult.user_answer || selectedAnswer)} + +
+
+ + 正确答案: + + + {formatAnswer( + answerResult.correct_answer || (answerResult.correct ? selectedAnswer : '暂无') + )} + +
+ {answerResult.explanation && ( +
+ 解析: +
{answerResult.explanation}
+
+ )}
- {answerResult.explanation && ( -
- 解析: -
{answerResult.explanation}
+ } + /> + + {/* AI评分结果 - 仅简答题显示 */} + {answerResult.ai_grading && ( + + + AI智能评分 + + } + > + {/* 分数和进度条 */} +
+ +
+ 得分 +
+ + {answerResult.ai_grading.score} + + / 100 +
+
+
+ `${getScoreLevel(percent || 0)}`} + /> +
+
+
+ + {/* 评语 */} + {answerResult.ai_grading.feedback && ( +
+ + +
+ 评语: + + {answerResult.ai_grading.feedback} + +
+
)} -
- } - style={{ marginTop: 20 }} - /> + + {/* 改进建议 */} + {answerResult.ai_grading.suggestion && ( +
+ + +
+ 改进建议: + + {answerResult.ai_grading.suggestion} + +
+
+
+ )} + + )} +
) } diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index a0f2fb4..4648272 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -11,6 +11,7 @@ import { UserOutlined, LogoutOutlined, SettingOutlined, + UnorderedListOutlined as ListOutlined, } from '@ant-design/icons' import * as questionApi from '../api/question' import type { Statistics } from '../types/question' @@ -275,6 +276,24 @@ const Home: React.FC = () => { + + navigate('/question-list')} + > + +
+ +
+
+ 题目列表 + 查看所有题目和答案 +
+
+
+ + {/* 仅 yanlongqi 用户显示题库管理 */} {userInfo?.username === 'yanlongqi' && ( diff --git a/web/src/pages/QuestionList.module.less b/web/src/pages/QuestionList.module.less new file mode 100644 index 0000000..2880851 --- /dev/null +++ b/web/src/pages/QuestionList.module.less @@ -0,0 +1,133 @@ +.container { + min-height: 100vh; + background-color: #f5f5f5; + padding: 16px; +} + +.header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.backButton { + font-size: 16px; +} + +.title { + margin: 0 !important; + flex: 1; +} + +.filterCard { + margin-bottom: 16px; +} + +.searchInput { + width: 100%; +} + +.tabsCard { + margin-bottom: 16px; + + :global { + .ant-tabs-nav { + margin-bottom: 0; + } + } +} + +.questionCard { + margin-bottom: 16px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } +} + +.questionHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.questionId { + font-size: 12px; +} + +.questionNumber { + font-size: 13px; +} + +.questionContent { + margin-bottom: 16px; +} + +.contentText { + font-size: 15px; + line-height: 1.6; + margin: 0; + color: #262626; +} + +.blank { + display: inline-block; + border-bottom: 2px solid #1677ff; + min-width: 60px; + margin: 0 4px; + color: transparent; + user-select: none; +} + +.options { + background-color: #fafafa; + border-radius: 6px; + padding: 12px; + margin-bottom: 16px; +} + +.option { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 0; + + &:not(:last-child) { + border-bottom: 1px solid #f0f0f0; + } +} + +.answerSection { + background-color: #f6ffed; + border: 1px solid #b7eb8f; + border-radius: 6px; + padding: 12px; +} + +.answerText { + margin: 8px 0 0 0; + font-size: 14px; + color: #262626; + white-space: pre-wrap; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .container { + padding: 12px; + } + + .questionHeader { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .questionNumber { + align-self: flex-end; + } +} diff --git a/web/src/pages/QuestionList.tsx b/web/src/pages/QuestionList.tsx new file mode 100644 index 0000000..6e65300 --- /dev/null +++ b/web/src/pages/QuestionList.tsx @@ -0,0 +1,283 @@ +import React, { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Card, + Tabs, + List, + Tag, + Typography, + Space, + Divider, + Button, + Spin, + message, + Input, +} from 'antd' +import { + BookOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + EditOutlined, + FileTextOutlined, + UnorderedListOutlined, + SearchOutlined, + ArrowLeftOutlined, +} from '@ant-design/icons' +import * as questionApi from '../api/question' +import type { Question } from '../types/question' +import styles from './QuestionList.module.less' + +const { Title, Text, Paragraph } = Typography + +// 题型配置 +const questionTypeConfig: Record = { + 'multiple-choice': { label: '选择题', icon: , color: '#1677ff' }, + 'multiple-selection': { label: '多选题', icon: , color: '#52c41a' }, + 'true-false': { label: '判断题', icon: , color: '#fa8c16' }, + 'fill-in-blank': { label: '填空题', icon: , color: '#722ed1' }, + 'short-answer': { label: '简答题', icon: , color: '#eb2f96' }, +} + +const QuestionList: React.FC = () => { + const navigate = useNavigate() + const [loading, setLoading] = useState(false) + const [questions, setQuestions] = useState([]) + const [filteredQuestions, setFilteredQuestions] = useState([]) + const [selectedType, setSelectedType] = useState('all') + const [searchKeyword, setSearchKeyword] = useState('') + + // 加载题目列表 + const loadQuestions = async () => { + setLoading(true) + try { + const res = await questionApi.getQuestions({}) + if (res.success && res.data) { + setQuestions(res.data) + setFilteredQuestions(res.data) + } + } catch (error) { + message.error('加载题目列表失败') + console.error('加载题目失败:', error) + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadQuestions() + }, []) + + // 筛选题目 + useEffect(() => { + let filtered = questions + + // 按题型筛选 + if (selectedType !== 'all') { + filtered = filtered.filter(q => q.type === selectedType) + } + + // 按关键词搜索 + if (searchKeyword.trim()) { + const keyword = searchKeyword.trim().toLowerCase() + filtered = filtered.filter(q => + q.content.toLowerCase().includes(keyword) || + q.question_id.toLowerCase().includes(keyword) + ) + } + + setFilteredQuestions(filtered) + }, [selectedType, searchKeyword, questions]) + + // 渲染填空题内容,将****替换为下划线 + const renderFillInBlankContent = (content: string): React.ReactNode => { + // 将 **** 替换为下划线样式 + const parts = content.split('****') + return ( + <> + {parts.map((part, index) => ( + + {part} + {index < parts.length - 1 && ( + ______ + )} + + ))} + + ) + } + + // 格式化答案显示 + const formatAnswer = (question: Question): string => { + const { type, answer, options } = question + + switch (type) { + case 'true-false': + return answer === true ? '正确' : '错误' + + case 'multiple-choice': + const option = options?.find(opt => opt.key === answer) + return option ? `${answer}. ${option.value}` : String(answer) + + case 'multiple-selection': + if (Array.isArray(answer)) { + return answer.map(key => { + const opt = options?.find(o => o.key === key) + return opt ? `${key}. ${opt.value}` : key + }).join('; ') + } + return String(answer) + + case 'fill-in-blank': + if (Array.isArray(answer)) { + return answer.map((a, i) => `空${i + 1}: ${a}`).join('; ') + } + return String(answer) + + case 'short-answer': + return String(answer) + + default: + return String(answer) + } + } + + // 渲染选项 + const renderOptions = (question: Question) => { + // 判断题不显示选项 + if (question.type === 'true-false') { + return null + } + + if (!question.options || question.options.length === 0) { + return null + } + + return ( +
+ {question.options.map(opt => ( +
+ {opt.key} + {opt.value} +
+ ))} +
+ ) + } + + return ( +
+ {/* 头部 */} +
+ + + <BookOutlined /> 题目列表 + +
+ + {/* 筛选栏 */} + + } + value={searchKeyword} + onChange={e => setSearchKeyword(e.target.value)} + allowClear + size="large" + className={styles.searchInput} + /> + + + {/* 题型选项卡 */} + + + 全部题型 + + ), + }, + ...Object.entries(questionTypeConfig).map(([type, config]) => ({ + key: type, + label: ( + + {config.icon} {config.label} + + ), + })), + ]} + /> + + + 共 {filteredQuestions.length} 道题目 + + + + {/* 题目列表 */} + + { + const typeConfig = questionTypeConfig[question.type] + return ( + + {/* 题目头部 */} +
+ + + {typeConfig?.icon} {typeConfig?.label || question.type} + + + #{question.question_id} + + + + 第 {index + 1} 题 + +
+ + {/* 题目内容 */} +
+ + {question.type === 'fill-in-blank' + ? renderFillInBlankContent(question.content) + : question.content + } + +
+ + {/* 选项 */} + {renderOptions(question)} + + {/* 答案 */} +
+ + + 正确答案: + + + {formatAnswer(question)} + +
+
+ ) + }} + locale={{ emptyText: '暂无题目' }} + /> +
+
+ ) +} + +export default QuestionList diff --git a/web/src/types/question.ts b/web/src/types/question.ts index 5a03328..2445fc1 100644 --- a/web/src/types/question.ts +++ b/web/src/types/question.ts @@ -24,11 +24,20 @@ export interface SubmitAnswer { answer: string | string[] | boolean } +// AI评分结果 +export interface AIGrading { + score: number // 得分 (0-100) + feedback: string // 评语 + suggestion: string // 改进建议 +} + // 答案结果 export interface AnswerResult { correct: boolean + user_answer: string | string[] | boolean correct_answer: string | string[] explanation?: string + ai_grading?: AIGrading // AI评分结果(仅简答题) } // 统计数据