feat: AI 回复说明替学生定了什么并问一个问题,“帮我写清楚”能看到题目和之前的对话
Deploy / deploy (build, debian, 22) (push) Canceled after 0s

- 生成回复分“做了什么 / 我替你定的 / 想一想”三项;只问问题时直接回答、不给代码
- 没有代码的回复发 code: null,不再清空网页;上下文保留最近一份带代码的回复
- 教练带上题目要求、最近的提问和 AI 上一次的说明,照搬题目不算写清楚

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-27 21:36:06 +08:00
1 parent 9fee688d95
commit 43c18cdb88
7 files changed
+155 -41

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
- `src/auth.ts` — Django-compatible password hashing; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `requireUser/requireAdmin/requireSuper` (401 / 403)
- `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `review` (mounted under `/submission/review`: teacher class × task wall — no `classname` means all classes —, students' peer works with `scope=class|all`), `prompt`
- Who can read a submission (`canView` in `routes/submission.ts`): staff, the owner, anyone if it's in the showcase, or any student who has submitted the same task (any class). Students' `GET /submission/` only lists their own. Peer works and the random-rating item are anonymous until the viewer rates them (`PUT /:id/score` returns the author). `flag` now only carries `present` (teacher's in-class presentation queue); the old four colors are no longer shown
- `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models; chat context is the last 20 messages with older code blocks elided), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background; the model sees the task brief and the previous prompt, temperature 0, prompts under 4 chars get no level), `cascade.ts` (Django's CASCADE deletes done by hand, plus the shared delete rules: `hasRatingsOrAwards`, `deleteRound`), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit)
- `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models; chat context is the last 20 messages with code kept only in the newest reply that has code. Chat replies are written as **做了什么 / 我替你定的 / 想一想** — the decisions the AI made for the student and one open question, to prompt the next round; a reply to a plain question has no code and the WebSocket sends `code: null`. The “帮我写清楚” coach gets the task brief, recent prompts and the last AI explanation from `guidanceContext` in `services/prompt.ts`, and judges by what the student added beyond the brief, like the classifier), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background; the model sees the task brief and the previous prompt, temperature 0, prompts under 4 chars get no level), `cascade.ts` (Django's CASCADE deletes done by hand, plus the shared delete rules: `hasRatingsOrAwards`, `deleteRound`), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit)
- `src/ws.ts` — `/ws/prompt/<task_id>/?token=<access_token>` (code generation chat; the AI context and the page's chat include `conversation`, `edit` and `manual` messages) and `/ws/guidance/<task_id>/?token=...` (prompt coaching); one message per connection at a time (a message sent while the AI is still replying gets an error, payloads capped at 64 KB); on disconnect the pending user message is deleted and the LLM request aborted
- `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
+1 -10
View File
@@ -1,5 +1,6 @@
import { all, get, run } from "../db"
import { complete, llmConfigured } from "./llm"
import { briefOf } from "./prompt"
// 学生在这里是“指挥 AI 做网页”,看的是这句提问里学生自己想了多少,不是要 AI 做的东西有多大:
// 把题目要求原样贴过去,功能列得再多也只是照着做(L3)
@@ -24,7 +25,6 @@ const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。学生在网页
只返回一个数字(1-6),不要解释。`
const MIN_PROMPT_CHARS = 4
const BRIEF_CHARS = 800
const PROMPT_CHARS = 1500
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
@@ -52,15 +52,6 @@ async function callLlm(content: string): Promise<number | null> {
}
}
// 题目要求去掉图片、压成一段,太长的截掉
function briefOf(content: string) {
const text = content
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\s+/g, " ")
.trim()
return text.length > BRIEF_CHARS ? text.slice(0, BRIEF_CHARS) + "……" : text
}
function clip(text: string) {
return text.length > PROMPT_CHARS ? text.slice(0, PROMPT_CHARS) + "……" : text
}
+40 -24
View File
@@ -1,18 +1,29 @@
import { config } from "../config"
export const SYSTEM_PROMPT = `你是一个网页生成助手。根据用户的需求描述,生成网页代码。
// AI 回复里的说明是学生唯一会读的文字,用它引导学生想下一步:
// “我替你定的”指出学生没说、AI 自己拿的主意,学生看了才会说“不要这样,因为……”;“想一想”只问不答
export const SYSTEM_PROMPT = `你是一个网页生成助手,和学生一起做网页。根据学生的描述生成网页代码。
规则:
1. 使用一个 \`\`\`html 代码块返回所有代码
1. 生成或修改网页时,使用一个 \`\`\`html 代码块返回所有代码
2. HTML 代码只需要 body 内的内容,不需要完整的 HTML 文档结构
3. CSS 样式写在 <style> 标签内,JavaScript 写在 <script> 标签内,都放在代码块里
4. 用中文回复,先简要说明你做了什么,然后给出代码
5. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
6. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
4. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
5. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
6. 用中文回复,对学生称“你”。先写说明,再给代码。说明分下面三项,标题照抄,每项一两句话,不要写长:
- **做了什么**:这次按学生的要求做了哪些改动
- **我替你定的**:学生没说、你自己拿主意的地方,比如配色、布局、规则、数值、文字内容;列 1-3 条,只写在网页上看得到、学生可以要求改的。学生这次已经说清楚、没有需要你拿主意的地方,就省略这一项
- **想一想**:问学生一个问题,帮他想清楚下一步想要什么。问他自己的想法或判断(如“……的时候你希望它怎样?”“这两种你觉得哪个更适合……,为什么?”),不要列一串功能让他挑,也不要替他回答
7. 学生只是在问问题(为什么、是什么意思、怎么做到的),没有要求改网页时,用浅显的话直接回答,不给代码,也不用分三项
8. 学生只说“不行”“有 bug”“没反应”,没说清哪里不对时,照样尽力修好,并在“想一想”里请他下次说清楚:做了什么操作、想看到什么、实际看到了什么
输出格式示例(必须严格遵守,只用一个代码块):
好的,我为你创建了一个点击按钮变色的示例。
**做了什么**:做了一个按钮,点一下会变成红色。
**我替你定的**:按钮放在页面左上角;变色后不会再变回来。
**想一想**:再点一次的时候,你希望它变回原来的颜色,还是换成别的颜色?
\`\`\`html
<style>
@@ -28,23 +39,27 @@ document.getElementById('btn').onclick = function() {
</script>
\`\`\``
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。
// 判断标准和分类器一致:看学生自己想了什么,照搬题目写得再长也不算清楚
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练。学生在网页制作课上让 AI 生成或修改网页,你帮他把这一次要对 AI 说的话想清楚、说具体。后面会给你题目要求、他之前对 AI 说过的话和 AI 上一次的说明。
判断标准——满足以下条件视为"够好":
- 有明确主题(例如:登录页、计时器、商品卡片列表)
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)
判断标准——看学生这一次的话,满足下面一条就算"够好":
- 第一次描述作品:除了题目要求,有他自己的想法,至少有一处具体的视觉、交互或内容是他自己定的(如配色风格、玩法规则、页面上放什么)
- 修改已有网页:说清楚要改哪里、改成什么样;或者说清楚出了什么问题——做了什么操作、想看到什么、实际看到了什么
判断为"不够好"的典型情况:
- 目标太泛,如"做一个好看的页面"
- 只有主题,完全没有任何视觉、交互或内容描述
- 只有作品名称,如"做一个象棋游戏"
- 照搬或改写题目要求,没有自己的想法——哪怕写得很长、功能很多
- 只说"不行""有 bug""再好看一点",没说是哪里、要怎样
规则:
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
3. 用中文回复,语气鼓励,简洁明了
4. 使用 Markdown 语法高亮关键词,优先突出 **主题**、**视觉**、**交互**、**内容**、**可以生成** 等重点
5. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
6. 不要生成任何代码`
1. 如果不够好,用 1-2 个启发性问题引导学生补充,问他自己的想法,不要直接给出答案,也不要列一串功能让他挑
2. 照搬题目要求时,先肯定他读懂了题目,再问题目之外他想让作品有什么不一样
3. AI 上一次的说明里如果有“我替你定的”,可以问他同不同意这些决定
4. 如果已经够好,以 [READY] 开头回复,简短说明好在哪里,告诉他可以生成了;他还给了理由(为什么这样改)的,特别夸一下
5. 用中文回复,语气鼓励,简洁明了
6. 使用 Markdown 语法高亮关键词,优先突出 **视觉**、**交互**、**内容**、**你的想法**、**可以生成** 等重点
7. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
8. 不要生成任何代码`
// 走火山方舟(ARK)接口的模型
const ARK_MODELS = new Set(["doubao-seed-2-1-lite-260915"])
@@ -165,14 +180,15 @@ async function* streamCompletion(
}
}
// 发给模型的对话上下文:只留最近 20 条;除最后一条 AI 回复外,旧回复里的代码块换成占位文字。
// 每次回复都是完整代码,模型只需要最新那份;否则对话越长 token 越多,最后会超出上下文长度
// 发给模型的对话上下文:只留最近 20 条;除最后一条带代码的 AI 回复外,旧回复里的代码块换成占位文字。
// 每次回复都是完整代码,模型只需要最新那份;否则对话越长 token 越多,最后会超出上下文长度。
// 只回答问题的回复没有代码,不能拿它当“最后一条”,不然最新的代码也被省略了
const MAX_HISTORY = 20
const CODE_BLOCK = /```[\s\S]*?```/g
export function trimHistory(history: ChatMessage[]): ChatMessage[] {
const recent = history.slice(-MAX_HISTORY)
const last = recent.findLastIndex((m) => m.role === "assistant")
const last = recent.findLastIndex((m) => m.role === "assistant" && m.content.includes("```"))
return recent.map((m, i) =>
m.role === "assistant" && i !== last
? { ...m, content: m.content.replace(CODE_BLOCK, "```\n(旧版本代码已省略)\n```") }
@@ -201,9 +217,9 @@ export function streamChat(history: ChatMessage[], model?: string, signal?: Abor
})
}
// 引导只是简短追问,不用思考,回得快
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
return streamCompletion([{ role: "system", content: GUIDANCE_SYSTEM_PROMPT }, ...history], {
// 引导只是简短追问,不用思考,回得快。context 是题目和学生之前的对话(见 guidanceContext)
export function streamGuidance(history: ChatMessage[], context: string, signal?: AbortSignal) {
return streamCompletion([{ role: "system", content: `${GUIDANCE_SYSTEM_PROMPT}\n\n${context}` }, ...history], {
thinking: false,
signal,
})
+43
View File
@@ -99,6 +99,49 @@ export function codeAsMarkdown(code: {
return "```html\n" + parts.join("\n\n") + "\n```"
}
// 题目要求去掉图片、压成一段,太长的截掉
const BRIEF_CHARS = 800
export function briefOf(content: string) {
const text = content
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\s+/g, " ")
.trim()
return text.length > BRIEF_CHARS ? text.slice(0, BRIEF_CHARS) + "……" : text
}
const clipTo = (text: string, n: number) => (text.length > n ? text.slice(0, n) + "……" : text)
// “帮我写清楚”的教练要知道题目和学生已经做到哪了,才分得清照搬题目和自己的想法:
// 题目要求、学生最近几次对 AI 说的话、AI 上一次的说明(代码去掉)
export function guidanceContext(conversationId: string, taskId: number) {
const task = get<{ title: string; content: string }>(
`SELECT title, content FROM task_task WHERE id = $id`,
{ id: taskId },
)
const asked = all<{ content: string }>(
`SELECT content FROM prompt_message
WHERE conversation_id = $id AND role = 'user' AND source IN ('conversation', 'manual')
ORDER BY created DESC, id DESC LIMIT 5`,
{ id: conversationId },
).reverse()
const reply = get<{ content: string }>(
`SELECT content FROM prompt_message
WHERE conversation_id = $id AND role = 'assistant' AND source = 'conversation'
ORDER BY created DESC, id DESC LIMIT 1`,
{ id: conversationId },
)
const replyText = reply?.content.replace(/```[\s\S]*?(```|$)/g, "").trim()
return [
`题目:${task?.title ?? "(未知)"}`,
`题目要求:${task ? briefOf(task.content) || "(无)" : "(无)"}`,
`学生之前对 AI 说过的话:${
asked.length ? asked.map((m, i) => `\n${i + 1}. ${clipTo(m.content.trim(), 300)}`).join("") : "(还没有,这是第一次描述作品)"
}`,
`AI 上一次的说明:${replyText ? clipTo(replyText, 600) : "(无)"}`,
].join("\n\n")
}
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
return get<MessageRow>(
`SELECT * FROM prompt_message
+9 -2
View File
@@ -5,6 +5,7 @@ import {
canAccessTask,
codeAsMarkdown,
getOrCreateActiveConversation,
guidanceContext,
insertMessage,
} from "./services/prompt"
import {
@@ -151,7 +152,9 @@ async function handlePrompt(ws: WS, data: any) {
// 有了回复,这条提问才留下来;在后台打 L1–L6 层级
inBackground(classifyMessage(userMessageId))
ws.data.pendingUserMessageId = null
send(ws, { type: "complete", code, message_id: messageId })
// 只回答问题、没给代码的回复不是新版本;发 null,免得工作台把网页清空
const hasCode = !!(code.html || code.css || code.js)
send(ws, { type: "complete", code: hasCode ? code : null, message_id: messageId })
} finally {
dropPendingUserMessage(ws)
}
@@ -172,7 +175,11 @@ async function handleGuidance(ws: WS, data: any) {
try {
let full: string
try {
full = await streamToClient(ws, streamGuidance(ws.data.guidanceHistory, ws.data.abort.signal))
const context = guidanceContext(ws.data.conversationId, ws.data.taskId)
full = await streamToClient(
ws,
streamGuidance(ws.data.guidanceHistory, context, ws.data.abort.signal),
)
} catch (e) {
// 这条提问没有回复,留在上下文里会出现连续两条用户消息
ws.data.guidanceHistory.pop()
+14 -1
View File
@@ -111,7 +111,7 @@ describe("streamChat", () => {
expect(out).toBe("你好世界")
expect(model).toBe(config.llmModel)
expect(thinking).toEqual({ type: "enabled" })
for await (const _ of streamGuidance([{ role: "user", content: "hi" }]));
for await (const _ of streamGuidance([{ role: "user", content: "hi" }], "题目:x"));
expect(thinking).toEqual({ type: "disabled" })
} finally {
Object.assign(config, saved)
@@ -140,6 +140,19 @@ test("trimHistory 只留最近 20 条,旧回复的代码换成占位", () => {
expect(out.filter((m) => m.content.includes("<p>"))).toHaveLength(1)
})
test("trimHistory 最后一条回复没有代码时,保留之前最新的那份代码", () => {
const out = trimHistory([
{ role: "user", content: "做个页面" },
{ role: "assistant", content: "第1版\n```html\n<p>1</p>\n```" },
{ role: "user", content: "加个按钮" },
{ role: "assistant", content: "第2版\n```html\n<p>2</p>\n```" },
{ role: "user", content: "为什么按钮是圆的?" },
{ role: "assistant", content: "因为加了 border-radius。" },
])
expect(out[3]!.content).toContain("<p>2</p>")
expect(out[1]!.content).not.toContain("<p>1</p>")
})
test("userFacingError 不透出接口原始返回", () => {
const quiet = console.error
console.error = () => {}
+47 -3
View File
@@ -2,12 +2,13 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
import type { Server } from "bun"
import { app } from "../src/app"
import { config } from "../src/config"
import { all } from "../src/db"
import { all, run } from "../src/db"
import { upgrade, websocket, type WsData } from "../src/ws"
import { client, freshDb, makeTask, makeUser } from "./helpers"
// 假的模型接口:每段之间停一下,方便测试中途断开
const PIECES = ["好的", "```html\n<p>hi</p>\n```"]
const DEFAULT_PIECES = ["好的", "```html\n<p>hi</p>\n```"]
let PIECES = DEFAULT_PIECES
// 最近一次发给模型的请求;分类(非流式)单独记,固定回“3”
let lastRequest: any = null
let lastClassify: any = null
@@ -56,7 +57,10 @@ afterAll(() => {
llm.stop(true)
})
beforeEach(freshDb)
beforeEach(() => {
freshDb()
PIECES = DEFAULT_PIECES
})
interface Conn {
ws: WebSocket
@@ -176,9 +180,49 @@ describe("WebSocket 代码生成", () => {
c.ws.close()
})
test("只回答问题、没给代码时 code 是 null", async () => {
const { task, token } = await setup()
PIECES = ["因为加了 ", "border-radius。"]
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
await c.next("init")
c.ws.send(JSON.stringify({ type: "message", content: "为什么按钮是圆的?" }))
const done = await c.next("complete")
expect(done.code).toBeNull()
expect(done.message_id).toBeGreaterThan(0)
c.ws.close()
})
test("token 无效或任务不存在时握手失败", async () => {
const { task, token } = await setup()
expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull()
expect(await connect(`/ws/prompt/9999/?token=${token}`)).toBeNull()
})
})
describe("WebSocket 帮我写清楚", () => {
test("教练能看到题目要求、之前的提问和 AI 上一次的说明", async () => {
const { task, token } = await setup()
run(`UPDATE task_task SET content = $c WHERE id = $id`, { c: "做一个计时器,![图](a.png) 有开始和暂停按钮", id: task })
const p = (await connect(`/ws/prompt/${task}/?token=${token}`))!
await p.next("init")
p.ws.send(JSON.stringify({ type: "message", content: "做个计时器" }))
await p.next("complete")
p.ws.close()
const g = (await connect(`/ws/guidance/${task}/?token=${token}`))!
await g.next("init")
g.ws.send(JSON.stringify({ type: "message", content: "改成粉色" }))
await g.next("complete")
const system = lastRequest.messages[0]
expect(system.role).toBe("system")
expect(system.content).toContain("题目要求:做一个计时器, 有开始和暂停按钮")
expect(system.content).toContain("1. 做个计时器")
expect(system.content).toContain("AI 上一次的说明:好的")
expect(system.content).not.toContain("<p>hi</p>")
expect(lastRequest.messages.at(-1)).toEqual({ role: "user", content: "改成粉色" })
// 等后台打完层级再结束,不然假接口关了它会报错
const level = () => all(`SELECT prompt_level FROM prompt_message WHERE source = 'guidance' AND role = 'user'`)[0]?.prompt_level
for (let i = 0; i < 50 && level() == null; i++) await Bun.sleep(20)
g.ws.close()
})
})