feat: 参考答案图片上传 + AI提示词分有无标准答案图片两种流程
This commit is contained in:
+2
-1
@@ -12,4 +12,5 @@ build-release/
|
|||||||
dist-release/
|
dist-release/
|
||||||
out-electron/
|
out-electron/
|
||||||
out-portable/
|
out-portable/
|
||||||
packaged/
|
packaged/
|
||||||
|
test/
|
||||||
+61
-13
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user