Add assignment regrade update flow

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

查看文件

@@ -25,6 +25,7 @@ docker compose up -d --build
- `GET /api/assignments?username=xxx` - `GET /api/assignments?username=xxx`
- `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`(重新上传并重新批改)
- `GET /api/assignments/{id}?username=xxx` - `GET /api/assignments/{id}?username=xxx`
- `DELETE /api/assignments/{id}?username=xxx` - `DELETE /api/assignments/{id}?username=xxx`

查看文件

@@ -123,6 +123,7 @@ func main() {
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.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.Delete("/assignments/{id}", handleDeleteAssignment(db)) 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) { func handleCreateAssignmentWithImage(db *sql.DB, llm *LLMClient, uploadDir string, w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 50<<20) r.Body = http.MaxBytesReader(w, r.Body, 50<<20)
if err := r.ParseMultipartForm(60 << 20); err != nil { 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") 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) userPrompt := fmt.Sprintf("标题: %s\n\nOCR文本:\n%s", title, ocrText)
payload := map[string]any{ payload := map[string]any{
@@ -614,7 +762,7 @@ func (c *LLMClient) FormatAndGradeFromImages(ctx context.Context, title string,
return "", "", 0, errors.New("no images provided") 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) 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)
@@ -801,6 +949,29 @@ func firstPath(paths []string) string {
return paths[0] 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 { func randomToken(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789" const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
buf := make([]byte, length) 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 { func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*") 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") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)

查看文件

@@ -210,6 +210,17 @@ textarea.input {
background: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.7);
} }
.edit-banner {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
border: 1px dashed rgba(31, 29, 26, 0.3);
background: rgba(15, 76, 92, 0.08);
}
.preview { .preview {
width: 100%; width: 100%;
border-radius: 16px; border-radius: 16px;

查看文件

@@ -40,6 +40,7 @@ export default function HomePage() {
const [assignments, setAssignments] = useState<AssignmentSummary[]>([]); const [assignments, setAssignments] = useState<AssignmentSummary[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [selected, setSelected] = useState<AssignmentDetail | null>(null); const [selected, setSelected] = useState<AssignmentDetail | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [images, setImages] = useState<ImageItem[]>([]); const [images, setImages] = useState<ImageItem[]>([]);
@@ -255,6 +256,23 @@ export default function HomePage() {
); );
}; };
const beginEdit = async (item: AssignmentSummary) => {
setError("");
clearImages();
setTitle(item.title);
setEditingId(item.id);
setSelectedId(item.id);
setInfo("已进入重新修改模式,请上传新的作业图片。");
await loadAssignmentDetail(item.id);
};
const cancelEdit = () => {
setEditingId(null);
setTitle("");
clearImages();
setInfo("");
};
const startCamera = async () => { const startCamera = async () => {
setError(""); setError("");
try { try {
@@ -308,6 +326,7 @@ export default function HomePage() {
setIsSubmitting(true); setIsSubmitting(true);
setInfo("正在提交图片并调用 LLM 批改,请稍候..."); setInfo("正在提交图片并调用 LLM 批改,请稍候...");
try { try {
const isEditing = editingId !== null;
const formData = new FormData(); const formData = new FormData();
formData.append("username", savedUser); formData.append("username", savedUser);
if (title.trim()) { if (title.trim()) {
@@ -319,8 +338,11 @@ export default function HomePage() {
formData.append("images", blob, name); formData.append("images", blob, name);
}); });
const res = await fetch(`${apiBase}/assignments`, { const endpoint = isEditing
method: "POST", ? `${apiBase}/assignments/${editingId}`
: `${apiBase}/assignments`;
const res = await fetch(endpoint, {
method: isEditing ? "PUT" : "POST",
body: formData, body: formData,
}); });
if (!res.ok) { if (!res.ok) {
@@ -328,12 +350,19 @@ export default function HomePage() {
throw new Error(payload.error || "提交失败"); throw new Error(payload.error || "提交失败");
} }
const created = (await res.json()) as AssignmentDetail; const created = (await res.json()) as AssignmentDetail;
if (isEditing) {
setAssignments((prev) =>
prev.map((item) => (item.id === created.id ? { ...item, ...created } : item))
);
} else {
setAssignments((prev) => [created, ...prev]); setAssignments((prev) => [created, ...prev]);
}
setSelectedId(created.id); setSelectedId(created.id);
setSelected(created); setSelected(created);
setTitle(""); setTitle("");
clearImages(); clearImages();
setInfo("批改完成,可在右侧查看结果。"); setEditingId(null);
setInfo(isEditing ? "修改完成,已重新批改。" : "批改完成,可在右侧查看结果。");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "提交失败"); setError(err instanceof Error ? err.message : "提交失败");
} finally { } finally {
@@ -354,6 +383,9 @@ export default function HomePage() {
setSelectedId(null); setSelectedId(null);
setSelected(null); setSelected(null);
} }
if (editingId === id) {
cancelEdit();
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "删除失败"); setError(err instanceof Error ? err.message : "删除失败");
} }
@@ -414,6 +446,18 @@ export default function HomePage() {
<span className="tag">IMAGE + LLM</span> <span className="tag">IMAGE + LLM</span>
</header> </header>
<div className="upload-area"> <div className="upload-area">
{editingId && (
<div className="edit-banner">
<div>
<strong></strong>
{selected?.title || title || `作业 #${editingId}`}
<div className="muted"></div>
</div>
<button className="button ghost" onClick={cancelEdit}>
</button>
</div>
)}
<label className="muted"></label> <label className="muted"></label>
<input <input
className="input" className="input"
@@ -486,7 +530,7 @@ export default function HomePage() {
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? "批改中..." : "提交批改"} {isSubmitting ? "批改中..." : editingId ? "重新提交批改" : "提交批改"}
</button> </button>
{error && <div className="feedback">{error}</div>} {error && <div className="feedback">{error}</div>}
@@ -530,6 +574,15 @@ export default function HomePage() {
{imageCountFor(item) > 1 ? ` (${imageCountFor(item)}张)` : ""} {imageCountFor(item) > 1 ? ` (${imageCountFor(item)}张)` : ""}
</a> </a>
)} )}
<button
className="button secondary"
onClick={(event) => {
event.stopPropagation();
void beginEdit(item);
}}
>
</button>
<button <button
className="button ghost" className="button ghost"
onClick={(event) => { onClick={(event) => {