From e06930c8f717c52558acc4acae25f8b81b838515 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 27 Oct 2026 20:35:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E7=A4=BA=E8=AF=8D=E9=93=BE?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=B1=82=E7=BA=A7=E8=B5=B0=E5=8A=BF=E3=80=81?= =?UTF-8?q?=E6=AF=8F=E8=BD=AE=E6=94=B9=E4=BA=86=E5=A4=9A=E5=B0=91=E8=A1=8C?= =?UTF-8?q?=EF=BC=9B=E8=AF=BE=E5=A0=82=E6=80=BB=E7=BB=93=E5=8A=A0=E2=80=9C?= =?UTF-8?q?=E6=80=8E=E4=B9=88=E9=97=AE=E7=9A=84=20AI=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提示词弹框:顶部每轮一根层级柱子,点了跳到那一轮;每轮标出代码比上一版 +/− 多少行 (没改标“代码没变”),和前面某轮一字不差的提问标“重复提问” - 课堂总结:每人最新一版平均几轮、一轮就交、最后一版用外部 AI、问过引导的比例, 以及各层级提问次数 - prompt_message 加两个覆盖索引,统计只读小列(像素画板全部班级 197 份约 0.3 ms) Co-Authored-By: Claude Opus 5.5 --- .../src/components/submissions/ChainModal.vue | 186 +++++++++++++++--- client/src/components/task/TaskStatsModal.vue | 55 ++++++ client/src/utils/type.ts | 12 ++ server/src/db.ts | 3 + server/src/routes/submission.ts | 3 + server/src/services/prompt.ts | 36 ++++ server/tests/api.test.ts | 33 ++++ 7 files changed, 299 insertions(+), 29 deletions(-) diff --git a/client/src/components/submissions/ChainModal.vue b/client/src/components/submissions/ChainModal.vue index 91e0b74..3e49517 100644 --- a/client/src/components/submissions/ChainModal.vue +++ b/client/src/components/submissions/ChainModal.vue @@ -37,32 +37,49 @@ style="padding: 80px 0" />
-
    -
  1. -
    - {{ nums[i] }} - - {{ sourceLabel(r.source) }} - - {{ levelLabel(r.prompt_level) }} - 交的这一版 - 问写提示词的建议,不生成网页 - 没有网页 +
    +
    + 层级 +
    +
    -
    - -
  2. -
+
+
    +
  1. +
    + {{ nums[i] }} + + {{ sourceLabel(r.source) }} + + {{ levelLabel(r.prompt_level) }} + 重复提问 + {{ changes[i] }} + 交的这一版 + 问写提示词的建议,不生成网页 + 没有网页 +
    +
    + +
  2. +
