feat: 挑战的题目要求发给 AI 当对照清单,学生没提到的不替他做,在“想一想”里问
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
学生不用再整段贴题目;AI 只做学生说了的,从没提到的要求里挑一项问他打算怎么做。 整段贴了题目的照做。自由创作的题目是学生自己写的,不发。输入框提示改成“用你自己的话说说”。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
43c18cdb88
commit
5bdacc438d
6 files changed
+55
-9
No files matched your search
@@ -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 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/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`. For challenges the AI also gets the task brief (`challengeBrief`) as a checklist only (`BRIEF_RULES`): it builds just what the student asked for and asks about one missing requirement in “想一想”; creations get no brief. 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`)
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
v-model:value="input"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 4 }"
|
||||
placeholder="描述你想要的网页效果..."
|
||||
placeholder="用你自己的话说说想做成什么样…"
|
||||
:disabled="streaming"
|
||||
style="flex: 1; min-width: 0"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
|
||||
@@ -39,6 +39,15 @@ document.getElementById('btn').onclick = function() {
|
||||
</script>
|
||||
\`\`\``
|
||||
|
||||
// 挑战题目要求只当对照清单:学生没说的不替他做,挑一项放进“想一想”问他打算怎么做,
|
||||
// 要做成什么样还是学生自己说;整段贴了题目的照做(不拦,拦了只会去用外部 AI)
|
||||
export const BRIEF_RULES = `下面是这道题的题目要求。它是对照清单,不是让你照着做——这是课堂练习,题目要求要学生自己一步步提出来,这正是这节课要练的:
|
||||
- 只做学生在对话里说了的内容,做出来要能打开、能用。学生说的话里包含了题目要求(比如整段贴了题目),就照着做
|
||||
- 题目要求里学生还没提到的功能、规则和细节(如计时方式、对错的颜色、计分、重来按钮),哪怕很常见、顺手就能做,也先不做;也不要照搬题目里的类名、变量名和数据
|
||||
- 从学生没提到的要求里挑一项最重要的放进“想一想”,问他打算怎么做(如“题目还要求标出打错的字,你希望打错的时候是什么样子?”)
|
||||
- 学生要的和题目要求冲突时,按学生说的做,在“想一想”里提醒他题目是怎么要求的
|
||||
- 题目要求都做到了,“想一想”就问他自己的想法,不再提题目`
|
||||
|
||||
// 判断标准和分类器一致:看学生自己想了什么,照搬题目写得再长也不算清楚
|
||||
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练。学生在网页制作课上让 AI 生成或修改网页,你帮他把这一次要对 AI 说的话想清楚、说具体。后面会给你题目要求、他之前对 AI 说过的话和 AI 上一次的说明。
|
||||
|
||||
@@ -208,9 +217,10 @@ export function userFacingError(e: unknown) {
|
||||
return status ? `AI 服务出错(${status}),请稍后重试` : "AI 服务出错,请稍后重试"
|
||||
}
|
||||
|
||||
// 生成整页代码:开着思考,质量优先
|
||||
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal) {
|
||||
return streamCompletion([{ role: "system", content: SYSTEM_PROMPT }, ...trimHistory(history)], {
|
||||
// 生成整页代码:开着思考,质量优先。brief 是挑战的题目要求(见 challengeBrief),自由创作没有
|
||||
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal, brief?: string | null) {
|
||||
const system = brief ? `${SYSTEM_PROMPT}\n\n${BRIEF_RULES}\n\n${brief}` : SYSTEM_PROMPT
|
||||
return streamCompletion([{ role: "system", content: system }, ...trimHistory(history)], {
|
||||
model: chatModel(model),
|
||||
thinking: true,
|
||||
signal,
|
||||
|
||||
@@ -102,15 +102,28 @@ export function codeAsMarkdown(code: {
|
||||
// 题目要求去掉图片、压成一段,太长的截掉
|
||||
const BRIEF_CHARS = 800
|
||||
|
||||
export function briefOf(content: string) {
|
||||
export function briefOf(content: string, max = BRIEF_CHARS) {
|
||||
const text = content
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
return text.length > BRIEF_CHARS ? text.slice(0, BRIEF_CHARS) + "……" : text
|
||||
return clipTo(text, max)
|
||||
}
|
||||
|
||||
const clipTo = (text: string, n: number) => (text.length > n ? text.slice(0, n) + "……" : text)
|
||||
function clipTo(text: string, n: number) {
|
||||
return text.length > n ? text.slice(0, n) + "……" : text
|
||||
}
|
||||
|
||||
// 挑战的题目要求,给生成网页的 AI 当对照清单(见 llm.ts 的 BRIEF_RULES);要逐条对照,不能截得太短。
|
||||
// 自由创作的题目是学生自己写的介绍,不用对照,返回 null
|
||||
export function challengeBrief(taskId: number) {
|
||||
const task = get<{ title: string; content: string }>(
|
||||
`SELECT title, content FROM task_task WHERE id = $id AND task_type = 'challenge'`,
|
||||
{ id: taskId },
|
||||
)
|
||||
const brief = task && briefOf(task.content, 4000)
|
||||
return brief ? `题目:${task.title}\n\n题目要求:${brief}` : null
|
||||
}
|
||||
|
||||
// “帮我写清楚”的教练要知道题目和学生已经做到哪了,才分得清照搬题目和自己的想法:
|
||||
// 题目要求、学生最近几次对 AI 说的话、AI 上一次的说明(代码去掉)
|
||||
|
||||
+3
-1
@@ -3,6 +3,7 @@ import { all, get, run, iso, uuidOut } from "./db"
|
||||
import { userFromAccessToken, type User } from "./auth"
|
||||
import {
|
||||
canAccessTask,
|
||||
challengeBrief,
|
||||
codeAsMarkdown,
|
||||
getOrCreateActiveConversation,
|
||||
guidanceContext,
|
||||
@@ -132,7 +133,8 @@ async function handlePrompt(ws: WS, data: any) {
|
||||
}))
|
||||
let full: string
|
||||
try {
|
||||
full = await streamToClient(ws, streamChat(history, model, ws.data.abort.signal))
|
||||
const brief = challengeBrief(ws.data.taskId)
|
||||
full = await streamToClient(ws, streamChat(history, model, ws.data.abort.signal, brief))
|
||||
} catch (e) {
|
||||
// 客户端自己断开导致的中止不算出错
|
||||
if (!ws.data.closed) send(ws, { type: "error", content: userFacingError(e) })
|
||||
|
||||
@@ -192,6 +192,27 @@ describe("WebSocket 代码生成", () => {
|
||||
c.ws.close()
|
||||
})
|
||||
|
||||
test("挑战的题目要求作为对照清单发给 AI,自由创作不发", async () => {
|
||||
const { task, token } = await setup()
|
||||
run(`UPDATE task_task SET content = $c WHERE id = $id`, { c: "做一个计时器,有开始和暂停按钮", id: task })
|
||||
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||
await c.next("init")
|
||||
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||
await c.next("complete")
|
||||
expect(lastRequest.messages[0].content).toContain("它是对照清单")
|
||||
expect(lastRequest.messages[0].content).toContain("题目要求:做一个计时器,有开始和暂停按钮")
|
||||
c.ws.close()
|
||||
|
||||
const creation = makeTask("我的作品", "creation", 2)
|
||||
run(`UPDATE task_task SET content = '我想做一个相册' WHERE id = $id`, { id: creation })
|
||||
const d = (await connect(`/ws/prompt/${creation}/?token=${token}`))!
|
||||
await d.next("init")
|
||||
d.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||
await d.next("complete")
|
||||
expect(lastRequest.messages[0].content).not.toContain("题目要求")
|
||||
d.ws.close()
|
||||
})
|
||||
|
||||
test("token 无效或任务不存在时握手失败", async () => {
|
||||
const { task, token } = await setup()
|
||||
expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull()
|
||||
|
||||
Reference in new issue
Block a user