Add corrected image generation

这个提交包含在:
cryptocommuniums-afk
2026-02-01 13:38:57 +08:00
父节点 a0e9cf7e02
当前提交 103340ff9d
修改 6 个文件,包含 561 行新增85 行删除

查看文件

@@ -27,6 +27,7 @@ docker compose up -d --build
- `POST /api/assignments` `multipart/form-data``username``title``images`(可多张,按上传顺序) - `POST /api/assignments` `multipart/form-data``username``title``images`(可多张,按上传顺序)
- `PUT /api/assignments/{id}` `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}?username=xxx`
- `GET /api/assignments/{id}/corrected?username=xxx&index=0`
- `DELETE /api/assignments/{id}?username=xxx` - `DELETE /api/assignments/{id}?username=xxx`
## 说明 ## 说明

查看文件

@@ -10,7 +10,7 @@ COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o /bin/homework-backend ./ RUN CGO_ENABLED=1 GOOS=linux go build -o /bin/homework-backend ./
FROM alpine:3.20 FROM alpine:3.20
RUN apk add --no-cache ca-certificates RUN apk add --no-cache ca-certificates font-noto-cjk
WORKDIR /app WORKDIR /app
ENV DB_PATH=/data/homework.db ENV DB_PATH=/data/homework.db

查看文件