+
@@ -119,6 +136,57 @@ const nums = computed(() => { return rounds.value.map((r) => (isGuide(r) ? "" : String(++n))) }) +// 层级走势:每轮 AI 对话 / 外部 AI 一根柱子,两轮以上、有标过层级才显示 +const trend = computed(() => + rounds.value.flatMap((r, i) => (isGuide(r) ? [] : [{ i, level: r.prompt_level }])), +) +const showTrend = computed(() => trend.value.length > 1 && trend.value.some((b) => b.level)) + +// 和前面某一轮一字不差的提问(手改那一轮的正文都一样,不算) +const repeats = computed(() => { + const seen = new Set() + return rounds.value.map((r) => { + if (isGuide(r) || r.source === "edit") return false + const key = r.question.replace(/\s+/g, " ").trim() + const dup = seen.has(key) + seen.add(key) + return dup + }) +}) + +// 这一轮的代码比上一版改了多少行:只按行内容数,不管顺序 +function codeLines(r: PromptRound) { + return [r.html, r.css, r.js] + .join("\n") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) +} + +function lineDiff(before: string[], after: string[]) { + const left = new Map() + for (const l of before) left.set(l, (left.get(l) ?? 0) + 1) + let added = 0 + for (const l of after) { + const n = left.get(l) ?? 0 + if (n) left.set(l, n - 1) + else added++ + } + const removed = before.length - (after.length - added) + return added || removed ? `+${added} −${removed} 行` : "代码没变" +} + +const changes = computed(() => { + let prev: string[] | null = null + return rounds.value.map((r) => { + if (isGuide(r) || !r.html) return "" + const cur = codeLines(r) + const text = prev ? lineDiff(prev, cur) : "" + prev = cur + return text + }) +}) + const doc = computed(() => { const r = rounds.value[selected.value] if (!r?.html) return "" @@ -163,6 +231,13 @@ async function load() { } } +function select(i: number) { + selected.value = i + nextTick(() => + document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" }), + ) +} + // 在捕获阶段接管按键:弹框开着时,批改页的 Esc / J / K、批改面板的 1–5 / P 都不能触发 function onKey(e: KeyboardEvent) { if (!props.show) return @@ -176,10 +251,7 @@ function onKey(e: KeyboardEvent) { e.preventDefault() let i = selected.value + step while (rounds.value[i] && isGuide(rounds.value[i]!)) i += step - if (rounds.value[i]) selected.value = i - nextTick(() => - document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" }), - ) + if (rounds.value[i]) select(i) } watch( @@ -233,7 +305,57 @@ onUnmounted(() => window.removeEventListener("keydown", onKey, true)) height: 72vh; } +.left { + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.trend { + display: flex; + align-items: flex-end; + gap: 10px; + padding: 0 6px 6px 2px; + border-bottom: 1px solid var(--wp-line); +} + +.bars { + flex: 1; + height: 38px; + display: flex; + align-items: flex-end; + gap: 3px; +} + +.bar { + flex: 1; + max-width: 22px; + padding: 0; + border: 0; + border-radius: 2px 2px 0 0; + background: var(--wp-faint); + opacity: 0.55; + cursor: pointer; +} + +.bar.none { + background: var(--wp-line); + opacity: 1; +} + +.bar:hover, +.bar.on { + opacity: 1; +} + +.bar.on { + background: var(--wp-brand); +} + .rounds { + flex: 1; + min-height: 0; margin: 0; padding: 0 6px 0 0; list-style: none; @@ -280,6 +402,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKey, true)) .src, .level, +.repeat, .final { padding: 0 6px; border-radius: 4px; @@ -321,6 +444,11 @@ onUnmounted(() => window.removeEventListener("keydown", onKey, true)) color: var(--wp-muted); } +.repeat { + color: var(--wp-warn); + background: var(--wp-warn-soft); +} + .level { color: var(--wp-ink); background: var(--wp-paper); diff --git a/client/src/components/task/TaskStatsModal.vue b/client/src/components/task/TaskStatsModal.vue index 91fd143..aa2f4b0 100644 --- a/client/src/components/task/TaskStatsModal.vue +++ b/client/src/components/task/TaskStatsModal.vue @@ -130,6 +130,34 @@
还没有打过分的作品
+ + +
+

+ 怎么问的 AI + 每人最新一版 · {{ stats.prompts.with_chain }} 份有提示词记录 +

