提示不再一上来就把话说完。等级记在「学生 × 题目」上,没有单独的表 —— 它就是 ai_hint.level 的历史,当前等级 = 这道题上(最近一次 AC 之后)给过的最高一级。 阶梯(services/hint-level.ts) - 只有学生点「再多一点提示」才升级(请求带 more),不带就按当前等级再生成一次 - 升一级要先再交一次:锚点是「这一级是**什么时候**开出来的」,也就是这一级最早那条 提示的 ai_hint.create_time,必须有比这个时刻更新的提交才准 +1。锚点不能用提示所在 那条提交的时间 —— 端点谁的提交 id 都认(只校验归属),拿一条老提交去要提示,锚点 就退回到那条老提交的时间,连点两下 more 就能从 L0 爬到 L2,一次新提交都不用交 - AC 之后清零;编译失败自成一档(level = -1),既不消耗也不推进阶梯 - HINT_MIN_FAILURES 3 → 1:门槛的活由阶梯接走了,第一次失败只开放 L0,而 L0 只反问、 什么都不泄露,拦着它没有意义 - canEscalate 由后端算好在 done 事件里给,前端不自己推阶梯 输出后过滤(services/hint-filter.ts) - 「不要给代码」写在 prompt 里只是软约束,模型忍不住一次就把这一级的意义废掉了。 所以整段生成、过滤通过才推给前端,逐字显示改由前端模拟 —— 边流式边过滤做不到, 发现违规时内容已经在学生屏幕上了 - 判定只用客观、低误报的信号:代码块、过长的行内代码、整行不含中文的类代码行、 和标准答案重合 3 行以上、L0 一句问句都没有 - 违规就重生成一次,只重一次,再不过发写死的兜底话术。重试措辞按档分叉:编译档本来 就允许给片段,对它说「不要出现任何代码」等于用阶梯的标准把这一档也砍了 - 两次都留痕(filter_attempt / filter_blocked / filter_reason),7.5 的输出过滤触发率 就是从这三列出来的 services/ai.ts 加 streamWhole:事件形状和 streamChat 一样,前端不分叉。produce 期间 每 15 秒发一行 SSE 注释当心跳 —— 这条流中间有一大段静默(诊断 20s + 生成 60s + 重生成 60s,最坏 140 秒),而 NPM / nginx 的 proxy_read_timeout 默认 60 秒,超了学生 看到「请求失败」,后端却还在烧第二次调用,那条提示照样落库、照样把等级推上去。 prompt 版本另开 3 / 4(阶梯上每一级都换了 system),编译档仍走 1 / 2 的单段式基线, 两批数据不混在一起。迁移 0021 给 ai_hint 加四列,都可空、不带默认值,已有的行留 null 表示「分级上线前」。 实跑 - 阶梯:在 dev 库上用真实行驱动 decideHintLevel。正常路径 S1→S2→S3 走出 L0→L1→L2, 同级连点 more 不升,AC 之后回 L0,编译档给 L-1 且不推进阶梯。把旧锚点规则复刻出来 跑同一组数据做对照:只拿最老那条提交反复 POST,旧规则 L0→L1→L2(零新提交), 新规则钉死在 L0,正常路径两者行为一致 - 过滤:起假 AI 服务端走完整条链路。L1 摊平代码→重生成后合规(attempt 2 / 未拦), L0 两次都甩代码块→兜底话术(blocked,reason 两条相连),编译档抄标程→命中「和标准 答案重合 3 行」且追加的是分叉后的措辞 - streamWhole:produce 拖 16.5 秒收到 1 条心跳;同一份流喂给前端 consumeJSONEventStream 只解析出 delta + done(注释行被静默跳过,前端零改动);中途 cancel 断开后 produce 跑完不抛 - api / web typecheck、check:routes、fmt 全过 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+45
-10
@@ -6,6 +6,7 @@ import {
|
||||
classPkAnalysisRequestSchema,
|
||||
HINT_MIN_FAILURES,
|
||||
type AiAnalysisRecord,
|
||||
type AiHintDone,
|
||||
type AiDetail,
|
||||
type DurationData,
|
||||
type HintDiagnosis,
|
||||
@@ -36,8 +37,14 @@ import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { JudgeStatus, type JudgeStatusValue } from "../judge/status"
|
||||
import { failure, success } from "../http"
|
||||
import { completeChat, streamChat } from "../services/ai"
|
||||
import { hintDiagnosis, hintPrompt } from "../services/hint-diagnosis"
|
||||
import { completeChat, streamChat, streamWhole } from "../services/ai"
|
||||
import { generateFilteredHint } from "../services/hint-filter"
|
||||
import { decideHintLevel } from "../services/hint-level"
|
||||
import {
|
||||
hintDiagnosis,
|
||||
hintPrompt,
|
||||
referenceAnswer,
|
||||
} from "../services/hint-diagnosis"
|
||||
import { consumeToken } from "../services/throttling"
|
||||
import {
|
||||
calendarDay,
|
||||
@@ -942,9 +949,11 @@ async function recordHint(
|
||||
promptVersion: number
|
||||
diagnosis: HintDiagnosis | null
|
||||
diagnosisError: string | null
|
||||
level: number
|
||||
},
|
||||
content: string,
|
||||
error: string | null,
|
||||
filter?: { attempt: number; blocked: boolean; reason: string | null },
|
||||
) {
|
||||
try {
|
||||
const [row] = await db
|
||||
@@ -958,6 +967,11 @@ async function recordHint(
|
||||
durationMs: Math.round(performance.now() - base.startedAt),
|
||||
diagnosis: base.diagnosis,
|
||||
diagnosisError: base.diagnosisError,
|
||||
level: base.level,
|
||||
// 生成就失败的那条没走到过滤,三列都留 null(分母里不该有它)
|
||||
filterAttempt: filter?.attempt ?? null,
|
||||
filterBlocked: filter?.blocked ?? null,
|
||||
filterReason: filter?.reason ?? null,
|
||||
createTime: new Date().toISOString(),
|
||||
})
|
||||
.returning({ id: schema.aiHint.id })
|
||||
@@ -1020,28 +1034,49 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
|
||||
}
|
||||
const limited = await throttleAi(c)
|
||||
if (limited) return limited
|
||||
// 这次按第几级生成。等级记在「学生 × 题目」上,怎么算出来的见 services/hint-level.ts
|
||||
const { level, canEscalate } = await decideHintLevel(
|
||||
c.get("user")!.id,
|
||||
row.submission,
|
||||
parsed.data.more === true,
|
||||
)
|
||||
// 标准答案**只进诊断那一段**、出参只有枚举和行号;生成提示这一段看不到它。
|
||||
// 为什么这么拆、诊断怎么退回单段式,见 services/hint-diagnosis.ts 的文件头
|
||||
const startedAt = performance.now()
|
||||
const { diagnosis, error: diagnosisError } = await hintDiagnosis(row)
|
||||
const { system, prompt, version } = hintPrompt(row, diagnosis)
|
||||
const { system, prompt, version } = hintPrompt(row, diagnosis, level)
|
||||
const base = {
|
||||
submissionId: row.submission.id,
|
||||
startedAt,
|
||||
promptVersion: version,
|
||||
diagnosis,
|
||||
diagnosisError,
|
||||
level,
|
||||
}
|
||||
return streamChat(system, prompt, {
|
||||
onComplete: async (content) => {
|
||||
const id = await recordHint(base, content, null)
|
||||
// 不是 streamChat:提示要整段生成、过滤通过才推给学生(设计 2.6),
|
||||
// 边流式边过滤做不到 —— 发现违规时内容已经在屏幕上了
|
||||
return streamWhole(
|
||||
async () => {
|
||||
const filtered = await generateFilteredHint({
|
||||
system,
|
||||
prompt,
|
||||
level,
|
||||
// 标程只用来核「有没有把它抄出来」,不进任何 prompt
|
||||
referenceCode: referenceAnswer(row)?.code ?? null,
|
||||
})
|
||||
// 落库失败就不带 id:前端据此不出评价按钮,提示本身照常显示
|
||||
return id === null ? undefined : { hintId: id }
|
||||
const hintId = await recordHint(base, filtered.content, null, filtered)
|
||||
return {
|
||||
content: filtered.content,
|
||||
extra: { hintId, level, canEscalate } satisfies AiHintDone,
|
||||
}
|
||||
},
|
||||
onError: async (message) => {
|
||||
await recordHint(base, "", message)
|
||||
{
|
||||
onError: async (message) => {
|
||||
await recordHint(base, "", message)
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
|
||||
|
||||
Reference in New Issue
Block a user