feat: 参考答案图片上传 + AI提示词分有无标准答案图片两种流程

This commit is contained in:
2026-07-16 10:07:09 +08:00
parent 499a55af77
commit 25b8cfb787
6 changed files with 113 additions and 15 deletions
+2 -1
View File
@@ -12,4 +12,5 @@ build-release/
dist-release/ dist-release/
out-electron/ out-electron/
out-portable/ out-portable/
packaged/ packaged/
test/
+61 -13
View File
@@ -64,8 +64,11 @@ function getDb() {
max_score REAL NOT NULL DEFAULT 100, max_score REAL NOT NULL DEFAULT 100,
reference_answer TEXT NOT NULL DEFAULT '', reference_answer TEXT NOT NULL DEFAULT '',
rubric TEXT NOT NULL DEFAULT '', rubric TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0 sort_order INTEGER NOT NULL DEFAULT 0,
reference_answer_image TEXT NOT NULL DEFAULT ''
)`).run() )`).run()
// migrate: add column for existing DBs
try { db.prepare('ALTER TABLE rubric ADD COLUMN reference_answer_image TEXT NOT NULL DEFAULT \'\'').run() } catch (_) {}
} }
return db return db
} }
@@ -120,17 +123,18 @@ export function setupIpc(win: BrowserWindow): void {
}, 100) }, 100)
}) })
ipcMain.handle('ai:grade', async (_event, { imageBase64, rubric, apiConfig, maxScore, referenceAnswer, questionTitle }: { ipcMain.handle('ai:grade', async (_event, { imageBase64, rubric, apiConfig, maxScore, referenceAnswer, questionTitle, referenceAnswerImage }: {
imageBase64: string imageBase64: string
rubric: string rubric: string
apiConfig: { apiKey: string; baseUrl: string; model: string } apiConfig: { apiKey: string; baseUrl: string; model: string }
maxScore?: number maxScore?: number
referenceAnswer?: string referenceAnswer?: string
questionTitle?: string questionTitle?: string
referenceAnswerImage?: string
}) => { }) => {
log.info('IPC: ai:grade') log.info('IPC: ai:grade')
try { try {
const result = await callAiApi(imageBase64, rubric, apiConfig, { maxScore, referenceAnswer, questionTitle }) const result = await callAiApi(imageBase64, rubric, apiConfig, { maxScore, referenceAnswer, questionTitle, referenceAnswerImage })
return result return result
} catch (err: any) { } catch (err: any) {
log.error('AI grade failed:', err) log.error('AI grade failed:', err)
@@ -263,10 +267,10 @@ export function setupIpc(win: BrowserWindow): void {
log.info(`rubric:storeData: ${questions.length} questions`, JSON.stringify(questions.map((q: any) => ({ title: q.questionTitle, rubricLen: q.rubric?.length })))) log.info(`rubric:storeData: ${questions.length} questions`, JSON.stringify(questions.map((q: any) => ({ title: q.questionTitle, rubricLen: q.rubric?.length }))))
if (questions.length === 0) return if (questions.length === 0) return
d.prepare('DELETE FROM rubric').run() d.prepare('DELETE FROM rubric').run()
const insert = d.prepare('INSERT INTO rubric (question_title, max_score, reference_answer, rubric, sort_order) VALUES (?, ?, ?, ?, ?)') const insert = d.prepare('INSERT INTO rubric (question_title, max_score, reference_answer, rubric, sort_order, reference_answer_image) VALUES (?, ?, ?, ?, ?, ?)')
for (let i = 0; i < questions.length; i++) { for (let i = 0; i < questions.length; i++) {
const q = questions[i] const q = questions[i]
insert.run(q.questionTitle || '', q.maxScore ?? 100, q.referenceAnswer || '', q.rubric || '', i) insert.run(q.questionTitle || '', q.maxScore ?? 100, q.referenceAnswer || '', q.rubric || '', i, q.referenceAnswerImage || '')
} }
}) })
@@ -280,7 +284,8 @@ export function setupIpc(win: BrowserWindow): void {
questionTitle: r.question_title, questionTitle: r.question_title,
maxScore: r.max_score, maxScore: r.max_score,
referenceAnswer: r.reference_answer, referenceAnswer: r.reference_answer,
rubric: r.rubric rubric: r.rubric,
referenceAnswerImage: r.reference_answer_image || undefined
})), })),
currentIndex: 0 currentIndex: 0
} }
@@ -296,7 +301,8 @@ export function setupIpc(win: BrowserWindow): void {
questionTitle: r.question_title, questionTitle: r.question_title,
maxScore: r.max_score, maxScore: r.max_score,
referenceAnswer: r.reference_answer, referenceAnswer: r.reference_answer,
rubric: r.rubric rubric: r.rubric,
referenceAnswerImage: r.reference_answer_image || undefined
})) }))
const result = await dialogBox.showSaveDialog({ const result = await dialogBox.showSaveDialog({
defaultPath: '评分标准.json', defaultPath: '评分标准.json',
@@ -336,7 +342,7 @@ export function setupIpc(win: BrowserWindow): void {
const dialogPayloads: Record<string, any> = {} const dialogPayloads: Record<string, any> = {}
let gradingPayload: any = null let gradingPayload: any = null
async function callAiApi(imageBase64: string, rubric: string, config: { apiKey: string; baseUrl: string; model: string }, extras?: { maxScore?: number; referenceAnswer?: string; questionTitle?: string }): Promise<{ async function callAiApi(imageBase64: string, rubric: string, config: { apiKey: string; baseUrl: string; model: string }, extras?: { maxScore?: number; referenceAnswer?: string; questionTitle?: string; referenceAnswerImage?: string }): Promise<{
score: number score: number
confidence: number confidence: number
deductions: string[] deductions: string[]
@@ -344,7 +350,40 @@ async function callAiApi(imageBase64: string, rubric: string, config: { apiKey:
}> { }> {
const systemRole = `你是高校阅卷专家,严格按照评分标准逐项评分。只输出JSON,不包含任何其他文字。` const systemRole = `你是高校阅卷专家,严格按照评分标准逐项评分。只输出JSON,不包含任何其他文字。`
const prompt = `${extras?.questionTitle ? `【题目】\n${extras.questionTitle}\n` : ''}【评分标准】 const hasRefImage = !!extras?.referenceAnswerImage
const prompt = hasRefImage
? `${extras?.questionTitle ? `【题目】\n${extras.questionTitle}\n` : ''}【评分标准】
${rubric || '无'}
【参考答案】
${extras?.referenceAnswer || '无'}
【满分】
${extras?.maxScore ?? 100}
图片1:学生答案
图片2:标准答案
请按照标准答案(图片2)给学生答案(图片1)评分。
---
【评分规则】
1. 根据评分标准中的各项指标逐项评分,每项给出得分和说明
2. 各评分项得分之和不得超过满分${extras?.maxScore ?? 100}
3. 如果学生答案为空、空白或明显与题目无关,直接给0分
4. 部分正确时按评分标准酌情给分
【输出要求】
只输出以下JSON,不要包含markdown代码块、\`\`\`标记、注释或任何其他文字:
{
"score": <总分>,
"confidence": <置信度0~1>,
"deductions": ["扣分项说明1", "扣分项说明2"],
"comment": "总评语及逐项得分说明"
}`
: `${extras?.questionTitle ? `【题目】\n${extras.questionTitle}\n` : ''}【评分标准】
${rubric || '无'} ${rubric || '无'}
【参考答案】 【参考答案】
@@ -374,6 +413,18 @@ ${extras?.maxScore ?? 100}
}` }`
const axios = require('axios') const axios = require('axios')
const userContent: any[] = hasRefImage
? [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } },
{ type: 'image_url', image_url: { url: `data:image/png;base64,${extras!.referenceAnswerImage}` } }
]
: [
{ type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } },
{ type: 'text', text: prompt }
]
const response = await axios.post(`${config.baseUrl}/chat/completions`, { const response = await axios.post(`${config.baseUrl}/chat/completions`, {
model: config.model, model: config.model,
messages: [ messages: [
@@ -383,10 +434,7 @@ ${extras?.maxScore ?? 100}
}, },
{ {
role: 'user', role: 'user',
content: [ content: userContent
{ type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } },
{ type: 'text', text: prompt }
]
} }
], ],
max_tokens: 4096, max_tokens: 4096,
+1
View File
@@ -113,6 +113,7 @@ async function gradeQuestion(questionIdx: number) {
rubric: q.rubric || `请根据答案内容评分,满分${q.maxScore}`, rubric: q.rubric || `请根据答案内容评分,满分${q.maxScore}`,
maxScore: q.maxScore, maxScore: q.maxScore,
referenceAnswer: q.referenceAnswer, referenceAnswer: q.referenceAnswer,
referenceAnswerImage: q.referenceAnswerImage,
questionTitle: q.questionTitle, questionTitle: q.questionTitle,
apiConfig: { apiConfig: {
apiKey: settingsStore.settings.apiKey, apiKey: settingsStore.settings.apiKey,
+9
View File
@@ -28,6 +28,7 @@
<div class="result-section" v-if="currentQuestion.referenceAnswer"> <div class="result-section" v-if="currentQuestion.referenceAnswer">
<div class="section-title">参考答案</div> <div class="section-title">参考答案</div>
<div class="reference-text">{{ currentQuestion.referenceAnswer }}</div> <div class="reference-text">{{ currentQuestion.referenceAnswer }}</div>
<img v-if="currentQuestion.referenceAnswerImage" class="ref-image" :src="'data:image/png;base64,' + currentQuestion.referenceAnswerImage" />
</div> </div>
<div class="result-score"> <div class="result-score">
@@ -143,6 +144,7 @@ async function gradeAt(idx: number) {
rubric: q.rubric || `请根据答案内容评分,满分${q.maxScore}`, rubric: q.rubric || `请根据答案内容评分,满分${q.maxScore}`,
maxScore: q.maxScore, maxScore: q.maxScore,
referenceAnswer: q.referenceAnswer, referenceAnswer: q.referenceAnswer,
referenceAnswerImage: q.referenceAnswerImage,
questionTitle: q.questionTitle, questionTitle: q.questionTitle,
apiConfig: { apiConfig: {
apiKey: settingsStore.settings.apiKey, apiKey: settingsStore.settings.apiKey,
@@ -353,6 +355,13 @@ async function confirmScore() {
border-radius: 6px; border-radius: 6px;
white-space: pre-wrap; white-space: pre-wrap;
} }
.ref-image {
max-width: 100%;
max-height: 300px;
border-radius: 6px;
margin-top: 8px;
border: 1px solid #e4e7ed;
}
.error-message { .error-message {
background: #fef0f0; background: #fef0f0;
color: #f56c6c; color: #f56c6c;
+38
View File
@@ -25,6 +25,15 @@
</el-form-item> </el-form-item>
<el-form-item label="参考答案"> <el-form-item label="参考答案">
<el-input v-model="questions[activeIndex].referenceAnswer" type="textarea" :rows="3" placeholder="输入参考答案(可选)" /> <el-input v-model="questions[activeIndex].referenceAnswer" type="textarea" :rows="3" placeholder="输入参考答案(可选)" />
<div class="ref-image-section">
<template v-if="questions[activeIndex].referenceAnswerImage">
<div class="ref-image-preview">
<img :src="'data:image/png;base64,' + questions[activeIndex].referenceAnswerImage" />
<el-button size="small" type="danger" text @click="removeRefImage(activeIndex)">删除图片</el-button>
</div>
</template>
<el-button size="small" @click="uploadRefImage(activeIndex)">上传参考答案图片</el-button>
</div>
</el-form-item> </el-form-item>
<el-form-item label="评分标准"> <el-form-item label="评分标准">
<el-input v-model="questions[activeIndex].rubric" type="textarea" :rows="6" placeholder="输入评分标准" /> <el-input v-model="questions[activeIndex].rubric" type="textarea" :rows="6" placeholder="输入评分标准" />
@@ -128,6 +137,18 @@ async function handleImport() {
function handleClose() { function handleClose() {
window.electronAPI?.dialog.close() window.electronAPI?.dialog.close()
} }
async function uploadRefImage(idx: number) {
if (!window.electronAPI) return
const data = await window.electronAPI.dialog.openImage()
if (data) {
questions.value[idx].referenceAnswerImage = data.base64
}
}
function removeRefImage(idx: number) {
delete questions.value[idx].referenceAnswerImage
}
</script> </script>
<style scoped> <style scoped>
@@ -178,4 +199,21 @@ function handleClose() {
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.ref-image-section {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.ref-image-preview {
display: flex;
align-items: center;
gap: 8px;
}
.ref-image-preview img {
max-height: 120px;
max-width: 200px;
border-radius: 4px;
border: 1px solid #e4e7ed;
}
</style> </style>
+2 -1
View File
@@ -3,6 +3,7 @@ export interface QuestionItem {
maxScore: number maxScore: number
referenceAnswer: string referenceAnswer: string
rubric: string rubric: string
referenceAnswerImage?: string
} }
export interface GradeResult { export interface GradeResult {
@@ -56,7 +57,7 @@ export interface ElectronAPI {
onCompleted: (callback: (base64: string) => void) => () => void onCompleted: (callback: (base64: string) => void) => () => void
} }
ai: { ai: {
grade: (data: { imageBase64: string; rubric: string; apiConfig: ApiConfig; maxScore?: number; referenceAnswer?: string; questionTitle?: string }) => Promise<GradeResult> grade: (data: { imageBase64: string; rubric: string; apiConfig: ApiConfig; maxScore?: number; referenceAnswer?: string; questionTitle?: string; referenceAnswerImage?: string }) => Promise<GradeResult>
} }
settings: { settings: {
get: () => Promise<AppSettings> get: () => Promise<AppSettings>