Add assignment regrade update flow

这个提交包含在:
cryptocommuniums-afk
2026-02-01 11:42:48 +08:00
父节点 7a7e0a0d7f
当前提交 5a1a9a82ca
修改 4 个文件,包含 244 行新增8 行删除

查看文件

@@ -123,6 +123,7 @@ func main() {
r.Get("/assignments/{id}", handleGetAssignment(db))
r.Get("/assignments/{id}/image", handleGetAssignmentImage(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))
})
@@ -396,6 +397,153 @@ func handleCreateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.H
}
}
func handleUpdateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "multipart/form-data") {
writeError(w, http.StatusBadRequest, "multipart form required")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 50<<20)
if err := r.ParseMultipartForm(60 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form")
return
}
username := strings.TrimSpace(r.FormValue("username"))
if username == "" {
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 existingTitle string
var createdAt time.Time
var existingImagePath sql.NullString
row := db.QueryRow(`
SELECT title, created_at, image_path
FROM assignments
WHERE id = ? AND username = ?
LIMIT 1
`, id, username)
if err := row.Scan(&existingTitle, &createdAt, &existingImagePath); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "assignment not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to query assignment")
return
}
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
title = existingTitle
}
if title == "" {
title = fmt.Sprintf("%s %s", defaultTitlePrefix, time.Now().Format("2006-01-02 15:04"))
}
files := r.MultipartForm.File["images"]
if len(files) == 0 {
files = r.MultipartForm.File["image"]
}
if len(files) == 0 {
writeError(w, http.StatusBadRequest, "image is required")
return
}
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
writeError(w, http.StatusInternalServerError, "failed to prepare upload dir")
return
}
imagePaths := make([]string, 0, len(files))
images := make([]LLMImage, 0, len(files))
for _, header := range files {
file, err := header.Open()
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read image")
return
}
data, err := io.ReadAll(file)
file.Close()
if err != nil || len(data) == 0 {
writeError(w, http.StatusBadRequest, "failed to read image")
return
}
mimeType := header.Header.Get("Content-Type")
if mimeType == "" {
mimeType = http.DetectContentType(data)
}
if !strings.HasPrefix(mimeType, "image/") {
writeError(w, http.StatusBadRequest, "invalid image type")
return
}
filename := fmt.Sprintf("%s_%s%s", time.Now().Format("20060102_150405"), randomToken(6), extFromMime(mimeType))
imagePath := filepath.Join(uploadDir, filename)
if err := os.WriteFile(imagePath, data, 0o644); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save image")
return
}
imagePaths = append(imagePaths, imagePath)
images = append(images, LLMImage{
MimeType: mimeType,
Base64: base64.StdEncoding.EncodeToString(data),
})
}
markdown, feedback, score, err := llm.FormatAndGradeFromImages(r.Context(), title, images)
if err != nil {
writeError(w, http.StatusBadGateway, fmt.Sprintf("llm failed: %v", err))
return
}
imagePathsJSON, _ := json.Marshal(imagePaths)
_, err = db.Exec(`
UPDATE assignments
SET title = ?, ocr_text = ?, markdown = ?, feedback = ?, score = ?, image_path = ?
WHERE id = ? AND username = ?
`, title, "", markdown, feedback, score, string(imagePathsJSON), id, username)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update assignment")
return
}
if existingImagePath.Valid {
cleanupImages(uploadDir, existingImagePath.String)
}
assignment := Assignment{
ID: id,
Username: username,
Title: title,
OCRText: "",
Markdown: markdown,
Feedback: feedback,
Score: score,
ImagePath: firstPath(imagePaths),
ImagePaths: imagePaths,
CreatedAt: createdAt,
}
writeJSON(w, http.StatusOK, assignment)
}
}
func handleCreateAssignmentWithImage(db *sql.DB, llm *LLMClient, uploadDir string, w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 50<<20)
if err := r.ParseMultipartForm(60 << 20); err != nil {
@@ -566,7 +714,7 @@ func (c *LLMClient) FormatAndGrade(ctx context.Context, title, ocrText string) (
return "", "", 0, errors.New("LLM_BASE_URL or LLM_API_KEY missing")
}
systemPrompt := "你是一位细致的老师。请把 OCR 文字整理成清晰的 Markdown,保持原有题号、分点、段落与格式。然后给出作业批改意见。只输出 JSON,包含 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。"
systemPrompt := "你是一位严谨的阅卷老师。请把 OCR 文字整理成清晰的 Markdown,保持原有题号、分点、段落与格式。然后进行详细批改:对每一道题给出参考答案、解题过程拆解、学生答案摘录、正确性判定(正确/部分正确/错误)、正确答案对比说明、考察知识点说明。最后给出整体评价与改进建议。仅输出 JSON,字段为 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。"
userPrompt := fmt.Sprintf("标题: %s\n\nOCR文本:\n%s", title, ocrText)
payload := map[string]any{
@@ -614,7 +762,7 @@ func (c *LLMClient) FormatAndGradeFromImages(ctx context.Context, title string,
return "", "", 0, errors.New("no images provided")
}
systemPrompt := "你是一位细致的老师。请根据作业图片识别内容,整理为清晰的 Markdown,保持题号、分点、段落与格式。然后给出作业批改意见。只输出 JSON,包含 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。"
systemPrompt := "你是一位严谨的阅卷老师。请根据作业图片识别内容,整理为清晰的 Markdown,保持题号、分点、段落与格式。然后进行详细批改:对每一道题给出参考答案、解题过程拆解、学生答案摘录、正确性判定(正确/部分正确/错误)、正确答案对比说明、考察知识点说明。最后给出整体评价与改进建议。若题目或答案无法识别,请明确标注“无法辨识”。仅输出 JSON,字段为 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。"
userText := fmt.Sprintf("标题: %s\n以下图片按上传顺序依次为第1页、第2页……请综合批改。", title)
contentParts := make([]map[string]any, 0, len(images)+1)
@@ -801,6 +949,29 @@ func firstPath(paths []string) string {
return paths[0]
}
func cleanupImages(uploadDir, raw string) {
paths := parseImagePaths(raw)
if len(paths) == 0 {
return
}
absUpload, err := filepath.Abs(uploadDir)
if err != nil {
return
}
for _, path := range paths {
absImage, err := filepath.Abs(path)
if err != nil {
continue
}
if !strings.HasPrefix(absImage, absUpload+string(os.PathSeparator)) && absImage != absUpload {
continue
}
_ = os.Remove(absImage)
}
}
func randomToken(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
buf := make([]byte, length)
@@ -845,7 +1016,7 @@ func basicAuth(user, pass string) func(http.Handler) http.Handler {
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)