Add assignment regrade update flow
这个提交包含在:
@@ -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`
|
||||||
|
|
||||||
|
|||||||
177
backend/main.go
177
backend/main.go
@@ -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) => {
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户