|
|
|
@@ -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
|
|
|
|
@@ -56,6 +63,8 @@ type Assignment struct {
|
|
|
|
Score int `json:"score"`
|
|
|
|
Score int `json:"score"`
|
|
|
|
ImagePath string `json:"imagePath"`
|
|
|
|
ImagePath string `json:"imagePath"`
|
|
|
|
ImagePaths []string `json:"imagePaths"`
|
|
|
|
ImagePaths []string `json:"imagePaths"`
|
|
|
|
|
|
|
|
CorrectedImagePath string `json:"correctedImagePath"`
|
|
|
|
|
|
|
|
CorrectedImagePaths []string `json:"correctedImagePaths"`
|
|
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -69,6 +78,7 @@ 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
|
|
|
|
@@ -391,6 +487,8 @@ func handleCreateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.H
|
|
|
|
Score: score,
|
|
|
|
Score: score,
|
|
|
|
ImagePath: "",
|
|
|
|
ImagePath: "",
|
|
|
|
ImagePaths: []string{},
|
|
|
|
ImagePaths: []string{},
|
|
|
|
|
|
|
|
CorrectedImagePath: "",
|
|
|
|
|
|
|
|
CorrectedImagePaths: []string{},
|
|
|
|
CreatedAt: time.Now(),
|
|
|
|
CreatedAt: time.Now(),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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,6 +638,9 @@ 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,
|
|
|
|
@@ -538,6 +652,8 @@ func handleUpdateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.H
|
|
|
|
Score: score,
|
|
|
|
Score: score,
|
|
|
|
ImagePath: firstPath(imagePaths),
|
|
|
|
ImagePath: firstPath(imagePaths),
|
|
|
|
ImagePaths: imagePaths,
|
|
|
|
ImagePaths: imagePaths,
|
|
|
|
|
|
|
|
CorrectedImagePath: firstPath(correctedPaths),
|
|
|
|
|
|
|
|
CorrectedImagePaths: correctedPaths,
|
|
|
|
CreatedAt: createdAt,
|
|
|
|
CreatedAt: createdAt,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
|
|
@@ -643,13 +771,15 @@ func handleCreateAssignmentWithImage(db *sql.DB, llm *LLMClient, uploadDir strin
|
|
|
|
Score: score,
|
|
|
|
Score: score,
|
|
|
|
ImagePath: firstPath(imagePaths),
|
|
|
|
ImagePath: firstPath(imagePaths),
|
|
|
|
ImagePaths: imagePaths,
|
|
|
|
ImagePaths: imagePaths,
|
|
|
|
|
|
|
|
CorrectedImagePath: firstPath(correctedPaths),
|
|
|
|
|
|
|
|
CorrectedImagePaths: correctedPaths,
|
|
|
|
CreatedAt: time.Now(),
|
|
|
|
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)
|
|
|
|
|