feat(AI 提示): 提示分级(L0 反问 / L1 定位 / L2 概念)与输出后过滤
Deploy / deploy (push) Canceled after 0s

提示不再一上来就把话说完。等级记在「学生 × 题目」上,没有单独的表 —— 它就是
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:
xuyueandClaude Opus 5 committed 2026-09-21 19:03:21 -06:00
1 parent 8b4d8899f9
commit 954797a9a5
11 files changed
+4674 -39

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
-- AI 提示的分级与输出过滤留痕(AI 时代 OJ 设计 2c),字段含义见 schema.ts 的 aiHint。
-- 四列都可空、不带默认值,加列只改目录不重写表;已有的行留 null,表示「分级上线前」。
ALTER TABLE "ai_hint" ADD COLUMN "level" integer;--> statement-breakpoint
ALTER TABLE "ai_hint" ADD COLUMN "filter_attempt" integer;--> statement-breakpoint
ALTER TABLE "ai_hint" ADD COLUMN "filter_blocked" boolean;--> statement-breakpoint
ALTER TABLE "ai_hint" ADD COLUMN "filter_reason" text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -148,6 +148,13 @@
"when": 1789906612433,
"tag": "0020_drop_unsupported_languages",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1789990062378,
"tag": "0021_ai_hint_level",
"breakpoints": true
}
]
}
+23
View File
@@ -1034,6 +1034,29 @@ export const aiHint = pgTable(
diagnosis: jsonb().$type<HintDiagnosis>(),
// 诊断失败的原因(超时、回的不是 JSON、校验不过)。这时第二段退回单段式的 prompt
diagnosisError: text("diagnosis_error"),
/**
* 这条提示给的是哪一级(契约的 `HINT_LEVELS`,AI 时代 OJ 设计 2c)。
* -1 = 编译失败那一档,**不在阶梯上**;null = 2c 上线前那批不分级的提示。
* 查阶梯的 SQL 一律 `level >= 0`,两者都被摘掉。
*/
level: integer(),
/**
* 一共生成了几次(1 = 一次就过,2 = 重生成过)。**整段生成、过滤后才推**(边流式边
* 过滤做不到:发现违规时内容已经在学生屏幕上了),违规就重生成一次,只重一次。
*
* **不是「发出去的是第几次生成」** —— `filter_blocked` 为真时两次都作废了,发的是
* 兜底话术。7.5 的「输出过滤触发率」= `filter_attempt = 2` ÷ `filter_attempt` 非空。
*/
filterAttempt: integer("filter_attempt"),
/**
* 两次都违规,发的是写死的兜底话术(content 是那句话,不是模型的输出)。
*
* **这种行的 `error` 仍是 null** —— 生成本身没失败,是内容被拦了。所以算「生成失败率」
* 看 `error`,算「学生有没有真拿到提示」要另外扣掉 `filter_blocked` 为真的这批。
*/
filterBlocked: boolean("filter_blocked"),
// 被拦下的原因,两次都被拦时是两条(用 ; 连)。null = 一次都没触发过过滤
filterReason: text("filter_reason"),
// 学生的评价:null = 没评
helpful: boolean(),
feedbackTime: timestamp("feedback_time", {