diff --git a/README.md b/README.md index 648679a..dd546cd 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ docker compose up -d --build - `POST /api/assignments` `multipart/form-data`:`username`、`title`、`images`(可多张,按上传顺序) - `PUT /api/assignments/{id}` `multipart/form-data`:`username`、`title`、`images`(重新上传并重新批改) - `GET /api/assignments/{id}?username=xxx` +- `GET /api/assignments/{id}/corrected?username=xxx&index=0` - `DELETE /api/assignments/{id}?username=xxx` ## 说明 diff --git a/backend/Dockerfile b/backend/Dockerfile index 52fd614..271404c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,7 +10,7 @@ COPY . . RUN CGO_ENABLED=1 GOOS=linux go build -o /bin/homework-backend ./ FROM alpine:3.20 -RUN apk add --no-cache ca-certificates +RUN apk add --no-cache ca-certificates font-noto-cjk WORKDIR /app ENV DB_PATH=/data/homework.db diff --git a/backend/go.mod b/backend/go.mod index aa780b6..47908a3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,10 @@ module homework-backend go 1.22 require ( + github.com/fogleman/gg v1.3.0 github.com/go-chi/chi/v5 v5.1.0 github.com/mattn/go-sqlite3 v1.14.22 + golang.org/x/image v0.21.0 ) + +require github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 9fc5d04..2e30d35 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,4 +1,10 @@ +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= +golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= diff --git a/backend/main.go b/backend/main.go index 7b40af5..5a5e4d7 100644 --- a/backend/main.go +++ b/backend/main.go @@ -11,8 +11,13 @@ import ( "errors" "fmt" "html/template" + "image" + _ "image/gif" + "image/jpeg" + _ "image/png" "io" "log" + "math" "net/http" "os" "path/filepath" @@ -21,9 +26,11 @@ import ( "strings" "time" + "github.com/fogleman/gg" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" _ "github.com/mattn/go-sqlite3" + "golang.org/x/image/font/basicfont" ) //go:embed admin.html @@ -47,16 +54,18 @@ type Config struct { } type Assignment struct { - ID int64 `json:"id"` - Username string `json:"username"` - Title string `json:"title"` - OCRText string `json:"ocrText"` - Markdown string `json:"markdown"` - Feedback string `json:"feedback"` - Score int `json:"score"` - ImagePath string `json:"imagePath"` - ImagePaths []string `json:"imagePaths"` - CreatedAt time.Time `json:"createdAt"` + ID int64 `json:"id"` + Username string `json:"username"` + Title string `json:"title"` + OCRText string `json:"ocrText"` + Markdown string `json:"markdown"` + Feedback string `json:"feedback"` + Score int `json:"score"` + ImagePath string `json:"imagePath"` + ImagePaths []string `json:"imagePaths"` + CorrectedImagePath string `json:"correctedImagePath"` + CorrectedImagePaths []string `json:"correctedImagePaths"` + CreatedAt time.Time `json:"createdAt"` } type createAssignmentRequest struct { @@ -66,9 +75,10 @@ type createAssignmentRequest struct { } type LLMResult struct { - Markdown string `json:"markdown"` - Feedback string `json:"feedback"` - Score int `json:"score"` + Markdown string `json:"markdown"` + Feedback string `json:"feedback"` + Score int `json:"score"` + Annotations []string `json:"annotations"` } type LLMImage struct { @@ -123,9 +133,10 @@ func main() { r.Get("/assignments", handleListAssignments(db)) r.Get("/assignments/{id}", handleGetAssignment(db)) r.Get("/assignments/{id}/image", handleGetAssignmentImage(db, cfg.UploadDir)) + r.Get("/assignments/{id}/corrected", handleGetAssignmentCorrectedImage(db, cfg.UploadDir)) r.Post("/assignments", handleCreateAssignment(db, llmClient, cfg.UploadDir)) r.Put("/assignments/{id}", handleUpdateAssignment(db, llmClient, cfg.UploadDir)) - r.Delete("/assignments/{id}", handleDeleteAssignment(db)) + r.Delete("/assignments/{id}", handleDeleteAssignment(db, cfg.UploadDir)) }) router.Route("/backend", func(r chi.Router) { @@ -175,6 +186,7 @@ func initDB(path string) (*sql.DB, error) { feedback TEXT, score INTEGER, image_path TEXT, + corrected_image_path TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); err != nil { @@ -183,6 +195,9 @@ func initDB(path string) (*sql.DB, error) { if err := ensureColumn(db, "assignments", "image_path", "TEXT"); err != nil { return nil, err } + if err := ensureColumn(db, "assignments", "corrected_image_path", "TEXT"); err != nil { + return nil, err + } return db, nil } @@ -195,7 +210,7 @@ func handleListAssignments(db *sql.DB) http.HandlerFunc { } rows, err := db.Query(` - SELECT id, username, title, score, image_path, created_at + SELECT id, username, title, score, image_path, corrected_image_path, created_at FROM assignments WHERE username = ? ORDER BY created_at DESC @@ -210,11 +225,13 @@ func handleListAssignments(db *sql.DB) http.HandlerFunc { for rows.Next() { var a Assignment var rawImagePath string - if err := rows.Scan(&a.ID, &a.Username, &a.Title, &a.Score, &rawImagePath, &a.CreatedAt); err != nil { + var rawCorrectedPath string + if err := rows.Scan(&a.ID, &a.Username, &a.Title, &a.Score, &rawImagePath, &rawCorrectedPath, &a.CreatedAt); err != nil { writeError(w, http.StatusInternalServerError, "failed to read assignments") return } setImageFields(&a, rawImagePath) + setCorrectedImageFields(&a, rawCorrectedPath) assignments = append(assignments, a) } @@ -238,13 +255,14 @@ func handleGetAssignment(db *sql.DB) http.HandlerFunc { var a Assignment var rawImagePath string + var rawCorrectedPath string row := db.QueryRow(` - SELECT id, username, title, ocr_text, markdown, feedback, score, image_path, created_at + SELECT id, username, title, ocr_text, markdown, feedback, score, image_path, corrected_image_path, created_at FROM assignments WHERE id = ? AND username = ? LIMIT 1 `, id, username) - if err := row.Scan(&a.ID, &a.Username, &a.Title, &a.OCRText, &a.Markdown, &a.Feedback, &a.Score, &rawImagePath, &a.CreatedAt); err != nil { + if err := row.Scan(&a.ID, &a.Username, &a.Title, &a.OCRText, &a.Markdown, &a.Feedback, &a.Score, &rawImagePath, &rawCorrectedPath, &a.CreatedAt); err != nil { if errors.Is(err, sql.ErrNoRows) { writeError(w, http.StatusNotFound, "assignment not found") return @@ -253,6 +271,7 @@ func handleGetAssignment(db *sql.DB) http.HandlerFunc { return } setImageFields(&a, rawImagePath) + setCorrectedImageFields(&a, rawCorrectedPath) writeJSON(w, http.StatusOK, a) } @@ -335,6 +354,83 @@ func handleGetAssignmentImage(db *sql.DB, uploadDir string) http.HandlerFunc { } } +func handleGetAssignmentCorrectedImage(db *sql.DB, uploadDir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.URL.Query().Get("username")) + if username == "" { + writeError(w, http.StatusBadRequest, "username is required") + return + } + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid assignment id") + return + } + + var imagePath sql.NullString + row := db.QueryRow(` + SELECT corrected_image_path + FROM assignments + WHERE id = ? AND username = ? + LIMIT 1 + `, id, username) + if err := row.Scan(&imagePath); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "assignment not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to query image") + return + } + + if !imagePath.Valid || strings.TrimSpace(imagePath.String) == "" { + writeError(w, http.StatusNotFound, "image not found") + return + } + + paths := parseImagePaths(imagePath.String) + if len(paths) == 0 { + writeError(w, http.StatusNotFound, "image not found") + return + } + + index := 0 + if indexStr := r.URL.Query().Get("index"); indexStr != "" { + if parsed, err := strconv.Atoi(indexStr); err == nil { + index = parsed + } + } + if index < 0 || index >= len(paths) { + writeError(w, http.StatusNotFound, "image not found") + return + } + + absImage, err := filepath.Abs(paths[index]) + if err != nil { + writeError(w, http.StatusInternalServerError, "invalid image path") + return + } + absUpload, err := filepath.Abs(uploadDir) + if err != nil { + writeError(w, http.StatusInternalServerError, "invalid upload dir") + return + } + if !strings.HasPrefix(absImage, absUpload+string(os.PathSeparator)) && absImage != absUpload { + writeError(w, http.StatusForbidden, "invalid image path") + return + } + + if _, err := os.Stat(absImage); err != nil { + writeError(w, http.StatusNotFound, "image not found") + return + } + + w.Header().Set("Cache-Control", "private, max-age=3600") + http.ServeFile(w, r, absImage) + } +} + func handleCreateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { contentType := r.Header.Get("Content-Type") @@ -372,9 +468,9 @@ func handleCreateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.H } result, err := db.Exec(` - INSERT INTO assignments (username, title, ocr_text, markdown, feedback, score, image_path) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, req.Username, req.Title, req.OCRText, markdown, feedback, score, "[]") + INSERT INTO assignments (username, title, ocr_text, markdown, feedback, score, image_path, corrected_image_path) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, req.Username, req.Title, req.OCRText, markdown, feedback, score, "[]", "[]") if err != nil { writeError(w, http.StatusInternalServerError, "failed to save assignment") return @@ -382,16 +478,18 @@ func handleCreateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.H id, _ := result.LastInsertId() assignment := Assignment{ - ID: id, - Username: req.Username, - Title: req.Title, - OCRText: req.OCRText, - Markdown: markdown, - Feedback: feedback, - Score: score, - ImagePath: "", - ImagePaths: []string{}, - CreatedAt: time.Now(), + ID: id, + Username: req.Username, + Title: req.Title, + OCRText: req.OCRText, + Markdown: markdown, + Feedback: feedback, + Score: score, + ImagePath: "", + ImagePaths: []string{}, + CorrectedImagePath: "", + CorrectedImagePaths: []string{}, + CreatedAt: time.Now(), } writeJSON(w, http.StatusOK, assignment) @@ -430,13 +528,14 @@ func handleUpdateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.H var existingTitle string var createdAt time.Time var existingImagePath sql.NullString + var existingCorrectedPath sql.NullString row := db.QueryRow(` - SELECT title, created_at, image_path + SELECT title, created_at, image_path, corrected_image_path FROM assignments WHERE id = ? AND username = ? LIMIT 1 `, id, username) - if err := row.Scan(&existingTitle, &createdAt, &existingImagePath); err != nil { + if err := row.Scan(&existingTitle, &createdAt, &existingImagePath, &existingCorrectedPath); err != nil { if errors.Is(err, sql.ErrNoRows) { writeError(w, http.StatusNotFound, "assignment not found") return @@ -506,19 +605,31 @@ func handleUpdateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.H }) } - markdown, feedback, score, err := llm.FormatAndGradeFromImages(r.Context(), title, images) + llmResult, err := llm.FormatAndGradeFromImagesResult(r.Context(), title, images) if err != nil { writeError(w, http.StatusBadGateway, fmt.Sprintf("llm failed: %v", err)) return } + markdown := llmResult.Markdown + feedback := llmResult.Feedback + score := llmResult.Score + + annotations := buildAnnotations(llmResult, markdown, feedback, score) + correctedPaths, err := generateCorrectedImages(uploadDir, imagePaths, title, annotations) + if err != nil { + log.Printf("corrected image failed: %v", err) + correctedPaths = []string{} + } + imagePathsJSON, _ := json.Marshal(imagePaths) + correctedPathsJSON, _ := json.Marshal(correctedPaths) _, err = db.Exec(` UPDATE assignments - SET title = ?, ocr_text = ?, markdown = ?, feedback = ?, score = ?, image_path = ? + SET title = ?, ocr_text = ?, markdown = ?, feedback = ?, score = ?, image_path = ?, corrected_image_path = ? WHERE id = ? AND username = ? - `, title, "", markdown, feedback, score, string(imagePathsJSON), id, username) + `, title, "", markdown, feedback, score, string(imagePathsJSON), string(correctedPathsJSON), id, username) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update assignment") return @@ -527,18 +638,23 @@ func handleUpdateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.H if existingImagePath.Valid { cleanupImages(uploadDir, existingImagePath.String) } + if existingCorrectedPath.Valid { + cleanupImages(uploadDir, existingCorrectedPath.String) + } assignment := Assignment{ - ID: id, - Username: username, - Title: title, - OCRText: "", - Markdown: markdown, - Feedback: feedback, - Score: score, - ImagePath: firstPath(imagePaths), - ImagePaths: imagePaths, - CreatedAt: createdAt, + ID: id, + Username: username, + Title: title, + OCRText: "", + Markdown: markdown, + Feedback: feedback, + Score: score, + ImagePath: firstPath(imagePaths), + ImagePaths: imagePaths, + CorrectedImagePath: firstPath(correctedPaths), + CorrectedImagePaths: correctedPaths, + CreatedAt: createdAt, } writeJSON(w, http.StatusOK, assignment) @@ -615,18 +731,30 @@ func handleCreateAssignmentWithImage(db *sql.DB, llm *LLMClient, uploadDir strin }) } - markdown, feedback, score, err := llm.FormatAndGradeFromImages(r.Context(), title, images) + llmResult, err := llm.FormatAndGradeFromImagesResult(r.Context(), title, images) if err != nil { writeError(w, http.StatusBadGateway, fmt.Sprintf("llm failed: %v", err)) return } + markdown := llmResult.Markdown + feedback := llmResult.Feedback + score := llmResult.Score + + annotations := buildAnnotations(llmResult, markdown, feedback, score) + correctedPaths, err := generateCorrectedImages(uploadDir, imagePaths, title, annotations) + if err != nil { + log.Printf("corrected image failed: %v", err) + correctedPaths = []string{} + } + imagePathsJSON, _ := json.Marshal(imagePaths) + correctedPathsJSON, _ := json.Marshal(correctedPaths) result, err := db.Exec(` - INSERT INTO assignments (username, title, ocr_text, markdown, feedback, score, image_path) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, username, title, "", markdown, feedback, score, string(imagePathsJSON)) + INSERT INTO assignments (username, title, ocr_text, markdown, feedback, score, image_path, corrected_image_path) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, username, title, "", markdown, feedback, score, string(imagePathsJSON), string(correctedPathsJSON)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to save assignment") return @@ -634,22 +762,24 @@ func handleCreateAssignmentWithImage(db *sql.DB, llm *LLMClient, uploadDir strin id, _ := result.LastInsertId() assignment := Assignment{ - ID: id, - Username: username, - Title: title, - OCRText: "", - Markdown: markdown, - Feedback: feedback, - Score: score, - ImagePath: firstPath(imagePaths), - ImagePaths: imagePaths, - CreatedAt: time.Now(), + ID: id, + Username: username, + Title: title, + OCRText: "", + Markdown: markdown, + Feedback: feedback, + Score: score, + ImagePath: firstPath(imagePaths), + ImagePaths: imagePaths, + CorrectedImagePath: firstPath(correctedPaths), + CorrectedImagePaths: correctedPaths, + CreatedAt: time.Now(), } writeJSON(w, http.StatusOK, assignment) } -func handleDeleteAssignment(db *sql.DB) http.HandlerFunc { +func handleDeleteAssignment(db *sql.DB, uploadDir string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.URL.Query().Get("username")) if username == "" { @@ -663,6 +793,23 @@ func handleDeleteAssignment(db *sql.DB) http.HandlerFunc { return } + var imagePath sql.NullString + var correctedPath sql.NullString + row := db.QueryRow(` + SELECT image_path, corrected_image_path + FROM assignments + WHERE id = ? AND username = ? + LIMIT 1 + `, id, username) + if err := row.Scan(&imagePath, &correctedPath); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "assignment not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load assignment") + return + } + res, err := db.Exec(`DELETE FROM assignments WHERE id = ? AND username = ?`, id, username) if err != nil { writeError(w, http.StatusInternalServerError, "failed to delete assignment") @@ -674,6 +821,13 @@ func handleDeleteAssignment(db *sql.DB) http.HandlerFunc { return } + if imagePath.Valid { + cleanupImages(uploadDir, imagePath.String) + } + if correctedPath.Valid { + cleanupImages(uploadDir, correctedPath.String) + } + writeJSON(w, http.StatusOK, map[string]any{"deleted": id}) } } @@ -710,12 +864,12 @@ func handleAdminDashboard(db *sql.DB) http.HandlerFunc { } } -func (c *LLMClient) FormatAndGrade(ctx context.Context, title, ocrText string) (string, string, int, error) { +func (c *LLMClient) FormatAndGradeResult(ctx context.Context, title, ocrText string) (LLMResult, error) { if c.baseURL == "" || c.apiKey == "" { - return "", "", 0, errors.New("LLM_BASE_URL or LLM_API_KEY missing") + return LLMResult{}, errors.New("LLM_BASE_URL or LLM_API_KEY missing") } - systemPrompt := "你是一位严谨的阅卷老师。请把 OCR 文字整理成清晰的 Markdown,保持原有题号、分点、段落与格式。然后进行详细批改:对每一道题给出参考答案、解题过程拆解、学生答案摘录、正确性判定(正确/部分正确/错误)、正确答案对比说明、考察知识点说明。最后给出整体评价与改进建议。仅输出 JSON,字段为 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。" + systemPrompt := "你是一位严谨的阅卷老师。请把 OCR 文字整理成清晰的 Markdown,保持原有题号、分点、段落与格式。然后进行详细批改:对每一道题给出参考答案、解题过程拆解、学生答案摘录、正确性判定(正确/部分正确/错误)、正确答案对比说明、考察知识点说明。最后给出整体评价与改进建议。请额外输出 annotations 字段,数组,每条为简短批注(<=25字),用于叠加到作业图片。仅输出 JSON,字段为 markdown、feedback、score(0-100整数)、annotations。不要加代码块或多余文字。" userPrompt := fmt.Sprintf("标题: %s\n\nOCR文本:\n%s", title, ocrText) payload := map[string]any{ @@ -729,38 +883,45 @@ func (c *LLMClient) FormatAndGrade(ctx context.Context, title, ocrText string) ( content, err := c.chatCompletionWithRetry(ctx, payload) if err != nil { - return "", "", 0, err + return LLMResult{}, err } result := LLMResult{} if err := decodeLLMResult(content, &result); err != nil { markdown, feedback, score := fallbackFromContent(content, ocrText) - return markdown, feedback, score, nil + return LLMResult{Markdown: markdown, Feedback: feedback, Score: score}, nil } - markdown := strings.TrimSpace(result.Markdown) - feedback := strings.TrimSpace(result.Feedback) - score := result.Score - if markdown == "" { - markdown = ocrText + result.Markdown = strings.TrimSpace(result.Markdown) + result.Feedback = strings.TrimSpace(result.Feedback) + if result.Markdown == "" { + result.Markdown = ocrText } - return markdown, feedback, score, nil + return result, nil +} + +func (c *LLMClient) FormatAndGrade(ctx context.Context, title, ocrText string) (string, string, int, error) { + result, err := c.FormatAndGradeResult(ctx, title, ocrText) + if err != nil { + return "", "", 0, err + } + return result.Markdown, result.Feedback, result.Score, nil } func (c *LLMClient) FormatAndGradeFromImage(ctx context.Context, title, mimeType, imageBase64 string) (string, string, int, error) { return c.FormatAndGradeFromImages(ctx, title, []LLMImage{{MimeType: mimeType, Base64: imageBase64}}) } -func (c *LLMClient) FormatAndGradeFromImages(ctx context.Context, title string, images []LLMImage) (string, string, int, error) { +func (c *LLMClient) FormatAndGradeFromImagesResult(ctx context.Context, title string, images []LLMImage) (LLMResult, error) { if c.baseURL == "" || c.apiKey == "" { - return "", "", 0, errors.New("LLM_BASE_URL or LLM_API_KEY missing") + return LLMResult{}, errors.New("LLM_BASE_URL or LLM_API_KEY missing") } if len(images) == 0 { - return "", "", 0, errors.New("no images provided") + return LLMResult{}, errors.New("no images provided") } - systemPrompt := "你是一位严谨的阅卷老师。请根据作业图片识别内容,整理为清晰的 Markdown,保持题号、分点、段落与格式。然后进行详细批改:对每一道题给出参考答案、解题过程拆解、学生答案摘录、正确性判定(正确/部分正确/错误)、正确答案对比说明、考察知识点说明。最后给出整体评价与改进建议。若题目或答案无法识别,请明确标注“无法辨识”。仅输出 JSON,字段为 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。" + systemPrompt := "你是一位严谨的阅卷老师。请根据作业图片识别内容,整理为清晰的 Markdown,保持题号、分点、段落与格式。然后进行详细批改:对每一道题给出参考答案、解题过程拆解、学生答案摘录、正确性判定(正确/部分正确/错误)、正确答案对比说明、考察知识点说明。最后给出整体评价与改进建议。若题目或答案无法识别,请明确标注“无法辨识”。请额外输出 annotations 字段,数组,每条为简短批注(<=25字),用于叠加到作业图片。仅输出 JSON,字段为 markdown、feedback、score(0-100整数)、annotations。不要加代码块或多余文字。" userText := fmt.Sprintf("标题: %s\n以下图片按上传顺序依次为第1页、第2页……请综合批改。", title) contentParts := make([]map[string]any, 0, len(images)+1) @@ -785,23 +946,30 @@ func (c *LLMClient) FormatAndGradeFromImages(ctx context.Context, title string, content, err := c.chatCompletionWithRetry(ctx, payload) if err != nil { - return "", "", 0, err + return LLMResult{}, err } result := LLMResult{} if err := decodeLLMResult(content, &result); err != nil { markdown, feedback, score := fallbackFromContent(content, "未识别到有效内容。") - return markdown, feedback, score, nil + return LLMResult{Markdown: markdown, Feedback: feedback, Score: score}, nil } - markdown := strings.TrimSpace(result.Markdown) - feedback := strings.TrimSpace(result.Feedback) - score := result.Score - if markdown == "" { - markdown = "未识别到有效内容。" + result.Markdown = strings.TrimSpace(result.Markdown) + result.Feedback = strings.TrimSpace(result.Feedback) + if result.Markdown == "" { + result.Markdown = "未识别到有效内容。" } - return markdown, feedback, score, nil + return result, nil +} + +func (c *LLMClient) FormatAndGradeFromImages(ctx context.Context, title string, images []LLMImage) (string, string, int, error) { + result, err := c.FormatAndGradeFromImagesResult(ctx, title, images) + if err != nil { + return "", "", 0, err + } + return result.Markdown, result.Feedback, result.Score, nil } func (c *LLMClient) chatCompletionWithRetry(ctx context.Context, payload map[string]any) (string, error) { @@ -924,6 +1092,87 @@ func fallbackFromContent(content, defaultMarkdown string) (string, string, int) return markdown, "", score } +func buildAnnotations(result LLMResult, markdown, feedback string, score int) []string { + annotations := normalizeAnnotations(result.Annotations) + if len(annotations) == 0 { + annotations = deriveAnnotations(feedback) + } + if len(annotations) == 0 { + annotations = deriveAnnotations(markdown) + } + if len(annotations) == 0 { + annotations = []string{"已完成批改,请查看详细结果。"} + } + if score > 0 && !hasScoreAnnotation(annotations) { + annotations = append([]string{fmt.Sprintf("评分:%d", score)}, annotations...) + } + if len(annotations) > 8 { + annotations = append(annotations[:8], "更多细节请查看批改结果") + } + return annotations +} + +func normalizeAnnotations(raw []string) []string { + if len(raw) == 0 { + return nil + } + normalized := make([]string, 0, len(raw)) + for _, item := range raw { + clean := strings.TrimSpace(item) + if clean == "" { + continue + } + normalized = append(normalized, clean) + } + return normalized +} + +func hasScoreAnnotation(lines []string) bool { + for _, line := range lines { + if strings.Contains(line, "评分") || strings.Contains(strings.ToLower(line), "score") { + return true + } + } + return false +} + +func deriveAnnotations(text string) []string { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + parts := splitSentences(text) + lines := make([]string, 0, len(parts)) + for _, part := range parts { + clean := strings.TrimSpace(part) + if clean == "" { + continue + } + lines = append(lines, clean) + } + return lines +} + +func splitSentences(text string) []string { + var parts []string + var buf strings.Builder + for _, r := range []rune(text) { + if r == '\r' { + continue + } + buf.WriteRune(r) + switch r { + case '\n', '。', ';', ';', '!', '!', '?', '?': + parts = append(parts, buf.String()) + buf.Reset() + } + } + if buf.Len() > 0 { + parts = append(parts, buf.String()) + } + return parts +} + func ensureColumn(db *sql.DB, table, column, columnType string) error { rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s);", table)) if err != nil { @@ -955,6 +1204,9 @@ func parseImagePaths(raw string) []string { if raw == "" { return nil } + if raw == "null" { + return nil + } if strings.HasPrefix(raw, "[") { var paths []string if json.Unmarshal([]byte(raw), &paths) == nil { @@ -970,6 +1222,12 @@ func setImageFields(a *Assignment, raw string) { a.ImagePath = firstPath(paths) } +func setCorrectedImageFields(a *Assignment, raw string) { + paths := parseImagePaths(raw) + a.CorrectedImagePaths = paths + a.CorrectedImagePath = firstPath(paths) +} + func firstPath(paths []string) string { if len(paths) == 0 { return "" @@ -1000,6 +1258,158 @@ func cleanupImages(uploadDir, raw string) { } } +func generateCorrectedImages(uploadDir string, imagePaths []string, title string, annotations []string) ([]string, error) { + if len(imagePaths) == 0 { + return nil, nil + } + annotations = normalizeAnnotations(annotations) + if len(annotations) == 0 { + annotations = []string{"已完成批改,请查看详细结果。"} + } + + results := make([]string, 0, len(imagePaths)) + for index, srcPath := range imagePaths { + filename := fmt.Sprintf("%s_%s_corrected_%d.jpg", time.Now().Format("20060102_150405"), randomToken(6), index+1) + dstPath := filepath.Join(uploadDir, filename) + if err := renderCorrectionImage(srcPath, dstPath, title, annotations); err != nil { + return nil, err + } + results = append(results, dstPath) + } + return results, nil +} + +func renderCorrectionImage(srcPath, dstPath, title string, annotations []string) error { + file, err := os.Open(srcPath) + if err != nil { + return err + } + defer file.Close() + + img, _, err := image.Decode(file) + if err != nil { + return err + } + + dc := gg.NewContextForImage(img) + width := float64(dc.Width()) + height := float64(dc.Height()) + + fontSize := clamp(width/36.0, 18, 30) + if err := loadFontFace(dc, fontSize); err != nil { + dc.SetFontFace(basicfont.Face7x13) + fontSize = 14 + } + + dc.SetRGBA(1, 1, 1, 0.05) + dc.DrawRectangle(0, 0, width, height) + dc.Fill() + + label := "订正" + if strings.TrimSpace(title) != "" { + label = "订正" + } + labelX := width - 24 + labelY := 24.0 + drawShadowText(dc, label, labelX, labelY, 1, 0) + + startX := width * 0.06 + startY := height * 0.12 + maxWidth := width * 0.7 + lineHeight := fontSize * 1.45 + maxY := height * 0.9 + + y := startY + for _, note := range annotations { + lines := wrapText(dc, note, maxWidth) + for _, line := range lines { + if y > maxY { + return saveJPEG(dstPath, dc.Image()) + } + drawShadowText(dc, line, startX, y, 0, 0) + y += lineHeight + } + y += lineHeight * 0.25 + } + + return saveJPEG(dstPath, dc.Image()) +} + +func loadFontFace(dc *gg.Context, size float64) error { + candidates := []string{ + "/usr/share/fonts/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/noto/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/noto/NotoSerifCJK-Regular.ttc", + "/usr/share/fonts/noto/NotoSerifCJK-Bold.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + } + for _, path := range candidates { + if _, err := os.Stat(path); err != nil { + continue + } + if err := dc.LoadFontFace(path, size); err == nil { + return nil + } + } + return errors.New("font not found") +} + +func wrapText(dc *gg.Context, text string, maxWidth float64) []string { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + + var lines []string + var current []rune + flush := func() { + if len(current) == 0 { + return + } + lines = append(lines, strings.TrimSpace(string(current))) + current = nil + } + + for _, r := range []rune(text) { + if r == '\n' { + flush() + continue + } + current = append(current, r) + if len(current) <= 1 { + continue + } + if width, _ := dc.MeasureString(string(current)); width > maxWidth { + last := current[len(current)-1] + current = current[:len(current)-1] + flush() + current = []rune{last} + } + } + flush() + return lines +} + +func drawShadowText(dc *gg.Context, text string, x, y, ax, ay float64) { + dc.SetRGBA(1, 1, 1, 0.85) + dc.DrawStringAnchored(text, x+1, y+1, ax, ay) + dc.SetRGB(0.82, 0, 0) + dc.DrawStringAnchored(text, x, y, ax, ay) +} + +func saveJPEG(path string, img image.Image) error { + tmp, err := os.Create(path) + if err != nil { + return err + } + defer tmp.Close() + return jpeg.Encode(tmp, img, &jpeg.Options{Quality: 92}) +} + +func clamp(value, min, max float64) float64 { + return math.Max(min, math.Min(value, max)) +} + func randomToken(length int) string { const charset = "abcdefghijklmnopqrstuvwxyz0123456789" buf := make([]byte, length) diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 5f86dc8..4f13920 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -14,6 +14,8 @@ type AssignmentSummary = { score: number; imagePath?: string; imagePaths?: string[]; + correctedImagePath?: string; + correctedImagePaths?: string[]; createdAt: string; }; @@ -23,6 +25,8 @@ type AssignmentDetail = AssignmentSummary & { feedback: string; imagePath?: string; imagePaths?: string[]; + correctedImagePath?: string; + correctedImagePaths?: string[]; }; type ImageItem = { @@ -394,9 +398,15 @@ export default function HomePage() { const imageUrlFor = (id: number, index = 0) => `${apiBase}/assignments/${id}/image?username=${encodeURIComponent(savedUser)}&index=${index}`; + const correctedImageUrlFor = (id: number, index = 0) => + `${apiBase}/assignments/${id}/corrected?username=${encodeURIComponent(savedUser)}&index=${index}`; + const imageCountFor = (item: AssignmentSummary) => item.imagePaths?.length ?? (item.imagePath ? 1 : 0); + const correctedImageCountFor = (item: AssignmentSummary) => + item.correctedImagePaths?.length ?? (item.correctedImagePath ? 1 : 0); + const selectedImagePaths = selected?.imagePaths && selected.imagePaths.length > 0 ? selected.imagePaths @@ -404,6 +414,13 @@ export default function HomePage() { ? [selected.imagePath] : []; + const selectedCorrectedPaths = + selected?.correctedImagePaths && selected.correctedImagePaths.length > 0 + ? selected.correctedImagePaths + : selected?.correctedImagePath + ? [selected.correctedImagePath] + : []; + return (
@@ -574,6 +591,19 @@ export default function HomePage() { 保存图片{imageCountFor(item) > 1 ? ` (${imageCountFor(item)}张)` : ""} )} + {correctedImageCountFor(item) > 0 && ( + event.stopPropagation()} + > + 保存订正图 + {correctedImageCountFor(item) > 1 + ? ` (${correctedImageCountFor(item)}张)` + : ""} + + )}
)} + {selectedCorrectedPaths.length > 0 && ( +
+
+ 订正效果图(共 {selectedCorrectedPaths.length} 张) +
+
+ {selectedCorrectedPaths.map((_, index) => ( +
+ {`订正图 + + 保存订正第 {index + 1} 张 + +
+ ))} +
+
+ )}
{selected.markdown}