refactor: 后端从 Django 换成 Bun + SQLite
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
Deploy / deploy (build:staging, school, 8822) (push) Canceled after 0s

Django 多个 worker 内存占用 1-2 GB,换成 Bun 单进程后约 25 MB,
同时去掉 Postgres 和 Redis,只剩 Caddy + Bun 两个容器。

- server/:Hono + bun:sqlite + Bun 原生 WebSocket,接口与原 django-ninja
  保持兼容;表结构、时间/UUID 存储格式、密码哈希与 Django 一致,旧数据可直接使用
- 评分加权、贝叶斯平滑、成绩册的计算结果与原实现逐位一致(复刻 Python round/sum)
- scripts/:Postgres → SQLite 迁移、新建超管、提示词层级标注、重算分数
- 前端:Django Admin 去掉后,用户新建/修改改为页面内弹窗;
  非超管的挑战公开按钮置灰
- 权限不足返回 JSON 403(原来是重定向到不存在的登录页)
- docker-compose / Caddyfile / dev.sh / CLAUDE.md 更新,删除 AGENTS.md
- 服务器切换步骤见 docs/migrate-to-bun.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 17:45:09 +08:00
1 parent 47d4679984
commit 26f05d1d76
297 files changed
+4659 -42956

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
import { run } from "../db"
// Django 的 on_delete=CASCADE 是在应用层做的,这里手动按依赖顺序删除。
// 调用方负责包在事务里。
export function deleteSubmissions(where: string, params: Record<string, unknown>) {
const ids = `SELECT id FROM submission_submission WHERE ${where}`
run(`DELETE FROM submission_rating WHERE submission_id IN (${ids})`, params)
run(`DELETE FROM submission_submissionaward WHERE submission_id IN (${ids})`, params)
run(`DELETE FROM prompt_message WHERE submission_id IN (${ids})`, params)
run(`DELETE FROM submission_submission WHERE ${where}`, params)
}
export function deleteTask(taskId: number) {
const p = { task_id: taskId }
deleteSubmissions("task_id = $task_id", p)
run(
`DELETE FROM prompt_message WHERE conversation_id IN
(SELECT id FROM prompt_conversation WHERE task_id = $task_id)`,
p,
)
run(`DELETE FROM prompt_conversation WHERE task_id = $task_id`, p)
run(`DELETE FROM task_taskasset WHERE task_id = $task_id`, p)
run(`DELETE FROM task_tutorial WHERE task_ptr_id = $task_id`, p)
run(`DELETE FROM task_challenge WHERE task_ptr_id = $task_id`, p)
run(`DELETE FROM task_creation WHERE task_ptr_id = $task_id`, p)
run(`DELETE FROM task_task WHERE id = $task_id`, p)
}
+79
View File
@@ -0,0 +1,79 @@
import { all, get, run } from "../db"
import { complete, llmConfigured } from "./llm"
const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。根据布鲁姆认知分类学,分析以下学生在前端学习中发送给AI助手的一条提示词,判断该提示词所体现的认知层级。
层级定义:
- L1 记忆:能背诵HTML标签语法(例:"帮我写一个按钮")
- L2 理解:能解释flex布局原理(例:"为什么这里不居中?")
- L3 应用:能独立搭建页面结构(例:"用flex做导航栏,间距16px")
- L4 分析:能定位跨浏览器兼容性bug(例:"Safari中margin失效,原因?")
- L5 评价:能对比并选择方案(例:"对比Grid与Flex方案优劣")
- L6 创造:能设计并实现原创交互作品(例:"设计夜间/日间切换效果")
只返回一个数字(1-6),不要解释。`
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
async function callLlm(content: string): Promise<number | null> {
if (!llmConfigured()) return null
try {
const text = await complete(
[
{ role: "system", content: CLASSIFY_SYSTEM_PROMPT },
{ role: "user", content },
],
{ maxTokens: 10, timeoutMs: 30_000 },
)
const match = /[1-6]/.exec(text)
if (!match) {
console.warn(`classify: unexpected LLM response '${text}'`)
return null
}
return Number(match[0])
} catch (e) {
console.error("classify LLM call failed:", e)
return null
}
}
export async function classifyMessage(messageId: number): Promise<number | null> {
const msg = get<{ content: string }>(
`SELECT content FROM prompt_message WHERE id = $id AND role = 'user'`,
{ id: messageId },
)
if (!msg) return null
const level = await callLlm(msg.content)
if (level !== null) {
run(`UPDATE prompt_message SET prompt_level = $level WHERE id = $id`, {
level,
id: messageId,
})
}
return level
}
export async function classifyConversationMessages(conversationId: string, force = false) {
const rows = all<{ id: number }>(
`SELECT id FROM prompt_message
WHERE conversation_id = $id AND role = 'user' ${force ? "" : "AND prompt_level IS NULL"}
ORDER BY created, id`,
{ id: conversationId },
)
for (const { id } of rows) {
await classifyMessage(id)
await sleep(300)
}
}
export async function classifyMessagesBatch(ids: number[]) {
for (const id of ids) {
await classifyMessage(id)
await sleep(500)
}
}
// 后台执行,不阻塞请求
export function inBackground(p: Promise<unknown>) {
p.catch((e) => console.error("background task failed:", e))
}
+306
View File
@@ -0,0 +1,306 @@
import { all } from "../db"
import { uuidOut } from "../db"
import { HttpError } from "../http"
import { pyRound, pySum } from "./pyround"
export type GradebookTaskType = "tutorial" | "challenge"
type Grade = "A" | "B" | "C" | "D" | "E"
export interface GradebookFilters {
classname: string
task_type?: GradebookTaskType
username?: string
include_all_tasks?: boolean
}
interface GradebookTask {
id: number
display: number
title: string
task_type: string
submitted_count: number
coverage: number
included: boolean
}
interface GradebookRow {
user_id: number
username: string
classname: string
rank: number
grade: Grade
assessment_grade: Grade
assessment_score: number
top3_score: number
scores: Record<number, { score: number; submitted: boolean; submission_id: string | null }>
tutorial_total: number
challenge_total: number
total_score: number
average_score: number | null
submitted_task_count: number
missing_task_count: number
}
export interface Gradebook {
classname: string
classes: string[]
task_count: number
included_task_count: number
student_count: number
coverage_threshold_count: number
tasks: GradebookTask[]
rows: GradebookRow[]
}
const score = (value: number | null | undefined) => pyRound(value || 0, 2)
// Python 字符串比较(按码点),不能用 localeCompare
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function gradeForRank(rank: number, n: number): Grade {
if (rank <= Math.ceil(0.3 * n)) return "A"
if (rank <= Math.ceil(0.7 * n)) return "B"
if (rank <= Math.ceil(0.9 * n)) return "C"
if (rank <= Math.ceil(0.95 * n)) return "D"
return "E"
}
// 考核等级:A10% B20% C40% D20% E10%,按最高三次提交均分排名
function assessmentGradeForRank(rank: number, n: number): Grade {
if (rank <= Math.ceil(0.1 * n)) return "A"
if (rank <= Math.ceil(0.3 * n)) return "B"
if (rank <= Math.ceil(0.7 * n)) return "C"
if (rank <= Math.ceil(0.9 * n)) return "D"
return "E"
}
// 考核分数:各等级对应的分数段(高分→低分),段内按名次线性铺开;最高分封顶 99
const ASSESSMENT_SCORE_BANDS: Record<Grade, [number, number]> = {
A: [92, 99],
B: [84, 91],
C: [76, 83],
D: [68, 75],
E: [60, 67],
}
function assessmentScore(indexInGrade: number, gradeSize: number, grade: Grade) {
const [low, high] = ASSESSMENT_SCORE_BANDS[grade]
if (gradeSize <= 1) return high
// 段内第 1 名拿段顶,最后 1 名拿段底
return pyRound(high - (indexInGrade / (gradeSize - 1)) * (high - low))
}
export function buildGradebook(filters: GradebookFilters): Gradebook {
const classname = filters.classname?.trim() ?? ""
if (!classname) throw new HttpError(400, "请选择班级")
const classes = all<{ classname: string }>(
`SELECT DISTINCT classname FROM account_user
WHERE role IN ('normal', 'admin') AND classname != '' ORDER BY classname`,
).map((r) => r.classname)
const students = all<{ id: number; username: string; classname: string }>(
`SELECT id, username, classname FROM account_user
WHERE role IN ('normal', 'admin') AND classname = $classname
ORDER BY username, id`,
{ classname },
)
const studentCount = students.length
const coverageThreshold = studentCount ? Math.ceil(studentCount * 0.5) : 0
const typeFilter = filters.task_type ? `AND t.task_type = $task_type` : ""
const typeParams = filters.task_type ? { task_type: filters.task_type } : {}
const taskRows = all<{ id: number; display: number; title: string; task_type: string; submitted_count: number }>(
`SELECT t.id, t.display, t.title, t.task_type, COUNT(DISTINCT s.user_id) AS submitted_count
FROM submission_submission s
JOIN task_task t ON t.id = s.task_id
JOIN account_user u ON u.id = s.user_id
WHERE u.role IN ('normal', 'admin') AND u.classname = $classname ${typeFilter}
GROUP BY t.id`,
{ classname, ...typeParams },
)
taskRows.sort(
(a, b) =>
(a.task_type === "tutorial" ? 0 : 1) - (b.task_type === "tutorial" ? 0 : 1) ||
a.display - b.display ||
a.id - b.id,
)
const allTasks: GradebookTask[] = taskRows.map((t) => ({
id: t.id,
display: t.display,
title: t.title,
task_type: t.task_type,
submitted_count: t.submitted_count,
coverage: studentCount ? score(t.submitted_count / studentCount) : 0,
included: studentCount > 0 && t.submitted_count >= coverageThreshold,
}))
const tasks = allTasks.filter((t) => filters.include_all_tasks || t.included)
const visibleTaskIds = new Set(tasks.map((t) => t.id))
const includedTaskCount = tasks.filter((t) => t.included).length
// 每个 (学生, 任务) 取最高分的提交;同分取最新
const best = new Map<string, { id: string; score: number }>()
if (studentCount && visibleTaskIds.size) {
const subs = all<{ id: string; user_id: number; task_id: number; score: number }>(
`SELECT s.id, s.user_id, s.task_id, s.score
FROM submission_submission s JOIN account_user u ON u.id = s.user_id
WHERE u.role IN ('normal', 'admin') AND u.classname = $classname
ORDER BY s.user_id, s.task_id, s.score DESC, s.created DESC, s.id DESC`,
{ classname },
)
for (const s of subs) {
if (!visibleTaskIds.has(s.task_id)) continue
const key = `${s.user_id}:${s.task_id}`
if (!best.has(key)) best.set(key, s)
}
}
let rows: GradebookRow[] = students.map((student) => {
const scores: GradebookRow["scores"] = {}
let tutorialTotal = 0
let challengeTotal = 0
const submittedScores: number[] = []
let submittedTaskCount = 0
let missingTaskCount = 0
for (const task of tasks) {
const sub = best.get(`${student.id}:${task.id}`)
const cellScore = sub ? score(sub.score) : 0
scores[task.id] = {
score: cellScore,
submitted: !!sub,
submission_id: sub ? uuidOut(sub.id) : null,
}
if (!task.included) continue
if (sub) {
submittedTaskCount++
submittedScores.push(cellScore)
} else {
missingTaskCount++
}
if (task.task_type === "tutorial") tutorialTotal += cellScore
else challengeTotal += cellScore
}
tutorialTotal = score(tutorialTotal)
challengeTotal = score(challengeTotal)
const top3 = submittedScores.sort((a, b) => b - a).slice(0, 3)
const top3Score = top3.length ? score(pySum(top3) / top3.length) : 0
const totalScore = score(tutorialTotal + challengeTotal)
return {
user_id: student.id,
username: student.username,
classname: student.classname,
rank: 0,
grade: "E",
assessment_grade: "E",
assessment_score: 60,
top3_score: top3Score,
scores,
tutorial_total: tutorialTotal,
challenge_total: challengeTotal,
total_score: totalScore,
average_score: includedTaskCount ? score(totalScore / includedTaskCount) : null,
submitted_task_count: submittedTaskCount,
missing_task_count: missingTaskCount,
}
})
rows.sort((a, b) => b.total_score - a.total_score || cmp(a.username, b.username))
rows.forEach((row, i) => {
row.rank = i + 1
row.grade = gradeForRank(i + 1, studentCount)
})
// 考核等级:按最高三次提交均分单独排名后定级(不改变上面的总分排名)
const assessmentOrder = [...rows].sort(
(a, b) => b.top3_score - a.top3_score || cmp(a.username, b.username),
)
assessmentOrder.forEach((row, i) => {
row.assessment_grade = assessmentGradeForRank(i + 1, studentCount)
})
// 考核分数:在各等级段内按名次线性铺开到对应分数段
const groups = new Map<Grade, GradebookRow[]>()
for (const row of assessmentOrder) {
const g = groups.get(row.assessment_grade) ?? []
g.push(row)
groups.set(row.assessment_grade, g)
}
for (const [grade, group] of groups) {
group.forEach((row, i) => {
row.assessment_score = assessmentScore(i, group.length, grade)
})
}
const username = filters.username?.trim().toLowerCase() ?? ""
if (username) rows = rows.filter((r) => r.username.toLowerCase().includes(username))
return {
classname,
classes,
task_count: tasks.length,
included_task_count: includedTaskCount,
student_count: studentCount,
coverage_threshold_count: coverageThreshold,
tasks,
rows,
}
}
function csvNumber(value: number) {
if (Number.isInteger(value)) return String(value)
return pyRound(value, 2).toFixed(2).replace(/0+$/, "").replace(/\.$/, "")
}
const taskTypeLabel = (t: string) => (t === "tutorial" ? "教程" : "挑战")
export function gradebookCsvRows(gb: Gradebook): (string | number)[][] {
const rows: (string | number)[][] = [
[
"排名",
"平时等级",
"考核等级",
"考核分数",
"用户名",
"班级",
...gb.tasks.map((t) => `${taskTypeLabel(t.task_type)}${t.display}-${t.title}`),
"教程合计",
"挑战合计",
"总分",
"平均分",
"已提交任务数",
"未提交任务数",
],
]
for (const row of gb.rows) {
rows.push([
row.rank,
row.grade,
row.assessment_grade,
row.assessment_score,
row.username,
row.classname,
...gb.tasks.map((t) => csvNumber(row.scores[t.id]!.score)),
csvNumber(row.tutorial_total),
csvNumber(row.challenge_total),
csvNumber(row.total_score),
row.average_score === null ? "" : csvNumber(row.average_score),
row.submitted_task_count,
row.missing_task_count,
])
}
return rows
}
// 与 Python csv 模块默认行为一致:含逗号/引号/换行时加引号,行尾 \r\n
export function toCsv(rows: (string | number)[][]) {
const cell = (v: string | number) => {
const s = String(v)
return /[",\r\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s
}
return rows.map((r) => r.map(cell).join(",")).join("\r\n") + "\r\n"
}
+202
View File
@@ -0,0 +1,202 @@
import { config } from "../config"
export const SYSTEM_PROMPT = `你是一个网页生成助手。根据用户的需求描述,生成网页代码。
规则:
1. 使用一个 \`\`\`html 代码块返回所有代码
2. HTML 代码只需要 body 内的内容,不需要完整的 HTML 文档结构
3. CSS 样式写在 <style> 标签内,JavaScript 写在 <script> 标签内,都放在代码块里
4. 用中文回复,先简要说明你做了什么,然后给出代码
5. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
6. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
输出格式示例(必须严格遵守,只用一个代码块):
好的,我为你创建了一个点击按钮变色的示例。
\`\`\`html
<style>
button { padding: 8px 16px; }
</style>
<button id="btn">点击我</button>
<script>
document.getElementById('btn').onclick = function() {
this.style.background = 'red';
};
</script>
\`\`\``
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。
判断标准——满足以下条件视为"够好":
- 有明确主题(例如:登录页、计时器、商品卡片列表)
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)
判断为"不够好"的典型情况:
- 目标太泛,如"做一个好看的页面"
- 只有主题,完全没有任何视觉、交互或内容描述
规则:
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
3. 用中文回复,语气鼓励,简洁明了
4. 使用 Markdown 语法高亮关键词,优先突出 **主题**、**视觉**、**交互**、**内容**、**可以生成** 等重点
5. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
6. 不要生成任何代码`
const DEFAULT_MODEL = "deepseek-v4-flash"
const MODEL_ALIASES: Record<string, string> = {
"deepseek-v4-flash-thinking": DEFAULT_MODEL,
}
const NON_THINKING_MODELS = new Set(["deepseek-v4-flash"])
// 走火山方舟(ARK)接口的模型
const ARK_MODELS = new Set(["doubao-seed-2-0-lite-260215"])
export interface ChatMessage {
role: "system" | "user" | "assistant"
content: string
}
interface CompletionOptions {
model?: string
stream: boolean
maxTokens?: number
signal?: AbortSignal
timeoutMs?: number
}
export const llmConfigured = () => !!config.llmApiKey
function buildRequest(messages: ChatMessage[], opts: CompletionOptions) {
const requested = opts.model || DEFAULT_MODEL
const resolved = MODEL_ALIASES[requested] ?? requested
const ark = ARK_MODELS.has(resolved)
const body: Record<string, unknown> = {
model: resolved,
messages,
stream: opts.stream,
}
if (opts.maxTokens) body.max_tokens = opts.maxTokens
if (NON_THINKING_MODELS.has(resolved) && !(requested in MODEL_ALIASES)) {
body.thinking = { type: "disabled" }
}
const apiKey = ark ? config.arkApiKey : config.llmApiKey
if (!apiKey) throw new Error("未配置模型接口密钥")
const baseUrl = (ark ? config.arkBaseUrl : config.llmBaseUrl).replace(/\/$/, "")
const signals = [AbortSignal.timeout(opts.timeoutMs ?? 120_000)]
if (opts.signal) signals.push(opts.signal)
return fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal: AbortSignal.any(signals),
})
}
async function ensureOk(res: Response) {
if (res.ok) return
const text = await res.text().catch(() => "")
throw new Error(`Error code: ${res.status} - ${text.slice(0, 500)}`)
}
// 读取 OpenAI 兼容接口的 SSE 流,逐段产出文本
async function* streamCompletion(
messages: ChatMessage[],
model?: string,
signal?: AbortSignal,
): AsyncGenerator<string> {
const res = await buildRequest(messages, { model, stream: true, signal })
await ensureOk(res)
if (!res.body) return
const decoder = new TextDecoder()
let buffer = ""
for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true })
let idx: number
while ((idx = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx + 1)
if (!line.startsWith("data:")) continue
const data = line.slice(5).trim()
if (data === "[DONE]") return
const json = JSON.parse(data)
if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error))
const content = json.choices?.[0]?.delta?.content
if (content) yield content
}
}
}
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal) {
return streamCompletion([{ role: "system", content: SYSTEM_PROMPT }, ...history], model, signal)
}
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
return streamCompletion(
[{ role: "system", content: GUIDANCE_SYSTEM_PROMPT }, ...history],
undefined,
signal,
)
}
export async function complete(
messages: ChatMessage[],
opts: { model?: string; maxTokens?: number; timeoutMs?: number } = {},
): Promise<string> {
const res = await buildRequest(messages, {
model: opts.model ?? config.llmModel,
stream: false,
maxTokens: opts.maxTokens,
timeoutMs: opts.timeoutMs,
})
await ensureOk(res)
const json: any = await res.json()
return json.choices?.[0]?.message?.content ?? ""
}
export interface ExtractedCode {
html: string | null
css: string | null
js: string | null
}
// 从 AI 回复中提取代码:支持单个 html 代码块(新)或分开的 html/css/js 代码块(旧)
export function extractCode(text: string): ExtractedCode {
const result: ExtractedCode = { html: null, css: null, js: null }
const pattern = /```(html|css|js|javascript|typescript|ts|jsx|tsx)\s*\n([\s\S]*?)```/gi
for (const [, rawLang, code] of text.matchAll(pattern)) {
let lang = rawLang!.toLowerCase()
if (["javascript", "typescript", "ts", "jsx", "tsx"].includes(lang)) lang = "js"
const key = lang as keyof ExtractedCode
if (key in result && result[key] === null) result[key] = code!.trim()
}
// 单个 HTML 代码块:把 <style>/<script> 的内容拆出来,并从 html 中去掉
if (result.html && result.css === null && result.js === null) {
let html = result.html
const style = /<style[^>]*>([\s\S]*?)<\/style>/i.exec(html)
if (style) {
result.css = style[1]!.trim()
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
}
const script = /<script[^>]*>([\s\S]*?)<\/script>/i.exec(html)
if (script) {
result.js = script[1]!.trim()
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
}
result.html = html.trim()
}
return result
}
export function parseGuidanceResponse(full: string): [string, boolean] {
if (full.startsWith("[READY]")) {
return [full.slice("[READY]".length).replace(/^\n+/, ""), true]
}
return [full, false]
}
+126
View File
@@ -0,0 +1,126 @@
import { all, get, run, now, newUuidHex } from "../db"
import { Role, type User } from "../auth"
export interface MessageRow {
id: number
conversation_id: string
role: string
source: string
content: string
code_html: string | null
code_css: string | null
code_js: string | null
created: string
prompt_level: number | null
submission_id: string | null
}
// 自由创作只有创作者本人(和超管)能对话,其他任务对所有登录用户开放
export function canAccessTask(user: User, taskId: number) {
const row = get<{ owner_id: number }>(
`SELECT owner_id FROM task_creation WHERE task_ptr_id = $id`,
{ id: taskId },
)
if (!row) return true
return row.owner_id === user.id || user.role === Role.SUPER
}
// 当前用户在该任务下消息最多的那个对话
export function getActiveConversation(userId: number, taskId: number) {
return get<{ id: string }>(
`SELECT c.id FROM prompt_conversation c
WHERE c.user_id = $user_id AND c.task_id = $task_id
ORDER BY (SELECT COUNT(*) FROM prompt_message m WHERE m.conversation_id = c.id) DESC,
c.created DESC
LIMIT 1`,
{ user_id: userId, task_id: taskId },
)?.id
}
export function createConversation(userId: number, taskId: number, isActive = true) {
const id = newUuidHex()
const ts = now()
run(
`INSERT INTO prompt_conversation (id, created, modified, is_active, task_id, user_id)
VALUES ($id, $ts, $ts, $active, $task_id, $user_id)`,
{ id, ts, active: isActive, task_id: taskId, user_id: userId },
)
return id
}
export function getOrCreateActiveConversation(userId: number, taskId: number) {
return getActiveConversation(userId, taskId) ?? createConversation(userId, taskId)
}
export function insertMessage(m: {
conversation_id: string
role: string
content: string
source?: string
code_html?: string | null
code_css?: string | null
code_js?: string | null
submission_id?: string | null
}): number {
const res = run(
`INSERT INTO prompt_message
(conversation_id, role, source, content, code_html, code_css, code_js, created, submission_id)
VALUES ($conversation_id, $role, $source, $content, $code_html, $code_css, $code_js, $created, $submission_id)`,
{
conversation_id: m.conversation_id,
role: m.role,
source: m.source ?? "conversation",
content: m.content,
code_html: m.code_html ?? null,
code_css: m.code_css ?? null,
code_js: m.code_js ?? null,
created: now(),
submission_id: m.submission_id ?? null,
},
)
return Number(res.lastInsertRowid)
}
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
return get<MessageRow>(
`SELECT * FROM prompt_message
WHERE conversation_id = $conv AND role = 'user'
AND (created < $created OR (created = $created AND id < $id))
ORDER BY created DESC, id DESC LIMIT 1`,
{ conv: asst.conversation_id, created: asst.created, id: asst.id },
)
}
export function buildPromptRounds(source: MessageRow) {
let messages = all<MessageRow>(
`SELECT * FROM prompt_message WHERE conversation_id = $conv ORDER BY created, id`,
{ conv: source.conversation_id },
)
let idx = messages.findIndex((m) => m.id === source.id)
if (idx < 0) idx = messages.length - 1
messages = messages.slice(0, idx + 1)
const rounds = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]!
if (msg.role !== "user") continue
let reply: MessageRow | undefined
for (const r of messages.slice(i + 1)) {
if (r.role === "user") break
if (r.role === "assistant") {
reply = r
break
}
}
rounds.push({
question: msg.content,
source: msg.source,
prompt_level: msg.prompt_level,
assistant_msg_id: reply?.id ?? null,
html: reply?.code_html ?? null,
css: reply?.code_css ?? null,
js: reply?.code_js ?? null,
})
}
return rounds
}
+27
View File
@@ -0,0 +1,27 @@
/**
* 与 Python 的 round(x, n) 结果一致:按浮点数的精确值舍入,正好在中间时取偶数(银行家舍入)。
* Number#toFixed 同样按精确值舍入,只是“正好在中间”时向上取,所以只需单独处理这种情况。
* 十进制 n 位小数的精确中点必然形如 奇数 / 2^(n+1),据此判断。
*/
export function pyRound(x: number, ndigits = 0): number {
const t = x * 2 ** (ndigits + 1)
if (Number.isInteger(t) && Math.abs(t) % 2 === 1) {
const scale = 10 ** ndigits
const floor = Math.floor(x * scale)
return (floor % 2 === 0 ? floor : floor + 1) / scale
}
return Number(x.toFixed(ndigits))
}
/** 与 Python 3.12+ 的 sum() 一致:对浮点数做 Neumaier 补偿求和 */
export function pySum(values: Iterable<number>): number {
let sum = 0
let c = 0
for (const x of values) {
const t = sum + x
if (Math.abs(sum) >= Math.abs(x)) c += sum - t + x
else c += x - t + sum
sum = t
}
return c && Number.isFinite(c) ? sum + c : sum
}
+51
View File
@@ -0,0 +1,51 @@
import { all, get, run } from "../db"
import { Role } from "../auth"
import { pySum } from "./pyround"
const ROLE_WEIGHT: Record<string, number> = {
[Role.SUPER]: 0.5,
[Role.ADMIN]: 0.3,
}
function zoneFor(n: number, score: number): string | null {
if (n < 5) return "pending"
if (score >= 4.0) return "featured"
if (score < 3.0) return "low"
return null
}
/**
* 重新计算提交的分数:按角色加权平均得到 raw_score,
* 再用贝叶斯平均(C=3,全站平均分做先验)平滑得到 score,并更新分区。
*/
export function updateSubmissionScore(submissionId: string) {
const ratings = all<{ score: number; role: string }>(
`SELECT r.score, u.role FROM submission_rating r
JOIN account_user u ON u.id = r.user_id
WHERE r.submission_id = $id ORDER BY r.id`,
{ id: submissionId },
)
const n = ratings.length
let rawScore = 0
let score = 0
if (n > 0) {
const weights = ratings.map((r) => ROLE_WEIGHT[r.role] ?? 0.2)
rawScore = pySum(ratings.map((r, i) => r.score * weights[i]!)) / pySum(weights)
const C = 3
const mean = get<{ avg: number | null }>(
`SELECT AVG(raw_score) AS avg FROM submission_submission
WHERE raw_score > 0 AND id != $id`,
{ id: submissionId },
)?.avg
const globalMean = mean || rawScore
score = (C * globalMean + n * rawScore) / (C + n)
}
run(
`UPDATE submission_submission
SET raw_score = $raw, score = $score, zone = $zone WHERE id = $id`,
{ raw: rawScore, score, zone: zoneFor(n, score), id: submissionId },
)
}