Initial commit
This commit is contained in:
+710
@@ -0,0 +1,710 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/generative-ai-go/genai"
|
||||
_ "modernc.org/sqlite"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
type Question struct {
|
||||
ID int `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Category string `json:"category"`
|
||||
QuestionText string `json:"questionText"`
|
||||
Choice1 string `json:"choice1"`
|
||||
Choice2 string `json:"choice2"`
|
||||
Choice3 string `json:"choice3"`
|
||||
Choice4 string `json:"choice4"`
|
||||
Choice5 string `json:"choice5"`
|
||||
CorrectAnswer int `json:"correctAnswer"`
|
||||
CorrectAnswerStr string `json:"correctAnswerStr"`
|
||||
Explanation string `json:"explanation"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
Passage string `json:"passage"`
|
||||
ImageUrl string `json:"imageUrl"`
|
||||
GeminiExplanation string `json:"geminiExplanation"`
|
||||
IsBookmarked bool `json:"isBookmarked"`
|
||||
Subject string `json:"subject"`
|
||||
CorrectAnswers string `json:"correctAnswers"`
|
||||
Oid int `json:"oid"`
|
||||
}
|
||||
|
||||
|
||||
type DescriptiveGradingRecord struct {
|
||||
ID int `json:"id"`
|
||||
QuestionID int `json:"questionId"`
|
||||
Source string `json:"source"`
|
||||
UserAnswer string `json:"userAnswer"`
|
||||
Score int `json:"score"`
|
||||
Evaluation string `json:"evaluation"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
|
||||
type CategoryInfo struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type TargetInfo struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type QuizService struct {
|
||||
db *sql.DB
|
||||
geminiKey string
|
||||
}
|
||||
|
||||
func NewQuizService() *QuizService {
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
// Prefer the database in the parent directory first if it exists,
|
||||
// as it contains the real quiz database for development.
|
||||
parentPath := filepath.Join("..", "qple_quiz.db")
|
||||
if _, err := os.Stat(parentPath); err == nil {
|
||||
dbPath = parentPath
|
||||
} else {
|
||||
dbPath = "qple_quiz.db"
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Connecting to database at: %s", dbPath)
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
|
||||
// Create QuizExplain table if it doesn't exist
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS QuizExplain (
|
||||
id INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
explain TEXT,
|
||||
PRIMARY KEY (source, id),
|
||||
FOREIGN KEY (source, id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create QuizExplain table: %v", err)
|
||||
}
|
||||
|
||||
// Create Bookmark table if it doesn't exist
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS Bookmark (
|
||||
question_id INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (source, question_id),
|
||||
FOREIGN KEY (source, question_id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create Bookmark table: %v", err)
|
||||
}
|
||||
|
||||
// Create DescriptiveGrading table if it doesn't exist
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS DescriptiveGrading (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
question_id INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
user_answer TEXT NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
evaluation TEXT NOT NULL,
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (source, question_id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create DescriptiveGrading table: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Ensure subject column exists in QuizQuestions table
|
||||
var hasSubject int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('QuizQuestions') WHERE name='subject'").Scan(&hasSubject)
|
||||
if err == nil && hasSubject == 0 {
|
||||
_, err = db.Exec("ALTER TABLE QuizQuestions ADD COLUMN subject TEXT DEFAULT ''")
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to add subject column: %v", err)
|
||||
} else {
|
||||
log.Println("Database Migration: Added subject column to QuizQuestions table.")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure correct_answers column exists in QuizQuestions table
|
||||
var hasCorrectAnswers int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('QuizQuestions') WHERE name='correct_answers'").Scan(&hasCorrectAnswers)
|
||||
if err == nil && hasCorrectAnswers == 0 {
|
||||
_, err = db.Exec("ALTER TABLE QuizQuestions ADD COLUMN correct_answers TEXT DEFAULT ''")
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to add correct_answers column: %v", err)
|
||||
} else {
|
||||
log.Println("Database Migration: Added correct_answers column to QuizQuestions table.")
|
||||
}
|
||||
}
|
||||
|
||||
return &QuizService{
|
||||
db: db,
|
||||
// Initialize this via some UI setting later, or env var
|
||||
geminiKey: "",
|
||||
}
|
||||
}
|
||||
|
||||
// SetGeminiKey sets the API key for Gemini calls
|
||||
func (s *QuizService) SetGeminiKey(key string) {
|
||||
s.geminiKey = key
|
||||
}
|
||||
|
||||
func (s *QuizService) GetCategories() []CategoryInfo {
|
||||
rows, err := s.db.Query("SELECT category, COUNT(*) as count FROM QuizQuestions WHERE category IS NOT NULL AND category != '' GROUP BY category")
|
||||
if err != nil {
|
||||
log.Printf("Query error: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var categories []CategoryInfo
|
||||
for rows.Next() {
|
||||
var cat CategoryInfo
|
||||
if err := rows.Scan(&cat.Name, &cat.Count); err != nil {
|
||||
continue
|
||||
}
|
||||
categories = append(categories, cat)
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
func (s *QuizService) GetTargets(category string) []TargetInfo {
|
||||
rows, err := s.db.Query("SELECT difficulty, COUNT(*) as count FROM QuizQuestions WHERE category = ? AND difficulty IS NOT NULL AND difficulty != '' GROUP BY difficulty", category)
|
||||
if err != nil {
|
||||
log.Printf("Query error: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var targets []TargetInfo
|
||||
for rows.Next() {
|
||||
var t TargetInfo
|
||||
if err := rows.Scan(&t.Name, &t.Count); err != nil {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, t)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
func (s *QuizService) GetSubjects(category, difficulty string) []string {
|
||||
rows, err := s.db.Query("SELECT DISTINCT subject FROM QuizQuestions WHERE category = ? AND difficulty = ? AND subject IS NOT NULL AND subject != '' ORDER BY subject", category, difficulty)
|
||||
if err != nil {
|
||||
log.Printf("Query error in GetSubjects: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var subjects []string
|
||||
for rows.Next() {
|
||||
var sub string
|
||||
if err := rows.Scan(&sub); err != nil {
|
||||
continue
|
||||
}
|
||||
subjects = append(subjects, sub)
|
||||
}
|
||||
return subjects
|
||||
}
|
||||
|
||||
func (s *QuizService) GetQuestions(category, difficulty, subject string) []Question {
|
||||
query := `
|
||||
SELECT q.id, q.source, q.category, q.question_text, q.choice1, q.choice2, q.choice3, q.choice4, COALESCE(q.choice5, '') as choice5,
|
||||
q.correct_answer, q.correct_answer_str, q.explanation, q.difficulty, q.passage, q.image_url,
|
||||
COALESCE(e.explain, '') as geminiExplanation,
|
||||
EXISTS(SELECT 1 FROM Bookmark WHERE source = q.source AND question_id = q.id) as isBookmarked,
|
||||
COALESCE(q.subject, '') as subject,
|
||||
COALESCE(q.correct_answers, '') as correct_answers,
|
||||
COALESCE(q.oid, 0) as oid
|
||||
FROM QuizQuestions q
|
||||
LEFT JOIN QuizExplain e ON q.source = e.source AND q.id = e.id
|
||||
WHERE q.category = ?
|
||||
`
|
||||
var args []interface{}
|
||||
args = append(args, category)
|
||||
|
||||
if difficulty != "" && difficulty != "All" {
|
||||
query += " AND q.difficulty = ?"
|
||||
args = append(args, difficulty)
|
||||
}
|
||||
|
||||
if subject != "" && subject != "All" && subject != "dummy" {
|
||||
query += " AND q.subject = ?"
|
||||
args = append(args, subject)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
log.Printf("Query error: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var questions []Question
|
||||
for rows.Next() {
|
||||
var q Question
|
||||
err := rows.Scan(
|
||||
&q.ID, &q.Source, &q.Category, &q.QuestionText, &q.Choice1, &q.Choice2, &q.Choice3, &q.Choice4, &q.Choice5,
|
||||
&q.CorrectAnswer, &q.CorrectAnswerStr, &q.Explanation, &q.Difficulty, &q.Passage, &q.ImageUrl,
|
||||
&q.GeminiExplanation, &q.IsBookmarked, &q.Subject, &q.CorrectAnswers, &q.Oid,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
questions = append(questions, q)
|
||||
}
|
||||
return questions
|
||||
}
|
||||
|
||||
|
||||
|
||||
// AddBookmark adds a source and question ID to the Bookmark table
|
||||
func (s *QuizService) AddBookmark(source string, questionID int) error {
|
||||
_, err := s.db.Exec("INSERT OR IGNORE INTO Bookmark (source, question_id) VALUES (?, ?)", source, questionID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveBookmark removes a source and question ID from the Bookmark table
|
||||
func (s *QuizService) RemoveBookmark(source string, questionID int) error {
|
||||
_, err := s.db.Exec("DELETE FROM Bookmark WHERE source = ? AND question_id = ?", source, questionID)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
// GetBookmarkedCategories retrieves categories that have bookmarked questions
|
||||
func (s *QuizService) GetBookmarkedCategories() []CategoryInfo {
|
||||
query := `
|
||||
SELECT q.category, COUNT(*) as count
|
||||
FROM Bookmark b
|
||||
JOIN QuizQuestions q ON b.source = q.source AND b.question_id = q.id
|
||||
WHERE q.category IS NOT NULL AND q.category != ''
|
||||
GROUP BY q.category
|
||||
`
|
||||
rows, err := s.db.Query(query)
|
||||
if err != nil {
|
||||
log.Printf("Query error: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var categories []CategoryInfo
|
||||
for rows.Next() {
|
||||
var cat CategoryInfo
|
||||
if err := rows.Scan(&cat.Name, &cat.Count); err != nil {
|
||||
continue
|
||||
}
|
||||
categories = append(categories, cat)
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
// GetBookmarkedTargets retrieves difficulties (targets) that have bookmarked questions within a category
|
||||
func (s *QuizService) GetBookmarkedTargets(category string) []TargetInfo {
|
||||
query := `
|
||||
SELECT q.difficulty, COUNT(*) as count
|
||||
FROM Bookmark b
|
||||
JOIN QuizQuestions q ON b.source = q.source AND b.question_id = q.id
|
||||
WHERE q.category = ? AND q.difficulty IS NOT NULL AND q.difficulty != ''
|
||||
GROUP BY q.difficulty
|
||||
`
|
||||
rows, err := s.db.Query(query, category)
|
||||
if err != nil {
|
||||
log.Printf("Query error: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var targets []TargetInfo
|
||||
for rows.Next() {
|
||||
var t TargetInfo
|
||||
if err := rows.Scan(&t.Name, &t.Count); err != nil {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, t)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// GetBookmarkedQuestions retrieves all bookmarked questions for a given category and difficulty
|
||||
func (s *QuizService) GetBookmarkedQuestions(category, difficulty string) []Question {
|
||||
query := `
|
||||
SELECT q.id, q.source, q.category, q.question_text, q.choice1, q.choice2, q.choice3, q.choice4, COALESCE(q.choice5, '') as choice5,
|
||||
q.correct_answer, q.correct_answer_str, q.explanation, q.difficulty, q.passage, q.image_url,
|
||||
COALESCE(e.explain, '') as geminiExplanation,
|
||||
1 as isBookmarked,
|
||||
COALESCE(q.subject, '') as subject,
|
||||
COALESCE(q.correct_answers, '') as correct_answers,
|
||||
COALESCE(q.oid, 0) as oid
|
||||
FROM Bookmark b
|
||||
JOIN QuizQuestions q ON b.source = q.source AND b.question_id = q.id
|
||||
LEFT JOIN QuizExplain e ON q.source = e.source AND q.id = e.id
|
||||
WHERE q.category = ? AND q.difficulty = ?
|
||||
`
|
||||
rows, err := s.db.Query(query, category, difficulty)
|
||||
if err != nil {
|
||||
log.Printf("Query error: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var questions []Question
|
||||
for rows.Next() {
|
||||
var q Question
|
||||
err := rows.Scan(
|
||||
&q.ID, &q.Source, &q.Category, &q.QuestionText, &q.Choice1, &q.Choice2, &q.Choice3, &q.Choice4, &q.Choice5,
|
||||
&q.CorrectAnswer, &q.CorrectAnswerStr, &q.Explanation, &q.Difficulty, &q.Passage, &q.ImageUrl,
|
||||
&q.GeminiExplanation, &q.IsBookmarked, &q.Subject, &q.CorrectAnswers, &q.Oid,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
questions = append(questions, q)
|
||||
}
|
||||
return questions
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (s *QuizService) SaveAiExplanation(source string, id int, explanation string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO QuizExplain (source, id, explain) VALUES (?, ?, ?)
|
||||
ON CONFLICT(source, id) DO UPDATE SET explain=excluded.explain
|
||||
`, source, id, explanation)
|
||||
return err
|
||||
}
|
||||
|
||||
func parseBase64Image(base64Str string) (string, []byte, error) {
|
||||
mimeType := "image/png" // default fallback
|
||||
rawData := base64Str
|
||||
|
||||
if strings.HasPrefix(base64Str, "data:") {
|
||||
parts := strings.SplitN(base64Str, ";base64,", 2)
|
||||
if len(parts) == 2 {
|
||||
mimeType = strings.TrimPrefix(parts[0], "data:")
|
||||
rawData = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
rawData = strings.ReplaceAll(rawData, "\n", "")
|
||||
rawData = strings.ReplaceAll(rawData, "\r", "")
|
||||
rawData = strings.ReplaceAll(rawData, " ", "")
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(rawData)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return mimeType, decoded, nil
|
||||
}
|
||||
|
||||
func (s *QuizService) AskGemini(source string, id int, prompt string, base64Image string) (string, error) {
|
||||
if s.geminiKey == "" {
|
||||
return "", fmt.Errorf("Gemini API key is not set")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
client, err := genai.NewClient(ctx, option.WithAPIKey(s.geminiKey))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
model := client.GenerativeModel("gemini-3.5-flash") // Using the latest model
|
||||
|
||||
// Build parts (Prompt text + Optional base64 image + auto-extracted question assets)
|
||||
var parts []genai.Part
|
||||
parts = append(parts, genai.Text(prompt))
|
||||
|
||||
if base64Image != "" {
|
||||
mimeType, decodedData, err := parseBase64Image(base64Image)
|
||||
if err == nil {
|
||||
parts = append(parts, genai.Blob{
|
||||
MIMEType: mimeType,
|
||||
Data: decodedData,
|
||||
})
|
||||
log.Printf("Successfully attached base64 image asset to Gemini (MIME: %s, size: %d bytes)", mimeType, len(decodedData))
|
||||
} else {
|
||||
log.Printf("Error decoding base64 image for Gemini: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Automatically scan and attach all image assets associated with this question
|
||||
parts = s.attachQuestionAssets(parts, source, id)
|
||||
|
||||
var resp *genai.GenerateContentResponse
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
resp, err = model.GenerateContent(ctx, parts...)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if strings.Contains(err.Error(), "429") {
|
||||
log.Printf("[Gemini] Request returned 429 (Rate Limit). Attempt %d of 3. Retrying in %d seconds...", attempt, attempt*2)
|
||||
time.Sleep(time.Duration(attempt*2) * time.Second)
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
|
||||
return "", fmt.Errorf("No response from Gemini")
|
||||
}
|
||||
|
||||
explanation := fmt.Sprintf("%v", resp.Candidates[0].Content.Parts[0])
|
||||
|
||||
// Save to DB automatically
|
||||
err = s.SaveAiExplanation(source, id, explanation)
|
||||
if err != nil {
|
||||
log.Printf("Failed to save explanation to DB: %v", err)
|
||||
}
|
||||
|
||||
return explanation, nil
|
||||
}
|
||||
|
||||
func (s *QuizService) attachQuestionAssets(parts []genai.Part, source string, id int) []genai.Part {
|
||||
var questionText, choice1, choice2, choice3, choice4, choice5, passage, explanation, imageURL string
|
||||
queryQ := `
|
||||
SELECT q.question_text, q.choice1, q.choice2, q.choice3, q.choice4, COALESCE(q.choice5, '') as choice5, q.passage, q.explanation, q.image_url
|
||||
FROM QuizQuestions q
|
||||
WHERE q.source = ? AND q.id = ?
|
||||
`
|
||||
_ = s.db.QueryRow(queryQ, source, id).Scan(&questionText, &choice1, &choice2, &choice3, &choice4, &choice5, &passage, &explanation, &imageURL)
|
||||
|
||||
var aiExplanation string
|
||||
_ = s.db.QueryRow("SELECT explain FROM QuizExplain WHERE source = ? AND id = ?", source, id).Scan(&aiExplanation)
|
||||
|
||||
// Combine all texts to scan for assets
|
||||
combinedTexts := strings.Join([]string{
|
||||
questionText, choice1, choice2, choice3, choice4, choice5, passage, explanation, aiExplanation, imageURL,
|
||||
}, " \n ")
|
||||
|
||||
// Scan for unique asset paths
|
||||
uniqueAssets := make(map[string]bool)
|
||||
assetRegex := regexp.MustCompile(`/assets/[a-zA-Z0-9_\-\.]+`)
|
||||
matches := assetRegex.FindAllString(combinedTexts, -1)
|
||||
for _, m := range matches {
|
||||
uniqueAssets[m] = true
|
||||
}
|
||||
|
||||
for assetPath := range uniqueAssets {
|
||||
filename := strings.TrimPrefix(assetPath, "/assets/")
|
||||
|
||||
var filePath string
|
||||
path1 := filepath.Join("frontend", "public", "assets", filename)
|
||||
path2 := filepath.Join("qple_quiz_app", "frontend", "public", "assets", filename)
|
||||
path3 := filepath.Join("..", "qple_quiz_app", "frontend", "public", "assets", filename)
|
||||
|
||||
if _, err := os.Stat(path1); err == nil {
|
||||
filePath = path1
|
||||
} else if _, err := os.Stat(path2); err == nil {
|
||||
filePath = path2
|
||||
} else if _, err := os.Stat(path3); err == nil {
|
||||
filePath = path3
|
||||
}
|
||||
|
||||
if filePath != "" {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err == nil {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
mimeType := "image/png" // default fallback
|
||||
if ext == ".pdf" {
|
||||
mimeType = "application/pdf"
|
||||
} else if ext == ".jpg" || ext == ".jpeg" {
|
||||
mimeType = "image/jpeg"
|
||||
} else if ext == ".webp" {
|
||||
mimeType = "image/webp"
|
||||
} else if ext == ".gif" {
|
||||
mimeType = "image/gif"
|
||||
}
|
||||
|
||||
parts = append(parts, genai.Blob{
|
||||
MIMEType: mimeType,
|
||||
Data: data,
|
||||
})
|
||||
log.Printf("Successfully attached local asset to Gemini: %s", filePath)
|
||||
} else {
|
||||
log.Printf("Error reading local asset for Gemini: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func (s *QuizService) GetDescriptiveGradingHistory(source string, questionID int) []DescriptiveGradingRecord {
|
||||
query := `
|
||||
SELECT id, question_id, source, user_answer, score, evaluation, username, strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime') as created_at
|
||||
FROM DescriptiveGrading
|
||||
WHERE source = ? AND question_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
rows, err := s.db.Query(query, source, questionID)
|
||||
if err != nil {
|
||||
log.Printf("Query error in GetDescriptiveGradingHistory: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []DescriptiveGradingRecord
|
||||
for rows.Next() {
|
||||
var r DescriptiveGradingRecord
|
||||
err := rows.Scan(&r.ID, &r.QuestionID, &r.Source, &r.UserAnswer, &r.Score, &r.Evaluation, &r.Username, &r.CreatedAt)
|
||||
if err != nil {
|
||||
log.Printf("Scan error in GetDescriptiveGradingHistory: %v", err)
|
||||
continue
|
||||
}
|
||||
records = append(records, r)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func (s *QuizService) GradeDescriptiveAnswer(source string, questionID int, userAnswer string) (*DescriptiveGradingRecord, error) {
|
||||
if s.geminiKey == "" {
|
||||
return nil, fmt.Errorf("Gemini API key is not set")
|
||||
}
|
||||
|
||||
var qText, passage, modelAnswer string
|
||||
queryQ := `SELECT question_text, passage, explanation FROM QuizQuestions WHERE source = ? AND id = ?`
|
||||
err := s.db.QueryRow(queryQ, source, questionID).Scan(&qText, &passage, &modelAnswer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to query question details: %v", err)
|
||||
}
|
||||
|
||||
if modelAnswer == "" {
|
||||
var aiExplain string
|
||||
errExplain := s.db.QueryRow("SELECT explain FROM QuizExplain WHERE source = ? AND id = ?", source, questionID).Scan(&aiExplain)
|
||||
if errExplain == nil && aiExplain != "" {
|
||||
modelAnswer = aiExplain
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
client, err := genai.NewClient(ctx, option.WithAPIKey(s.geminiKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
model := client.GenerativeModel("gemini-3.5-flash")
|
||||
|
||||
prompt := fmt.Sprintf(`사용자가 작성한 주관식/서술형 답안을 모범답안과 문제에 대조하여 공정하게 채점해 주세요.
|
||||
|
||||
[문제 정보]
|
||||
- 지문: %s
|
||||
- 문제: %s
|
||||
- 모범 답안: %s
|
||||
|
||||
[사용자 제출 답안]
|
||||
%s
|
||||
|
||||
결과는 반드시 아래의 JSON 포맷으로만 응답해 주세요. 포맷 이외의 텍스트(Markdown 코드 블럭 등)는 포함하지 마세요.
|
||||
|
||||
{
|
||||
"score": <0~100 사이의 정수 점수>,
|
||||
"evaluation": "<잘한 부분, 아쉬운 부분, 보완점에 대한 상세한 평가 내용 (한글 마크다운 형식)>"
|
||||
}
|
||||
`, passage, qText, modelAnswer, userAnswer)
|
||||
|
||||
var parts []genai.Part
|
||||
parts = append(parts, genai.Text(prompt))
|
||||
parts = s.attachQuestionAssets(parts, source, questionID)
|
||||
|
||||
var resp *genai.GenerateContentResponse
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
resp, err = model.GenerateContent(ctx, parts...)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if strings.Contains(err.Error(), "429") {
|
||||
log.Printf("[Gemini] Grade request returned 429 (Rate Limit). Attempt %d of 3. Retrying in %d seconds...", attempt, attempt*2)
|
||||
time.Sleep(time.Duration(attempt*2) * time.Second)
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
|
||||
return nil, fmt.Errorf("No response from Gemini")
|
||||
}
|
||||
|
||||
respText := fmt.Sprintf("%v", resp.Candidates[0].Content.Parts[0])
|
||||
|
||||
cleanJSON := strings.TrimSpace(respText)
|
||||
if strings.HasPrefix(cleanJSON, "```json") {
|
||||
cleanJSON = strings.TrimPrefix(cleanJSON, "```json")
|
||||
cleanJSON = strings.TrimSuffix(cleanJSON, "```")
|
||||
} else if strings.HasPrefix(cleanJSON, "```") {
|
||||
cleanJSON = strings.TrimPrefix(cleanJSON, "```")
|
||||
cleanJSON = strings.TrimSuffix(cleanJSON, "```")
|
||||
}
|
||||
cleanJSON = strings.TrimSpace(cleanJSON)
|
||||
|
||||
var result struct {
|
||||
Score int `json:"score"`
|
||||
Evaluation string `json:"evaluation"`
|
||||
}
|
||||
err = json.Unmarshal([]byte(cleanJSON), &result)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse Gemini response JSON: %q, error: %v", cleanJSON, err)
|
||||
result.Score = 70
|
||||
result.Evaluation = respText
|
||||
}
|
||||
|
||||
stmt, err := s.db.Prepare(`
|
||||
INSERT INTO DescriptiveGrading (source, question_id, user_answer, score, evaluation, username)
|
||||
VALUES (?, ?, ?, ?, ?, 'admin')
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
res, err := stmt.Exec(source, questionID, userAnswer, result.Score, result.Evaluation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lastID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var record DescriptiveGradingRecord
|
||||
err = s.db.QueryRow(`
|
||||
SELECT id, question_id, source, user_answer, score, evaluation, username, strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime')
|
||||
FROM DescriptiveGrading
|
||||
WHERE id = ?
|
||||
`, lastID).Scan(&record.ID, &record.QuestionID, &record.Source, &record.UserAnswer, &record.Score, &record.Evaluation, &record.Username, &record.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user