From 5bdacc438dfe4a854cf04523723502c34acb4657 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 27 Oct 2026 21:40:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=91=E6=88=98=E7=9A=84=E9=A2=98?= =?UTF-8?q?=E7=9B=AE=E8=A6=81=E6=B1=82=E5=8F=91=E7=BB=99=20AI=20=E5=BD=93?= =?UTF-8?q?=E5=AF=B9=E7=85=A7=E6=B8=85=E5=8D=95=EF=BC=8C=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E6=B2=A1=E6=8F=90=E5=88=B0=E7=9A=84=E4=B8=8D=E6=9B=BF=E4=BB=96?= =?UTF-8?q?=E5=81=9A=EF=BC=8C=E5=9C=A8=E2=80=9C=E6=83=B3=E4=B8=80=E6=83=B3?= =?UTF-8?q?=E2=80=9D=E9=87=8C=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 学生不用再整段贴题目;AI 只做学生说了的,从没提到的要求里挑一项问他打算怎么做。 整段贴了题目的照做。自由创作的题目是学生自己写的,不发。输入框提示改成“用你自己的话说说”。 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- client/src/components/ai/PromptPanel.vue | 2 +- server/src/services/llm.ts | 16 +++++++++++++--- server/src/services/prompt.ts | 19 ++++++++++++++++--- server/src/ws.ts | 4 +++- server/tests/ws.test.ts | 21 +++++++++++++++++++++ 6 files changed, 55 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9d379ee..e08feb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//?token=` (code generation chat; the AI context and the page's chat include `conversation`, `edit` and `manual` messages) and `/ws/guidance//?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`) diff --git a/client/src/components/ai/PromptPanel.vue b/client/src/components/ai/PromptPanel.vue index 472c5ed..23c94d5 100644 --- a/client/src/components/ai/PromptPanel.vue +++ b/client/src/components/ai/PromptPanel.vue @@ -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" diff --git a/server/src/services/llm.ts b/server/src/services/llm.ts index 5c02605..e070c90 100644 --- a/server/src/services/llm.ts +++ b/server/src/services/llm.ts @@ -39,6 +39,15 @@ document.getElementById('btn').onclick = function() { \`\`\`` +// 挑战题目要求只当对照清单:学生没说的不替他做,挑一项放进“想一想”问他打算怎么做, +// 要做成什么样还是学生自己说;整段贴了题目的照做(不拦,拦了只会去用外部 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, diff --git a/server/src/services/prompt.ts b/server/src/services/prompt.ts index bd6dfea..4e8df4c 100644 --- a/server/src/services/prompt.ts +++ b/server/src/services/prompt.ts @@ -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 上一次的说明(代码去掉) diff --git a/server/src/ws.ts b/server/src/ws.ts index feb1360..a289439 100644 --- a/server/src/ws.ts +++ b/server/src/ws.ts @@ -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) }) diff --git a/server/tests/ws.test.ts b/server/tests/ws.test.ts index f43b1e0..6c104b7 100644 --- a/server/tests/ws.test.ts +++ b/server/tests/ws.test.ts @@ -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()