Compare commits
6
Commits
bc9da688ba
...
9f3f7187e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f3f7187e1 | ||
|
|
6b2ffbb3ec | ||
|
|
b765cbf1aa | ||
|
|
e06930c8f7 | ||
|
|
f41fda3c19 | ||
|
|
30630e8125 |
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 older code blocks elided), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `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 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/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`)
|
||||
|
||||
@@ -52,15 +52,26 @@
|
||||
<!-- Streaming indicator -->
|
||||
<div v-if="streaming" class="message assistant">
|
||||
<div class="message-role">AI</div>
|
||||
<div v-if="!streamingContent" class="typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="message-content"
|
||||
v-html="renderMarkdown(streamingContent)"
|
||||
></div>
|
||||
<div class="streaming-hint">AI 正在生成中…</div>
|
||||
<template v-if="!streamingContent">
|
||||
<div class="typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="streaming-hint">
|
||||
AI 正在构思… 已 {{ elapsedText }}<template v-if="elapsed >= 20">,复杂的网页要想一两分钟</template>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="streamParts.prose"
|
||||
class="message-content"
|
||||
v-html="renderMarkdown(streamParts.prose)"
|
||||
></div>
|
||||
<div v-if="streamParts.lines >= 0" class="code-progress">
|
||||
<Icon icon="lucide:sparkles" :width="14" />
|
||||
正在写网页… 已写 {{ streamParts.lines }} 行
|
||||
</div>
|
||||
<div v-else class="streaming-hint">AI 正在回复…</div>
|
||||
</template>
|
||||
</div>
|
||||
<RatingCard v-if="rating" :active="streaming" />
|
||||
</div>
|
||||
@@ -122,7 +133,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed } from "vue"
|
||||
import { ref, watch, nextTick, computed, onUnmounted } from "vue"
|
||||
import { useStorage } from "@vueuse/core"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "../../icons"
|
||||
@@ -133,6 +144,7 @@ import {
|
||||
messages,
|
||||
streaming,
|
||||
streamingContent,
|
||||
streamStartedAt,
|
||||
connected,
|
||||
sendPrompt,
|
||||
stopPrompt,
|
||||
@@ -238,10 +250,47 @@ async function onGuidanceGenerate(finalPrompt: string) {
|
||||
sendPrompt(finalPrompt, selectedModel.value)
|
||||
}
|
||||
|
||||
function renderContent(msg: { role: string; content: string }): string {
|
||||
return renderMarkdown(msg.content)
|
||||
// 学生只关心成品:AI 回复里的代码块不在对话里展开(右边“代码”里能看),只说写了多少行。
|
||||
// 按 ``` 切开,奇数段是代码;段数是偶数说明最后一个代码块还没写完
|
||||
function splitCode(text: string) {
|
||||
const parts = text.split("```")
|
||||
const prose = parts.filter((_, i) => i % 2 === 0).join("\n\n").trim()
|
||||
const code = parts.filter((_, i) => i % 2 === 1)
|
||||
const lines = code.reduce((n, c) => n + c.split("\n").length - 1, 0)
|
||||
return { prose, lines: code.length ? lines : -1 }
|
||||
}
|
||||
|
||||
const streamParts = computed(() => splitCode(streamingContent.value))
|
||||
|
||||
function renderContent(msg: { role: string; content: string }): string {
|
||||
if (msg.role !== "assistant") return renderMarkdown(msg.content)
|
||||
const { prose, lines } = splitCode(msg.content)
|
||||
const note = lines >= 0 ? `<p class="code-note">网页代码 ${lines} 行,在右边“代码”里看</p>` : ""
|
||||
return renderMarkdown(prose) + note
|
||||
}
|
||||
|
||||
// 等 AI 的计时:每秒刷新一次
|
||||
const now = ref(Date.now())
|
||||
let ticker: ReturnType<typeof setInterval> | undefined
|
||||
watch(
|
||||
streaming,
|
||||
(on) => {
|
||||
clearInterval(ticker)
|
||||
now.value = Date.now()
|
||||
if (on) ticker = setInterval(() => (now.value = Date.now()), 1000)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onUnmounted(() => clearInterval(ticker))
|
||||
|
||||
const elapsed = computed(() =>
|
||||
streamStartedAt.value ? Math.max(0, Math.floor((now.value - streamStartedAt.value) / 1000)) : 0,
|
||||
)
|
||||
const elapsedText = computed(() => {
|
||||
const s = elapsed.value
|
||||
return s < 60 ? `${s} 秒` : `${Math.floor(s / 60)} 分 ${s % 60} 秒`
|
||||
})
|
||||
|
||||
watch([() => messages.value.length, streamingContent], () => {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
@@ -377,6 +426,20 @@ watch([() => messages.value.length, streamingContent], () => {
|
||||
}
|
||||
}
|
||||
|
||||
.code-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.message-content :deep(.code-note) {
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.streaming-hint {
|
||||
font-size: 11px;
|
||||
color: var(--wp-faint);
|
||||
|
||||
@@ -121,14 +121,14 @@
|
||||
|
||||
<section>
|
||||
<n-flex justify="space-between" align="center" class="chain-head">
|
||||
<h4>提示词 <span class="muted" v-if="chain.length">· {{ chain.length }} 轮</span></h4>
|
||||
<h4>提示词 <span class="muted" v-if="chainSteps">· {{ chainSteps }} 轮</span></h4>
|
||||
<n-button v-if="chain.length" size="tiny" quaternary @click="showChain = true">
|
||||
逐轮看网页
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<div v-if="!chain.length" class="muted">这份提交没有关联的提示词</div>
|
||||
<ol class="chain">
|
||||
<li v-for="(r, i) in chain" :key="i">
|
||||
<li v-for="(r, i) in chain" :key="i" :class="{ guide: r.source === 'guidance' }">
|
||||
<n-flex :size="6" align="center">
|
||||
<span class="src">{{ sourceLabel(r.source) }}</span>
|
||||
<span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
|
||||
@@ -195,6 +195,8 @@ const versions = ref<SubmissionOut[]>([])
|
||||
const chain = ref<PromptRound[]>([])
|
||||
const rating = ref(false)
|
||||
const showChain = ref(false)
|
||||
// 引导对话不生成网页,不算轮数
|
||||
const chainSteps = computed(() => chain.value.filter((r) => r.source !== "guidance").length)
|
||||
|
||||
let awardsCache: Promise<AwardManageOut[]> | null = null
|
||||
const awards = ref<AwardManageOut[]>([])
|
||||
@@ -490,6 +492,12 @@ h4 {
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
/* 不是 list-item,就不占 ol 的序号 */
|
||||
.chain .guide {
|
||||
display: block;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.level {
|
||||
font: 600 11px var(--wp-font-mono);
|
||||
color: var(--wp-brand);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<template #header>
|
||||
<div class="head">
|
||||
<b>提示词</b>
|
||||
<span v-if="rounds.length" class="muted">{{ rounds.length }} 轮</span>
|
||||
<span v-if="steps" class="muted">{{ steps }} 轮</span>
|
||||
<span v-if="title" class="muted title">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -37,35 +37,53 @@
|
||||
style="padding: 80px 0"
|
||||
/>
|
||||
<div v-else class="body">
|
||||
<ol class="rounds">
|
||||
<li
|
||||
v-for="(r, i) in rounds"
|
||||
:key="i"
|
||||
:class="['round', { on: i === selected }]"
|
||||
@click="selected = i"
|
||||
>
|
||||
<div class="round-head">
|
||||
<span class="num">{{ i + 1 }}</span>
|
||||
<span :class="['src', `src-${r.source || 'conversation'}`]">
|
||||
{{ sourceLabel(r.source) }}
|
||||
</span>
|
||||
<span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
|
||||
<span v-if="i === rounds.length - 1" class="final">交的这一版</span>
|
||||
<span v-else-if="!r.html" class="muted no-code">没有网页</span>
|
||||
<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
|
||||
:class="['q', 'markdown-body', { clamp: isLong(r.question) && !expanded.has(i) }]"
|
||||
v-html="renderMarkdown(r.question)"
|
||||
></div>
|
||||
<button v-if="isLong(r.question)" class="more" @click.stop="toggle(i)">
|
||||
{{ expanded.has(i) ? "收起" : "展开全部" }}
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<ol class="rounds">
|
||||
<li
|
||||
v-for="(r, i) in rounds"
|
||||
:key="i"
|
||||
:class="['round', { on: i === selected, guide: isGuide(r) }]"
|
||||
@click="isGuide(r) || (selected = i)"
|
||||
>
|
||||
<div class="round-head">
|
||||
<span class="num">{{ nums[i] }}</span>
|
||||
<span :class="['src', `src-${r.source || 'conversation'}`]">
|
||||
{{ 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>
|
||||
</div>
|
||||
<div
|
||||
:class="['q', 'markdown-body', { clamp: isLong(r.question) && !expanded.has(i) }]"
|
||||
v-html="renderMarkdown(r.question)"
|
||||
></div>
|
||||
<button v-if="isLong(r.question)" class="more" @click.stop="toggle(i)">
|
||||
{{ expanded.has(i) ? "收起" : "展开全部" }}
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="stage">
|
||||
<div class="stage-head">
|
||||
<b>第 {{ selected + 1 }} 轮生成的网页</b>
|
||||
<b>第 {{ nums[selected] }} 轮生成的网页</b>
|
||||
<span class="muted">↑ ↓ 切换</span>
|
||||
</div>
|
||||
<div class="frame">
|
||||
@@ -110,6 +128,65 @@ const rounds = ref<PromptRound[]>([])
|
||||
const selected = ref(0)
|
||||
const expanded = ref(new Set<number>())
|
||||
|
||||
// 引导对话(问怎么写提示词)夹在 AI 对话中间,不算轮数,也不能选中
|
||||
const isGuide = (r: PromptRound) => r.source === "guidance"
|
||||
const steps = computed(() => rounds.value.filter((r) => !isGuide(r)).length)
|
||||
const nums = computed(() => {
|
||||
let n = 0
|
||||
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 ""
|
||||
@@ -154,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
|
||||
@@ -165,10 +249,9 @@ function onKey(e: KeyboardEvent) {
|
||||
const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0
|
||||
if (!step || !rounds.value.length) return
|
||||
e.preventDefault()
|
||||
selected.value = Math.min(rounds.value.length - 1, Math.max(0, selected.value + step))
|
||||
nextTick(() =>
|
||||
document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" }),
|
||||
)
|
||||
let i = selected.value + step
|
||||
while (rounds.value[i] && isGuide(rounds.value[i]!)) i += step
|
||||
if (rounds.value[i]) select(i)
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -222,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;
|
||||
@@ -269,6 +402,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKey, true))
|
||||
|
||||
.src,
|
||||
.level,
|
||||
.repeat,
|
||||
.final {
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
@@ -291,6 +425,30 @@ onUnmounted(() => window.removeEventListener("keydown", onKey, true))
|
||||
background: var(--wp-html-soft);
|
||||
}
|
||||
|
||||
.src-guidance {
|
||||
color: var(--wp-muted);
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
.round.guide {
|
||||
border-style: dashed;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.round.guide:hover {
|
||||
border-color: var(--wp-line);
|
||||
}
|
||||
|
||||
.round.guide .q {
|
||||
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>
|
||||
@@ -19,6 +19,8 @@ export const conversationId = ref<string>("")
|
||||
export const connected = ref(false)
|
||||
export const streaming = ref(false)
|
||||
export const streamingContent = ref("")
|
||||
// 这次提问发出的时间:AI 先思考,第一个字可能一两分钟后才出来,界面据此显示等了多久
|
||||
export const streamStartedAt = ref(0)
|
||||
export const currentTaskId = ref(0)
|
||||
// 新代码交给工作台处理:init 是连上时对话里最新的那一版,complete 是 AI 刚生成的
|
||||
let _codeSink:
|
||||
@@ -106,6 +108,7 @@ export function disconnectPrompt() {
|
||||
export function sendPrompt(content: string, model: string = "") {
|
||||
if (!socket.send({ type: "message", content, model })) return
|
||||
streaming.value = true
|
||||
streamStartedAt.value = Date.now()
|
||||
messages.value.push({ role: "user", content })
|
||||
}
|
||||
|
||||
|
||||
@@ -21,17 +21,18 @@ export function goHome(router: any, type: TASK_TYPE, display: number) {
|
||||
export function sourceLabel(source: string | null | undefined) {
|
||||
if (source === "edit") return "手改"
|
||||
if (source === "manual") return "外部 AI"
|
||||
if (source === "guidance") return "引导"
|
||||
return "AI 对话"
|
||||
}
|
||||
|
||||
// 提问的 Bloom 等级(services/classifier.ts 后台打的标签)
|
||||
export const PROMPT_LEVELS: Record<number, { name: string; desc: string }> = {
|
||||
1: { name: "记忆", desc: "复述或识别知识点" },
|
||||
2: { name: "理解", desc: "用自己的话解释概念" },
|
||||
3: { name: "应用", desc: "把知识用到新情境" },
|
||||
4: { name: "分析", desc: "拆解结构、找出规律" },
|
||||
5: { name: "评价", desc: "按标准作出判断" },
|
||||
6: { name: "创造", desc: "整合信息做出新东西" },
|
||||
1: { name: "记忆", desc: "只说要做什么或只报结果,没有自己的要求和解释" },
|
||||
2: { name: "理解", desc: "想弄懂原理:问为什么、是什么意思、怎么做到的" },
|
||||
3: { name: "应用", desc: "提出具体要求(颜色、布局、功能),照搬题目要求也算" },
|
||||
4: { name: "分析", desc: "说清在什么操作、哪个位置出了什么问题,或推测原因" },
|
||||
5: { name: "评价", desc: "带理由评判现有结果,或比较不同做法再选择" },
|
||||
6: { name: "创造", desc: "题目之外自己构思的整体设计:主题、玩法、交互" },
|
||||
}
|
||||
|
||||
export function levelLabel(level: number | null | undefined) {
|
||||
|
||||
@@ -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,18 +1,32 @@
|
||||
import { all, get, run } from "../db"
|
||||
import { complete, llmConfigured } from "./llm"
|
||||
|
||||
const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。根据布鲁姆认知分类学,分析以下学生在前端学习中发送给AI助手的一条提示词,判断该提示词所体现的认知层级。
|
||||
// 学生在这里是“指挥 AI 做网页”,看的是这句提问里学生自己想了多少,不是要 AI 做的东西有多大:
|
||||
// 把题目要求原样贴过去,功能列得再多也只是照着做(L3)
|
||||
const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。学生在网页制作课上让 AI 生成或修改网页,下面给出题目要求、学生上一轮的提问和这一轮的提问。请按布鲁姆认知分类学,判断“这一轮提问”体现的学生自己的思考层级。
|
||||
|
||||
层级定义:
|
||||
- L1 记忆:能背诵HTML标签语法(例:"帮我写一个按钮")
|
||||
- L2 理解:能解释flex布局原理(例:"为什么这里不居中?")
|
||||
- L3 应用:能独立搭建页面结构(例:"用flex做导航栏,间距16px")
|
||||
- L4 分析:能定位跨浏览器兼容性bug(例:"Safari中margin失效,原因?")
|
||||
- L5 评价:能对比并选择方案(例:"对比Grid与Flex方案优劣")
|
||||
- L6 创造:能设计并实现原创交互作品(例:"设计夜间/日间切换效果")
|
||||
按下面的顺序判断,命中哪一条就是哪一级:
|
||||
1. L1 记忆:没有任何具体要求、解释或问题。只给作品名称、只报结果或只让 AI 修(例:"帮我做一个象棋游戏"、"制作一个吃豆人"、"继续"、"预览代码"、"还是没有显示"、"点击开始游戏进不去"、"帮我修复代码bug")
|
||||
2. L2 理解:想弄懂原理、代码或概念(问为什么、是什么意思、怎么做到的),或者用自己的话解释现有代码在做什么(例:"为什么点了按钮没反应?"、"grid 和 flex 有什么区别"、"这段 js 里的 setInterval 是干什么的")
|
||||
3. L4 分析:说清在哪个操作、哪个位置出了什么具体问题,或者自己推测原因、指出问题出在哪部分(例:"拖动绘制时松开鼠标还在画,是不是没监听 mouseup"、"缩小窗口后网格就不显示了"、"围棋我刚下一颗黑棋就判我赢了")
|
||||
4. L5 评价:明确给出判断理由来评价现有结果,或者比较两种做法再选择;必须有“因为……所以……”“A 比 B 好”这样的理由或比较(例:"现在按钮太多,小学生看不懂,改成只留三个主要按钮"、"用 canvas 画比用一堆 div 流畅,改成 canvas")
|
||||
5. L6 创造:在题目要求之外提出自己构思的设计,而且几部分互相配合成一个整体,如原创的主题、玩法、规则或交互(例:"改成星空主题:转盘是星座,抽中稀有奖励时流星划过并解锁对应星座的图鉴")
|
||||
6. L3 应用:其余给出具体、可执行要求的,如颜色、风格、布局、要加或要改的功能(例:"方块大一点,旁边加一个橡皮擦和清空按钮"、"页面风格可爱,浅粉加浅蓝的渐变"、"加上撤销和保存为图片")
|
||||
|
||||
注意:
|
||||
- 只看学生自己想出来的部分。照搬或改写题目要求里的功能清单是 L3,要求写得长、功能多不等于层级高。
|
||||
- 只说想要什么风格、加什么功能,没有理由,是 L3 不是 L5;只列很多功能,没有自己的整体构思,是 L3 不是 L6。
|
||||
- 只说“没反应”“进不去”“不能玩”“有 bug”,哪怕提到了是哪个按钮,也是 L1;要说出更具体的现象(如“打字速度始终是 0”)或推测原因才是 L4。
|
||||
- 说明要改成什么样(如“弧线应该对着中圈”“球门要比禁区小”)是 L3,不是 L4。
|
||||
- 整段贴了题目要求或一长串功能清单,不是 L1,是 L3。
|
||||
- 结合上一轮看:重复上一轮的提问,层级不高于上一轮。
|
||||
|
||||
只返回一个数字(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))
|
||||
|
||||
async function callLlm(content: string): Promise<number | null> {
|
||||
@@ -23,7 +37,8 @@ async function callLlm(content: string): Promise<number | null> {
|
||||
{ role: "system", content: CLASSIFY_SYSTEM_PROMPT },
|
||||
{ role: "user", content },
|
||||
],
|
||||
{ maxTokens: 10, timeoutMs: 30_000 },
|
||||
// 只回一个数字,不用思考;关了思考 temperature 才生效,同一句提示词得到同一个层级
|
||||
{ thinking: false, maxTokens: 10, temperature: 0, timeoutMs: 30_000 },
|
||||
)
|
||||
const match = /[1-6]/.exec(text)
|
||||
if (!match) {
|
||||
@@ -37,13 +52,54 @@ 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
|
||||
}
|
||||
|
||||
// 发给模型的:题目要求、同一对话里上一轮的提问(引导对话只和引导对话比)、这一轮的提问
|
||||
function classifyInput(msg: { id: number; content: string; source: string; conversation_id: string; created: string }) {
|
||||
const task = get<{ title: string; content: string }>(
|
||||
`SELECT t.title, t.content FROM prompt_conversation c JOIN task_task t ON t.id = c.task_id WHERE c.id = $conv`,
|
||||
{ conv: msg.conversation_id },
|
||||
)
|
||||
const prev = get<{ content: string }>(
|
||||
`SELECT content FROM prompt_message
|
||||
WHERE conversation_id = $conv AND role = 'user' AND source != 'edit'
|
||||
AND (source = 'guidance') = $guidance
|
||||
AND (created < $created OR (created = $created AND id < $id))
|
||||
ORDER BY created DESC, id DESC LIMIT 1`,
|
||||
{ conv: msg.conversation_id, guidance: msg.source === "guidance", created: msg.created, id: msg.id },
|
||||
)
|
||||
return [
|
||||
`题目:${task?.title ?? "(未知)"}`,
|
||||
`题目要求:${task ? briefOf(task.content) : "(无)"}`,
|
||||
`上一轮提问:${prev ? clip(prev.content) : "(这是第一轮)"}`,
|
||||
`这一轮提问:${clip(msg.content)}`,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
export async function classifyMessage(messageId: number): Promise<number | null> {
|
||||
const msg = get<{ content: string }>(
|
||||
`SELECT content FROM prompt_message WHERE id = $id AND role = 'user' AND source != 'edit'`,
|
||||
const msg = get<{ id: number; content: string; source: string; conversation_id: string; created: string }>(
|
||||
`SELECT id, content, source, conversation_id, created FROM prompt_message
|
||||
WHERE id = $id AND role = 'user' AND source != 'edit'`,
|
||||
{ id: messageId },
|
||||
)
|
||||
if (!msg) return null
|
||||
const level = await callLlm(msg.content)
|
||||
// “1”“,”“可以”这类太短,判断不出层级;清掉以前打的标签(--force 重标时)
|
||||
if ([...msg.content.trim()].length < MIN_PROMPT_CHARS) {
|
||||
run(`UPDATE prompt_message SET prompt_level = NULL WHERE id = $id`, { id: messageId })
|
||||
return null
|
||||
}
|
||||
const level = await callLlm(classifyInput(msg))
|
||||
if (level !== null) {
|
||||
run(`UPDATE prompt_message SET prompt_level = $level WHERE id = $id`, {
|
||||
level,
|
||||
|
||||
+21
-11
@@ -59,10 +59,14 @@ export interface ChatMessage {
|
||||
content: string
|
||||
}
|
||||
|
||||
// DeepSeek(deepseek-flash)默认先思考再回答,思考的内容在 reasoning_content 里,也算输出 token;
|
||||
// 思考模式下 temperature 不起作用。thinking 只发给 DeepSeek,豆包不传
|
||||
interface CompletionOptions {
|
||||
model?: string
|
||||
stream: boolean
|
||||
thinking?: boolean
|
||||
maxTokens?: number
|
||||
temperature?: number
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
}
|
||||
@@ -81,7 +85,9 @@ function buildRequest(messages: ChatMessage[], opts: CompletionOptions) {
|
||||
messages,
|
||||
stream: opts.stream,
|
||||
}
|
||||
if (!ark && opts.thinking !== undefined) body.thinking = { type: opts.thinking ? "enabled" : "disabled" }
|
||||
if (opts.maxTokens) body.max_tokens = opts.maxTokens
|
||||
if (opts.temperature !== undefined) body.temperature = opts.temperature
|
||||
const apiKey = ark ? config.arkApiKey : config.llmApiKey
|
||||
if (!apiKey) throw new Error("未配置模型接口密钥")
|
||||
const baseUrl = (ark ? config.arkBaseUrl : config.llmBaseUrl).replace(/\/$/, "")
|
||||
@@ -107,8 +113,7 @@ async function ensureOk(res: Response) {
|
||||
// 读取 OpenAI 兼容接口的 SSE 流,逐段产出文本
|
||||
async function* streamCompletion(
|
||||
messages: ChatMessage[],
|
||||
model?: string,
|
||||
signal?: AbortSignal,
|
||||
{ model, thinking, signal }: { model?: string; thinking?: boolean; signal?: AbortSignal },
|
||||
): AsyncGenerator<string> {
|
||||
// 带思考的模型生成整页代码可能超过两分钟,只要一直有数据就不该中断
|
||||
const idle = new AbortController()
|
||||
@@ -123,6 +128,7 @@ async function* streamCompletion(
|
||||
const res = await buildRequest(messages, {
|
||||
model,
|
||||
stream: true,
|
||||
thinking,
|
||||
signal: AbortSignal.any(signals),
|
||||
timeoutMs: STREAM_MAX_MS,
|
||||
})
|
||||
@@ -139,6 +145,7 @@ async function* streamCompletion(
|
||||
if (data === "[DONE]") return true
|
||||
const json = JSON.parse(data)
|
||||
if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error))
|
||||
// 思考阶段只有 delta.reasoning_content,不发给学生(它也会重置空闲计时)
|
||||
const content = json.choices?.[0]?.delta?.content
|
||||
if (content) yield content
|
||||
}
|
||||
@@ -185,30 +192,33 @@ 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)],
|
||||
chatModel(model),
|
||||
return streamCompletion([{ role: "system", content: SYSTEM_PROMPT }, ...trimHistory(history)], {
|
||||
model: chatModel(model),
|
||||
thinking: true,
|
||||
signal,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 引导只是简短追问,不用思考,回得快
|
||||
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
|
||||
return streamCompletion(
|
||||
[{ role: "system", content: GUIDANCE_SYSTEM_PROMPT }, ...history],
|
||||
undefined,
|
||||
return streamCompletion([{ role: "system", content: GUIDANCE_SYSTEM_PROMPT }, ...history], {
|
||||
thinking: false,
|
||||
signal,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function complete(
|
||||
messages: ChatMessage[],
|
||||
opts: { model?: string; maxTokens?: number; timeoutMs?: number } = {},
|
||||
opts: { model?: string; thinking?: boolean; maxTokens?: number; temperature?: number; timeoutMs?: number } = {},
|
||||
): Promise<string> {
|
||||
const res = await buildRequest(messages, {
|
||||
model: opts.model ?? config.llmModel,
|
||||
stream: false,
|
||||
thinking: opts.thinking,
|
||||
maxTokens: opts.maxTokens,
|
||||
temperature: opts.temperature,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
})
|
||||
await ensureOk(res)
|
||||
|
||||
@@ -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,4 +645,50 @@ 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("提示词分类", () => {
|
||||
test("太短的提问不打层级,以前打的也清掉", async () => {
|
||||
const { classifyMessage } = await import("../src/services/classifier")
|
||||
const { createConversation, insertMessage } = await import("../src/services/prompt")
|
||||
const conv = createConversation(makeUser("stu"), makeTask("T"))
|
||||
const id = insertMessage({ conversation_id: conv, role: "user", content: " 1 " })
|
||||
run(`UPDATE prompt_message SET prompt_level = 6 WHERE id = $id`, { id })
|
||||
expect(await classifyMessage(id)).toBeNull()
|
||||
const row = get<{ prompt_level: number | null }>(`SELECT prompt_level FROM prompt_message WHERE id = $id`, { id })
|
||||
expect(row!.prompt_level).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
extractCode,
|
||||
parseGuidanceResponse,
|
||||
streamChat,
|
||||
streamGuidance,
|
||||
trimHistory,
|
||||
userFacingError,
|
||||
} from "../src/services/llm"
|
||||
@@ -77,12 +78,13 @@ test("CSV 转义", () => {
|
||||
})
|
||||
|
||||
describe("streamChat", () => {
|
||||
test("不认识的模型换成默认模型,最后一行没有换行也能读到", async () => {
|
||||
test("不认识的模型换成默认模型,最后一行没有换行也能读到;生成代码开思考,引导不开", async () => {
|
||||
let model = ""
|
||||
let thinking: unknown
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
model = ((await req.json()) as any).model
|
||||
;({ model, thinking } = (await req.json()) as any)
|
||||
const line = (s: string) => `data: ${JSON.stringify({ choices: [{ delta: { content: s } }] })}`
|
||||
// 一行拆在两个分块之间,最后一行不带换行
|
||||
const body = `${line("你好")}\n\n${line("世")}\n${line("界")}`
|
||||
@@ -108,6 +110,9 @@ describe("streamChat", () => {
|
||||
for await (const s of streamChat([{ role: "user", content: "hi" }], "deepseek-v4-flash-thinking")) out += s
|
||||
expect(out).toBe("你好世界")
|
||||
expect(model).toBe(config.llmModel)
|
||||
expect(thinking).toEqual({ type: "enabled" })
|
||||
for await (const _ of streamGuidance([{ role: "user", content: "hi" }]));
|
||||
expect(thinking).toEqual({ type: "disabled" })
|
||||
} finally {
|
||||
Object.assign(config, saved)
|
||||
server.stop(true)
|
||||
|
||||
Reference in new issue
Block a user