feat: 提示词链显示层级走势、每轮改了多少行;课堂总结加“怎么问的 AI”
- 提示词弹框:顶部每轮一根层级柱子,点了跳到那一轮;每轮标出代码比上一版 +/− 多少行 (没改标“代码没变”),和前面某轮一字不差的提问标“重复提问” - 课堂总结:每人最新一版平均几轮、一轮就交、最后一版用外部 AI、问过引导的比例, 以及各层级提问次数 - prompt_message 加两个覆盖索引,统计只读小列(像素画板全部班级 197 份约 0.3 ms) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f41fda3c19
commit
e06930c8f7
7 files changed
+274
-4
No files matched your search
@@ -37,6 +37,20 @@
|
||||
style="padding: 80px 0"
|
||||
/>
|
||||
<div v-else class="body">
|
||||
<div class="left">
|
||||
<div v-if="showTrend" class="trend">
|
||||
<span class="muted">层级</span>
|
||||
<div class="bars">
|
||||
<button
|
||||
v-for="b in trend"
|
||||
:key="b.i"
|
||||
:class="['bar', { on: b.i === selected, none: !b.level }]"
|
||||
:style="{ height: `${b.level ? 6 + b.level * 5 : 4}px` }"
|
||||
:title="`第 ${nums[b.i]} 轮${b.level ? ' · ' + levelLabel(b.level) : ''}`"
|
||||
@click="select(b.i)"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="rounds">
|
||||
<li
|
||||
v-for="(r, i) in rounds"
|
||||
@@ -50,6 +64,8 @@
|
||||
{{ sourceLabel(r.source) }}
|
||||
</span>
|
||||
<span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
|
||||
<span v-if="repeats[i]" class="repeat" title="和前面某一轮的提问一字不差">重复提问</span>
|
||||
<span v-if="changes[i]" class="muted">{{ changes[i] }}</span>
|
||||
<span v-if="i === rounds.length - 1" class="final">交的这一版</span>
|
||||
<span v-else-if="isGuide(r)" class="muted no-code">问写提示词的建议,不生成网页</span>
|
||||
<span v-else-if="!r.html" class="muted no-code">没有网页</span>
|
||||
@@ -63,6 +79,7 @@
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="stage">
|
||||
<div class="stage-head">
|
||||
@@ -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<string>()
|
||||
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<string, number>()
|
||||
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);
|
||||
|
||||
@@ -130,6 +130,34 @@
|
||||
</div>
|
||||
<div v-else class="empty">还没有打过分的作品</div>
|
||||
</section>
|
||||
|
||||
<!-- 怎么问的 AI -->
|
||||
<section class="span">
|
||||
<h4>
|
||||
怎么问的 AI
|
||||
<span class="count">每人最新一版 · {{ stats.prompts.with_chain }} 份有提示词记录</span>
|
||||
</h4>
|
||||
<div v-if="stats.prompts.with_chain" class="prompt">
|
||||
<div class="facts">
|
||||
<div><b>{{ stats.prompts.avg_rounds }}</b>平均几轮</div>
|
||||
<div><b>{{ pct(stats.prompts.one_round, stats.prompts.with_chain) }}</b>一轮就交</div>
|
||||
<div><b>{{ pct(stats.prompts.external, stats.prompts.with_chain) }}</b>最后一版用外部 AI</div>
|
||||
<div><b>{{ pct(stats.prompts.used_guidance, stats.prompts.with_chain) }}</b>问过怎么写提示词</div>
|
||||
</div>
|
||||
<div class="hist">
|
||||
<template v-for="(n, i) in stats.prompts.levels" :key="i">
|
||||
<span class="hist-label">{{ levelLabel(i + 1) }}</span>
|
||||
<span class="hist-bar">
|
||||
<span :style="{ width: pct(n, maxLevel) }"></span>
|
||||
</span>
|
||||
<span class="hist-value">{{ n }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
{{ stats.submitted_count ? "都是直接交的代码,没有提示词记录" : "还没有人交" }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
@@ -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);
|
||||
}
|
||||
</style>
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<Omit<Slim, "role" | "prompt_level">>(
|
||||
`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<string, Slim[]>()
|
||||
const msgs = all<Slim>(
|
||||
`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 }
|
||||
}
|
||||
@@ -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("提示词分类", () => {
|
||||
|
||||
Reference in new issue
Block a user