@@ -3,6 +3,10 @@ module homework-backend
go 1.22 go 1.22
require ( require (
github.com/fogleman/gg v1.3.0
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.1.0
github.com/mattn/go-sqlite3 v1.14.22 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

查看文件

@@ -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 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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=

查看文件

@@ -11,8 +11,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
"image"
_ "image/gif"
"image/jpeg"
_ "image/png"
"io" "io"
"log" "log"
"math"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -21,9 +26,11 @@ import (
"strings" "strings"
"time" "time"
"github.com/fogleman/gg"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"golang.org/x/image/font/basicfont"
) )
//go:embed admin.html //go:embed admin.html
@@ -47,16 +54,18 @@ type Config struct {
} }
type Assignment struct { type Assignment struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Title string `json:"title"` Title string `json:"title"`
OCRText string `json:"ocrText"` OCRText string `json:"ocrText"`
Markdown string `json:"markdown"` Markdown string `json:"markdown"`
Feedback string `json:"feedback"` Feedback string `json:"feedback"`
Score int `json:"score"` Score int `json:"score"`
ImagePath string `json:"imagePath"` ImagePath string `json:"imagePath"`
ImagePaths []string `json:"imagePaths"` ImagePaths []string `json:"imagePaths"`
CreatedAt time.Time `json:"createdAt"` CorrectedImagePath string `json:"correctedImagePath"`
CorrectedImagePaths []string `json:"correctedImagePaths"`
CreatedAt time.Time `json:"createdAt"`
} }
type createAssignmentRequest struct { type createAssignmentRequest struct {
@@ -66,9 +75,10 @@ type createAssignmentRequest struct {
} }
type LLMResult struct { type LLMResult struct {
Markdown string `json:"markdown"` Markdown string `json:"markdown"`
Feedback string `json:"feedback"` Feedback string `json:"feedback"`
Score int `json:"score"` Score int `json:"score"`
Annotations []string `json:"annotations"`
} }
type LLMImage struct { type LLMImage struct {
@@ -123,9 +133,10 @@ func main() {
r.Get("/assignments", handleListAssignments(db)) r.Get("/assignments", handleListAssignments(db))
r.Get("/assignments/{id}", handleGetAssignment(db)) r.Get("/assignments/{id}", handleGetAssignment(db))
r.Get("/assignments/{id}/image", handleGetAssignmentImage(db, cfg.UploadDir)) 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.Post("/assignments", handleCreateAssignment(db, llmClient, cfg.UploadDir))
r.Put("/assignments/{id}", handleUpdateAssignment(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) { router.Route("/backend", func(r chi.Router) {
@@ -175,6 +186,7 @@ func initDB(path string) (*sql.DB, error) {
feedback TEXT, feedback TEXT,
score INTEGER, score INTEGER,
image_path TEXT, image_path TEXT,
corrected_image_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
`); err != nil { `); err != nil {
@@ -183,6 +195,9 @@ func initDB(path string) (*sql.DB, error) {
if err := ensureColumn(db, "assignments", "image_path", "TEXT"); err != nil { if err := ensureColumn(db, "assignments", "image_path", "TEXT"); err != nil {
return nil, err return nil, err
} }
if err := ensureColumn(db, "assignments", "corrected_image_path", "TEXT"); err != nil {
return nil, err
}
return db, nil return db, nil
} }
@@ -195,7 +210,7 @@ func handleListAssignments(db *sql.DB) http.HandlerFunc {
} }
rows, err := db.Query(` 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 FROM assignments
WHERE username = ? WHERE username = ?
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -210,11 +225,13 @@ func handleListAssignments(db *sql.DB) http.HandlerFunc {
for rows.Next() { for rows.Next() {
var a Assignment var a Assignment
var rawImagePath string 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") writeError(w, http.StatusInternalServerError, "failed to read assignments")
return return
} }
setImageFields(&a, rawImagePath) setImageFields(&a, rawImagePath)
setCorrectedImageFields(&a, rawCorrectedPath)
assignments = append(assignments, a) assignments = append(assignments, a)
} }
@@ -238,13 +255,14 @@ func handleGetAssignment(db *sql.DB) http.HandlerFunc {
var a Assignment var a Assignment
var rawImagePath string var rawImagePath string
var rawCorrectedPath string
row := db.QueryRow(` 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 FROM assignments
WHERE id = ? AND username = ? WHERE id = ? AND username = ?
LIMIT 1 LIMIT 1
`, id, username) `, 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) { if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "assignment not found") writeError(w, http.StatusNotFound, "assignment not found")
return return
@@ -253,6 +271,7 @@ func handleGetAssignment(db *sql.DB) http.HandlerFunc {
return return
} }
setImageFields(&a, rawImagePath) setImageFields(&a, rawImagePath)
setCorrectedImageFields(&a, rawCorrectedPath)
writeJSON(w, http.StatusOK, a) 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 { func handleCreateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type") 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(` result, err := db.Exec(`
INSERT INTO assignments (username, title, ocr_text, markdown, feedback, score, image_path) INSERT INTO assignments (username, title, ocr_text, markdown, feedback, score, image_path, corrected_image_path)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, req.Username, req.Title, req.OCRText, markdown, feedback, score, "[]") `, req.Username, req.Title, req.OCRText, markdown, feedback, score, "[]", "[]")
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save assignment") writeError(w, http.StatusInternalServerError, "failed to save assignment")
return return
@@ -382,16 +478,18 @@ func handleCreateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.H
id, _ := result.LastInsertId() id, _ := result.LastInsertId()
assignment := Assignment{ assignment := Assignment{
ID: id, ID: id,
Username: req.Username, Username: req.Username,
Title: req.Title, Title: req.Title,
OCRText: req.OCRText, OCRText: req.OCRText,
Markdown: markdown, Markdown: markdown,
Feedback: feedback, Feedback: feedback,
Score: score, Score: score,
ImagePath: "", ImagePath: "",
ImagePaths: []string{}, ImagePaths: []string{},
CreatedAt: time.Now(), CorrectedImagePath: "",
CorrectedImagePaths: []string{},
CreatedAt: time.Now(),
} }
writeJSON(w, http.StatusOK, assignment) writeJSON(w, http.StatusOK, assignment)
@@ -430,13 +528,14 @@ func handleUpdateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.H
var existingTitle string var existingTitle string
var createdAt time.Time var createdAt time.Time
var existingImagePath sql.NullString var existingImagePath sql.NullString
var existingCorrectedPath sql.NullString
row := db.QueryRow(` row := db.QueryRow(`
SELECT title, created_at, image_path SELECT title, created_at, image_path, corrected_image_path
FROM assignments FROM assignments
WHERE id = ? AND username = ? WHERE id = ? AND username = ?
LIMIT 1 LIMIT 1
`, id, username) `, 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) { if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "assignment not found") writeError(w, http.StatusNotFound, "assignment not found")
return 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 { if err != nil {
writeError(w, http.StatusBadGateway, fmt.Sprintf("llm failed: %v", err)) writeError(w, http.StatusBadGateway, fmt.Sprintf("llm failed: %v", err))
return 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) imagePathsJSON, _ := json.Marshal(imagePaths)
correctedPathsJSON, _ := json.Marshal(correctedPaths)
_, err = db.Exec(` _, err = db.Exec(`
UPDATE assignments 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 = ? 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update assignment") writeError(w, http.StatusInternalServerError, "failed to update assignment")
return return
@@ -527,18 +638,23 @@ func handleUpdateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.H
if existingImagePath.Valid { if existingImagePath.Valid {
cleanupImages(uploadDir, existingImagePath.String) cleanupImages(uploadDir, existingImagePath.String)
} }
if existingCorrectedPath.Valid {
cleanupImages(uploadDir, existingCorrectedPath.String)
}
assignment := Assignment{ assignment := Assignment{
ID: id, ID: id,
Username: username, Username: username,
Title: title, Title: title,
OCRText: "", OCRText: "",
Markdown: markdown, Markdown: markdown,
Feedback: feedback, Feedback: feedback,
Score: score, Score: score,
ImagePath: firstPath(imagePaths), ImagePath: firstPath(imagePaths),
ImagePaths: imagePaths, ImagePaths: imagePaths,
CreatedAt: createdAt, CorrectedImagePath: firstPath(correctedPaths),
CorrectedImagePaths: correctedPaths,
CreatedAt: createdAt,
} }
writeJSON(w, http.StatusOK, assignment) 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 { if err != nil {
writeError(w, http.StatusBadGateway, fmt.Sprintf("llm failed: %v", err)) writeError(w, http.StatusBadGateway, fmt.Sprintf("llm failed: %v", err))
return 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) imagePathsJSON, _ := json.Marshal(imagePaths)
correctedPathsJSON, _ := json.Marshal(correctedPaths)
result, err := db.Exec(` result, err := db.Exec(`
INSERT INTO assignments (username, title, ocr_text, markdown, feedback, score, image_path) INSERT INTO assignments (username, title, ocr_text, markdown, feedback, score, image_path, corrected_image_path)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, username, title, "", markdown, feedback, score, string(imagePathsJSON)) `, username, title, "", markdown, feedback, score, string(imagePathsJSON), string(correctedPathsJSON))
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save assignment") writeError(w, http.StatusInternalServerError, "failed to save assignment")
return return
@@ -634,22 +762,24 @@ func handleCreateAssignmentWithImage(db *sql.DB, llm *LLMClient, uploadDir strin
id, _ := result.LastInsertId() id, _ := result.LastInsertId()
assignment := Assignment{ assignment := Assignment{
ID: id, ID: id,
Username: username, Username: username,
Title: title, Title: title,
OCRText: "", OCRText: "",
Markdown: markdown, Markdown: markdown,
Feedback: feedback, Feedback: feedback,
Score: score, Score: score,
ImagePath: firstPath(imagePaths), ImagePath: firstPath(imagePaths),
ImagePaths: imagePaths, ImagePaths: imagePaths,
CreatedAt: time.Now(), CorrectedImagePath: firstPath(correctedPaths),
CorrectedImagePaths: correctedPaths,
CreatedAt: time.Now(),
} }
writeJSON(w, http.StatusOK, assignment) 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) { return func(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.URL.Query().Get("username")) username := strings.TrimSpace(r.URL.Query().Get("username"))
if username == "" { if username == "" {
@@ -663,6 +793,23 @@ func handleDeleteAssignment(db *sql.DB) http.HandlerFunc {
return 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) res, err := db.Exec(`DELETE FROM assignments WHERE id = ? AND username = ?`, id, username)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete assignment") writeError(w, http.StatusInternalServerError, "failed to delete assignment")
@@ -674,6 +821,13 @@ func handleDeleteAssignment(db *sql.DB) http.HandlerFunc {
return return
} }
if imagePath.Valid {
cleanupImages(uploadDir, imagePath.String)
}
if correctedPath.Valid {
cleanupImages(uploadDir, correctedPath.String)
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": id}) 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 == "" { 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) userPrompt := fmt.Sprintf("标题: %s\n\nOCR文本:\n%s", title, ocrText)
payload := map[string]any{ payload := map[string]any{
@@ -729,38 +883,45 @@ func (c *LLMClient) FormatAndGrade(ctx context.Context, title, ocrText string) (
content, err := c.chatCompletionWithRetry(ctx, payload) content, err := c.chatCompletionWithRetry(ctx, payload)
if err != nil { if err != nil {
return "", "", 0, err return LLMResult{}, err
} }
result := LLMResult{} result := LLMResult{}
if err := decodeLLMResult(content, &result); err != nil { if err := decodeLLMResult(content, &result); err != nil {
markdown, feedback, score := fallbackFromContent(content, ocrText) 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) result.Markdown = strings.TrimSpace(result.Markdown)
feedback := strings.TrimSpace(result.Feedback) result.Feedback = strings.TrimSpace(result.Feedback)
score := result.Score if result.Markdown == "" {
if markdown == "" { result.Markdown = ocrText
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) { 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}}) 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 == "" { 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 { 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) userText := fmt.Sprintf("标题: %s\n以下图片按上传顺序依次为第1页、第2页……请综合批改。", title)
contentParts := make([]map[string]any, 0, len(images)+1) 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) content, err := c.chatCompletionWithRetry(ctx, payload)
if err != nil { if err != nil {
return "", "", 0, err return LLMResult{}, err
} }
result := LLMResult{} result := LLMResult{}
if err := decodeLLMResult(content, &result); err != nil { if err := decodeLLMResult(content, &result); err != nil {
markdown, feedback, score := fallbackFromContent(content, "未识别到有效内容。") markdown, feedback, score := fallbackFromContent(content, "未识别到有效内容。")
return markdown, feedback, score, nil return LLMResult{Markdown: markdown, Feedback: feedback, Score: score}, nil
} }
markdown := strings.TrimSpace(result.Markdown) result.Markdown = strings.TrimSpace(result.Markdown)
feedback := strings.TrimSpace(result.Feedback) result.Feedback = strings.TrimSpace(result.Feedback)
score := result.Score if result.Markdown == "" {
if markdown == "" { result.Markdown = "未识别到有效内容。"
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) { 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 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 { func ensureColumn(db *sql.DB, table, column, columnType string) error {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s);", table)) rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s);", table))
if err != nil { if err != nil {
@@ -955,6 +1204,9 @@ func parseImagePaths(raw string) []string {
if raw == "" { if raw == "" {
return nil return nil
} }
if raw == "null" {
return nil
}
if strings.HasPrefix(raw, "[") { if strings.HasPrefix(raw, "[") {
var paths []string var paths []string
if json.Unmarshal([]byte(raw), &paths) == nil { if json.Unmarshal([]byte(raw), &paths) == nil {
@@ -970,6 +1222,12 @@ func setImageFields(a *Assignment, raw string) {
a.ImagePath = firstPath(paths) 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 { func firstPath(paths []string) string {
if len(paths) == 0 { if len(paths) == 0 {
return "" 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 { func randomToken(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789" const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
buf := make([]byte, length) buf := make([]byte, length)

查看文件

@@ -14,6 +14,8 @@ type AssignmentSummary = {
score: number; score: number;
imagePath?: string; imagePath?: string;
imagePaths?: string[]; imagePaths?: string[];
correctedImagePath?: string;
correctedImagePaths?: string[];
createdAt: string; createdAt: string;
}; };
@@ -23,6 +25,8 @@ type AssignmentDetail = AssignmentSummary & {
feedback: string; feedback: string;
imagePath?: string; imagePath?: string;
imagePaths?: string[]; imagePaths?: string[];
correctedImagePath?: string;
correctedImagePaths?: string[];
}; };
type ImageItem = { type ImageItem = {
@@ -394,9 +398,15 @@ export default function HomePage() {
const imageUrlFor = (id: number, index = 0) => const imageUrlFor = (id: number, index = 0) =>
`${apiBase}/assignments/${id}/image?username=${encodeURIComponent(savedUser)}&index=${index}`; `${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) => const imageCountFor = (item: AssignmentSummary) =>
item.imagePaths?.length ?? (item.imagePath ? 1 : 0); item.imagePaths?.length ?? (item.imagePath ? 1 : 0);
const correctedImageCountFor = (item: AssignmentSummary) =>
item.correctedImagePaths?.length ?? (item.correctedImagePath ? 1 : 0);
const selectedImagePaths = const selectedImagePaths =
selected?.imagePaths && selected.imagePaths.length > 0 selected?.imagePaths && selected.imagePaths.length > 0
? selected.imagePaths ? selected.imagePaths
@@ -404,6 +414,13 @@ export default function HomePage() {
? [selected.imagePath] ? [selected.imagePath]
: []; : [];
const selectedCorrectedPaths =
selected?.correctedImagePaths && selected.correctedImagePaths.length > 0
? selected.correctedImagePaths
: selected?.correctedImagePath
? [selected.correctedImagePath]
: [];
return ( return (
<main> <main>
<div className="page"> <div className="page">
@@ -574,6 +591,19 @@ export default function HomePage() {
{imageCountFor(item) > 1 ? ` (${imageCountFor(item)}张)` : ""} {imageCountFor(item) > 1 ? ` (${imageCountFor(item)}张)` : ""}
</a> </a>
)} )}
{correctedImageCountFor(item) > 0 && (
<a
className="button secondary"
href={correctedImageUrlFor(item.id, 0)}
download={`assignment-${item.id}-corrected-1.jpg`}
onClick={(event) => event.stopPropagation()}
>
{correctedImageCountFor(item) > 1
? ` (${correctedImageCountFor(item)}张)`
: ""}
</a>
)}
<button <button
className="button secondary" className="button secondary"
onClick={(event) => { onClick={(event) => {
@@ -636,6 +666,31 @@ export default function HomePage() {
</div> </div>
</div> </div>
)} )}
{selectedCorrectedPaths.length > 0 && (
<div style={{ display: "grid", gap: 12 }}>
<div className="assignment-meta">
<span> {selectedCorrectedPaths.length} </span>
</div>
<div className="image-grid">
{selectedCorrectedPaths.map((_, index) => (
<div key={`${selected.id}-corrected-${index}`} className="image-card">
<img
className="preview"
src={correctedImageUrlFor(selected.id, index)}
alt={`订正图 ${index + 1}`}
/>
<a
className="button secondary"
href={correctedImageUrlFor(selected.id, index)}
download={`assignment-${selected.id}-corrected-${index + 1}.jpg`}
>
{index + 1}
</a>
</div>
))}
</div>
</div>
)}
<div className="markdown"> <div className="markdown">
<ReactMarkdown>{selected.markdown}</ReactMarkdown> <ReactMarkdown>{selected.markdown}</ReactMarkdown>
</div> </div>