+
+
+
{{ stats.prompts.avg_rounds }}平均几轮
+
{{ pct(stats.prompts.one_round, stats.prompts.with_chain) }}一轮就交
+
{{ pct(stats.prompts.external, stats.prompts.with_chain) }}最后一版用外部 AI
+
{{ pct(stats.prompts.used_guidance, stats.prompts.with_chain) }}问过怎么写提示词
+
+
+ +
+
+
+ {{ stats.submitted_count ? "都是直接交的代码,没有提示词记录" : "还没有人交" }} +
+
@@ -150,6 +178,7 @@ import { computed, ref, watch } from "vue" import { useRouter } from "vue-router" import { Icon } from "../../icons" import { Submission } from "../../api" +import { levelLabel } from "../../utils/helper" import type { TaskStatsOut, TopScoredItem, UserTag } from "../../utils/type" // 老师下课前看的这道题的总结:默认看批改页正在看的班 @@ -200,6 +229,7 @@ const buckets = computed(() => { ] }) const maxBucket = computed(() => Math.max(...buckets.value.map((b) => b.value))) +const maxLevel = computed(() => Math.max(0, ...(stats.value?.prompts.levels ?? []))) const unsubmittedGroups = computed(() => byClass(stats.value?.unsubmitted_users ?? [])) const unratedGroups = computed(() => byClass(stats.value?.unrated_users ?? [])) @@ -540,4 +570,29 @@ h4 { .go { color: var(--wp-faint); } + +.span { + grid-column: 1 / -1; +} + +.prompt { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.facts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 16px; + align-content: start; + font-size: 12px; + color: var(--wp-muted); +} + +.facts b { + display: block; + font: 600 18px/1.3 var(--wp-font-mono); + color: var(--wp-ink); +} diff --git a/client/src/utils/type.ts b/client/src/utils/type.ts index d1e06db..0813ff4 100644 --- a/client/src/utils/type.ts +++ b/client/src/utils/type.ts @@ -199,6 +199,18 @@ export interface TaskStatsOut { present_count: number classes: string[] top_scored: TopScoredItem[] + prompts: PromptChainStats +} + +// 课堂总结里每人最新一版的提示词链概况 +export interface PromptChainStats { + with_chain: number + one_round: number + external: number + used_guidance: number + avg_rounds: number + // L1–L6 各有几次提问 + levels: number[] } export type GradebookTaskType = "tutorial" | "challenge" diff --git a/server/src/db.ts b/server/src/db.ts index f90433e..bb207e7 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -47,6 +47,9 @@ CREATE INDEX IF NOT EXISTS "prompt_conversation_user_id_ecc418b3" ON "prompt_con CREATE TABLE IF NOT EXISTS "prompt_message" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "role" varchar(10) NOT NULL, "content" text NOT NULL, "code_html" text NULL, "code_css" text NULL, "code_js" text NULL, "created" datetime NOT NULL, "conversation_id" char(32) NOT NULL REFERENCES "prompt_conversation" ("id") DEFERRABLE INITIALLY DEFERRED, "prompt_level" integer NULL, "source" varchar(12) NOT NULL, "submission_id" char(32) NULL UNIQUE REFERENCES "submission_submission" ("id") DEFERRABLE INITIALLY DEFERRED); CREATE INDEX IF NOT EXISTS "prompt_message_conversation_id_af18dd4b" ON "prompt_message" ("conversation_id"); CREATE INDEX IF NOT EXISTS "prompt_message_prompt_level_e461b596" ON "prompt_message" ("prompt_level"); +-- 课堂总结统计提示词链用的覆盖索引:只读小列,不碰 content / code_* +CREATE INDEX IF NOT EXISTS "prompt_message_chain" ON "prompt_message" ("conversation_id", "created", "id", "role", "source", "prompt_level"); +CREATE INDEX IF NOT EXISTS "prompt_message_submission" ON "prompt_message" ("submission_id", "conversation_id", "created", "source") WHERE "submission_id" IS NOT NULL; -- 登录用 JWT:access token 不落库,refresh token 只存 sha256 DROP TABLE IF EXISTS "auth_session"; diff --git a/server/src/routes/submission.ts b/server/src/routes/submission.ts index 3ee57fb..a55f91c 100644 --- a/server/src/routes/submission.ts +++ b/server/src/routes/submission.ts @@ -26,6 +26,7 @@ import { getActiveConversation, getPrecedingUserMessage, insertMessage, + promptChainStats, type MessageRow, } from "../services/prompt" import { pyRound } from "../services/pyround" @@ -473,6 +474,8 @@ router.get("/stats/:task_id", (c) => { present_count: studentSubs.filter((s) => s.flag === "present").length, classes, top_scored: topScored, + // 每人最新一版是怎么问出来的 + prompts: promptChainStats([...latest.values()].map((s) => s.id)), }) }) diff --git a/server/src/services/prompt.ts b/server/src/services/prompt.ts index 97b0748..29407b1 100644 --- a/server/src/services/prompt.ts +++ b/server/src/services/prompt.ts @@ -1,5 +1,6 @@ import { all, get, run, now, newUuidHex } from "../db" import { Role, type User } from "../auth" +import { pyRound } from "./pyround" export interface MessageRow { id: number @@ -141,3 +142,38 @@ export function buildPromptRounds(source: MessageRow) { } return rounds } + +// 课堂总结:这些提交各自是怎么问出来的(只走覆盖索引,不读提问和代码) +export function promptChainStats(submissionIds: string[]) { + type Slim = { conversation_id: string; created: string; id: number; role: string; source: string; prompt_level: number | null } + const finals = all>( + `SELECT conversation_id, created, id, source FROM prompt_message + WHERE submission_id IN (SELECT value FROM json_each($ids))`, + { ids: JSON.stringify(submissionIds) }, + ) + const byConv = new Map() + const msgs = all( + `SELECT conversation_id, created, id, role, source, prompt_level FROM prompt_message + WHERE conversation_id IN (SELECT value FROM json_each($convs))`, + { convs: JSON.stringify([...new Set(finals.map((f) => f.conversation_id))]) }, + ) + for (const m of msgs) byConv.set(m.conversation_id, [...(byConv.get(m.conversation_id) ?? []), m]) + + const out = { with_chain: finals.length, one_round: 0, external: 0, used_guidance: 0, avg_rounds: 0 } + const levels = [0, 0, 0, 0, 0, 0] + let rounds = 0 + for (const f of finals) { + // 和 buildPromptRounds 一样:只算交的那一版之前的提问 + const asked = (byConv.get(f.conversation_id) ?? []).filter( + (m) => m.role === "user" && (m.created < f.created || (m.created === f.created && m.id < f.id)), + ) + const code = asked.filter((m) => m.source !== "guidance") + rounds += code.length + if (code.length === 1) out.one_round++ + if (f.source === "manual") out.external++ + if (code.length < asked.length) out.used_guidance++ + for (const m of code) if (m.prompt_level) levels[m.prompt_level - 1]!++ + } + out.avg_rounds = finals.length ? pyRound(rounds / finals.length, 1) : 0 + return { ...out, levels } +} diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index 58ec7a3..a76abeb 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -645,6 +645,39 @@ describe("课堂总结", () => { const all = await adm("GET", `/api/submission/stats/${task}`) expect(all.json.top_scored.map((t: any) => t.username)).toEqual(["weba2小李", "weba1小明"]) }) + + test("统计每人最新一版的提示词链", async () => { + const { createConversation, insertMessage } = await import("../src/services/prompt") + makeUser("adm", "admin") + const task = makeTask("挑战") + const say = (conv: string, source: string, level: number | null, submission_id?: string) => { + const id = insertMessage({ conversation_id: conv, role: "user", source, content: "提问" }) + run(`UPDATE prompt_message SET prompt_level = $level WHERE id = $id`, { level, id }) + insertMessage({ conversation_id: conv, role: "assistant", source, content: "回答", submission_id }) + } + // 小明:AI 对话 → 引导 → 外部 AI,交最后这版;交了之后的提问不算 + const a = makeUser("weba1小明", "normal", "a1") + const convA = createConversation(a, task) + say(convA, "conversation", 3) + say(convA, "guidance", 2) + say(convA, "manual", 6, makeSubmission(a, task)) + say(convA, "conversation", 1) + // 小红:一轮就交 + const b = makeUser("weba1小红", "normal", "a1") + say(createConversation(b, task), "conversation", 1, makeSubmission(b, task)) + // 小刚:手动交的,没有提示词 + makeSubmission(makeUser("weba1小刚", "normal", "a1"), task) + + const res = await (await client("adm"))("GET", `/api/submission/stats/${task}`) + expect(res.json.prompts).toEqual({ + with_chain: 2, + one_round: 1, + external: 1, + used_guidance: 1, + avg_rounds: 1.5, + levels: [1, 0, 1, 0, 0, 1], + }) + }) }) describe("提示词分类", () => {