2a111d21c0
P0 修复: - 详情页正文渲染(content 分段),71 条知识不再空白 - 问答页去登录墙:游客每日 3 次(IP 限流),登录后每日 5 次 - 修正问答配额为真正的每日重置语义 点亮闭环: - KnowledgeLit 模型 + 服务端判题(POST /:id/quiz,答案不下发) - /api/knowledge/stats 星域统计 + 点亮进度 + streak + litIds - AI 批量生成 71 条读后检测题(scripts/generate-quiz.js) 设计系统 2.0: - 70 个统一线性图标替换全部 emoji 图标 - 主题 token mixin 化,修复 H5 暗色级联 bug,MP 跟随系统(darkmode) - 首页星域导航(进度环)、我的页学习者档案、星域列表页重写 - 硬编码色清理,误植 map 页移除 服务端加固: - trust proxy 真实 IP、错误信息脱敏、ask 独立限流、无效 ObjectId 404 - seo.js ESM→CJS(修复容器启动)、AI 导师"维度精灵"人格化
154 lines
5.2 KiB
JavaScript
154 lines
5.2 KiB
JavaScript
/**
|
||
* AI 批量生成知识检测题 —— 为没有 options 的知识生成 1 道四选一检测题
|
||
* 执行: node src/scripts/generate-quiz.js [--dry] [--limit 10]
|
||
*
|
||
* 生成后字段: question / options[{text,isCorrect}] / answer(解析)
|
||
* 幂等:已有 options 的知识自动跳过;单个失败不影响后续
|
||
*/
|
||
const mongoose = require('mongoose')
|
||
const path = require('path')
|
||
require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') })
|
||
|
||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/wdkj'
|
||
|
||
const args = process.argv.slice(2)
|
||
const DRY = args.includes('--dry')
|
||
const LIMIT_ARG = args.find(a => a.startsWith('--limit'))
|
||
const LIMIT = LIMIT_ARG ? parseInt(LIMIT_ARG.split('=')[1] || args[args.indexOf(LIMIT_ARG) + 1]) : 0
|
||
|
||
async function getModels() {
|
||
const { AIModel } = require('../models')
|
||
const active = await AIModel.getActiveModels()
|
||
if (active && active.length) return active
|
||
return [{
|
||
name: 'env-model',
|
||
apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
|
||
apiKey: process.env.OPENAI_API_KEY,
|
||
modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo'
|
||
}]
|
||
}
|
||
|
||
function extractJson(text) {
|
||
if (!text) return null
|
||
let t = text.trim()
|
||
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/)
|
||
if (fence) t = fence[1].trim()
|
||
const start = t.indexOf('{')
|
||
const end = t.lastIndexOf('}')
|
||
if (start === -1 || end === -1) return null
|
||
try {
|
||
return JSON.parse(t.slice(start, end + 1))
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function validate(q) {
|
||
if (!q || typeof q.question !== 'string' || !q.question.trim()) return null
|
||
if (!Array.isArray(q.options) || q.options.length < 3 || q.options.length > 5) return null
|
||
const normalized = q.options.map(o => ({ text: String(o.text || '').trim(), isCorrect: !!o.isCorrect }))
|
||
if (normalized.some(o => !o.text)) return null
|
||
const correctCount = normalized.filter(o => o.isCorrect).length
|
||
if (correctCount !== 1) return null
|
||
return {
|
||
question: q.question.trim(),
|
||
options: normalized,
|
||
answer: String(q.answer || '').trim()
|
||
}
|
||
}
|
||
|
||
async function askAI(models, prompt) {
|
||
for (const model of models) {
|
||
try {
|
||
const headers = { 'Content-Type': 'application/json' }
|
||
if (model.apiKey && model.apiKey !== 'sk-dummy' && model.apiKey !== '***') {
|
||
headers['Authorization'] = `Bearer ${model.apiKey}`
|
||
}
|
||
const controller = new AbortController()
|
||
const timer = setTimeout(() => controller.abort(), 60000)
|
||
let res
|
||
try {
|
||
res = await fetch(model.apiUrl, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify({
|
||
model: model.modelId,
|
||
messages: [
|
||
{ role: 'system', content: '你是知识测验出题官,只输出 JSON,不要输出任何其他文字。' },
|
||
{ role: 'user', content: prompt }
|
||
],
|
||
temperature: 0.4,
|
||
max_tokens: 600
|
||
}),
|
||
signal: controller.signal
|
||
})
|
||
} finally {
|
||
clearTimeout(timer)
|
||
}
|
||
if (!res.ok) continue
|
||
const data = await res.json()
|
||
const content = data.choices?.[0]?.message?.content || ''
|
||
return content
|
||
} catch (e) {
|
||
console.warn(` 模型 ${model.name} 失败: ${e.message}`)
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
async function main() {
|
||
await mongoose.connect(MONGODB_URI)
|
||
console.log('✅ 已连接 MongoDB')
|
||
|
||
const { Knowledge } = require('../models')
|
||
const models = await getModels()
|
||
console.log(`🤖 出题模型: ${models.map(m => m.name).join(', ')}`)
|
||
|
||
const query = {
|
||
status: 'approved',
|
||
$or: [{ options: { $exists: false } }, { options: { $size: 0 } }, { question: { $exists: false } }, { question: null }]
|
||
}
|
||
let items = await Knowledge.find(query).sort({ dim: 1, sortOrder: 1 }).lean()
|
||
if (LIMIT > 0) items = items.slice(0, LIMIT)
|
||
console.log(`📚 待出题知识: ${items.length} 条${DRY ? '(dry 模式,不写库)' : ''}\n`)
|
||
|
||
let ok = 0
|
||
let fail = 0
|
||
for (const item of items) {
|
||
const prompt = `基于以下 AI 知识内容,出一道四选一的中文检测题(考察对核心概念的理解,干扰项要有迷惑性但不歧义)。
|
||
|
||
标题:${item.title}
|
||
内容:${item.content.slice(0, 800)}
|
||
|
||
严格按此 JSON 格式输出:
|
||
{"question":"题干","options":[{"text":"选项A","isCorrect":true},{"text":"选项B","isCorrect":false},{"text":"选项C","isCorrect":false},{"text":"选项D","isCorrect":false}],"answer":"一句话解析为什么正确"}`
|
||
|
||
console.log(`→ [dim${item.dim}] ${item.title}`)
|
||
const raw = await askAI(models, prompt)
|
||
const quiz = validate(extractJson(raw))
|
||
|
||
if (!quiz) {
|
||
console.log(' ✗ 生成或校验失败,跳过')
|
||
fail++
|
||
} else {
|
||
console.log(` ✓ ${quiz.question} (${quiz.options.length} 选项)`)
|
||
if (!DRY) {
|
||
await Knowledge.updateOne(
|
||
{ _id: item._id },
|
||
{ $set: { question: quiz.question, options: quiz.options, answer: quiz.answer } }
|
||
)
|
||
}
|
||
ok++
|
||
}
|
||
await new Promise(r => setTimeout(r, 400))
|
||
}
|
||
|
||
console.log(`\n完成:成功 ${ok},失败 ${fail},共 ${items.length}`)
|
||
await mongoose.disconnect()
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error('执行失败:', err)
|
||
process.exit(1)
|
||
})
|