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>
248 lines
8.8 KiB
TypeScript
248 lines
8.8 KiB
TypeScript
import { config } from "../config"
|
|
|
|
interface ChatMessage {
|
|
role: "system" | "user"
|
|
content: string
|
|
}
|
|
|
|
function requestBody(messages: ChatMessage[], stream: boolean, json = false) {
|
|
return {
|
|
model: config.aiModel,
|
|
messages,
|
|
stream,
|
|
temperature: 0,
|
|
thinking: { type: "disabled" },
|
|
// DeepSeek 的 JSON 模式:保证回的是合法 JSON,但 prompt 里得出现「json」字样
|
|
...(json ? { response_format: { type: "json_object" } } : {}),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 非流式调用的超时。fetch 默认不超时,AI 侧一挂就会把 worker 的并发位一直占着,
|
|
* 学生那边的按钮也就一直转。流式调用不设:那边超时会把正在推的长回答直接掐断,
|
|
* 客户端断开本来就能收尾。
|
|
*/
|
|
const COMPLETE_TIMEOUT_MS = 60_000
|
|
|
|
export async function completeChat(
|
|
system: string,
|
|
user: string,
|
|
options: { json?: boolean; timeoutMs?: number } = {},
|
|
) {
|
|
if (!config.aiKey) throw new Error("缺少 AI_KEY")
|
|
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
|
|
method: "POST",
|
|
signal: AbortSignal.timeout(options.timeoutMs ?? COMPLETE_TIMEOUT_MS),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
authorization: `Bearer ${config.aiKey}`,
|
|
},
|
|
body: JSON.stringify(
|
|
requestBody(
|
|
[
|
|
{ role: "system", content: system },
|
|
{ role: "user", content: user },
|
|
],
|
|
false,
|
|
options.json,
|
|
),
|
|
),
|
|
})
|
|
if (!response.ok)
|
|
throw new Error(
|
|
`AI provider returned HTTP ${response.status}: ${await response.text()}`,
|
|
)
|
|
const payload = (await response.json()) as {
|
|
choices?: Array<{ message?: { content?: string } }>
|
|
}
|
|
return payload.choices?.[0]?.message?.content?.trim() ?? ""
|
|
}
|
|
|
|
export interface StreamChatHooks {
|
|
/**
|
|
* 生成完整结束后调,拿到的是全文。**返回的对象会并进 `done` 事件**,
|
|
* 用来把落库之后才有的东西(比如 ai_hint 的 id)交给前端。
|
|
*/
|
|
onComplete?: (value: string) => Promise<Record<string, unknown> | void>
|
|
/**
|
|
* 生成失败时调(没配 AI_KEY、provider 报错、流中途断掉)。只用来留痕,
|
|
* 抛出的异常会被吞掉 —— 记录失败不该再搅乱这条流本身的收尾。
|
|
*/
|
|
onError?: (message: string) => Promise<void>
|
|
}
|
|
|
|
export function streamChat(
|
|
system: string,
|
|
user: string,
|
|
hooks: StreamChatHooks = {},
|
|
) {
|
|
const encoder = new TextEncoder()
|
|
const reportError = (message: string) =>
|
|
hooks.onError?.(message).catch((error) => {
|
|
console.error("streamChat onError hook failed", error)
|
|
})
|
|
const body = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
const send = (value: string) => controller.enqueue(encoder.encode(value))
|
|
if (!config.aiKey) {
|
|
await reportError("缺少 AI_KEY")
|
|
send(
|
|
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
|
|
)
|
|
send("event: end\n\n")
|
|
controller.close()
|
|
return
|
|
}
|
|
try {
|
|
const response = await fetch(
|
|
new URL("/chat/completions", config.aiBaseUrl),
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
authorization: `Bearer ${config.aiKey}`,
|
|
},
|
|
body: JSON.stringify(
|
|
requestBody(
|
|
[
|
|
{ role: "system", content: system },
|
|
{ role: "user", content: user },
|
|
],
|
|
true,
|
|
),
|
|
),
|
|
},
|
|
)
|
|
if (!response.ok || !response.body)
|
|
throw new Error(
|
|
`AI provider returned HTTP ${response.status}: ${await response.text()}`,
|
|
)
|
|
send("event: start\n\n")
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ""
|
|
const chunks: string[] = []
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
buffer += decoder.decode(value, { stream: !done })
|
|
const lines = buffer.split("\n")
|
|
buffer = lines.pop() ?? ""
|
|
for (const raw of lines) {
|
|
const line = raw.trim()
|
|
if (!line.startsWith("data:")) continue
|
|
const data = line.slice(5).trim()
|
|
if (data === "[DONE]") continue
|
|
try {
|
|
const item = JSON.parse(data) as {
|
|
choices?: Array<{
|
|
delta?: { content?: string }
|
|
finish_reason?: string | null
|
|
}>
|
|
}
|
|
const choice = item.choices?.[0]
|
|
const content = choice?.delta?.content
|
|
if (content) {
|
|
chunks.push(content)
|
|
send(`data: ${JSON.stringify({ type: "delta", content })}\n\n`)
|
|
}
|
|
} catch {
|
|
// Provider keepalive or a partial non-data line.
|
|
}
|
|
}
|
|
if (done) break
|
|
}
|
|
const full = chunks.join("").trim()
|
|
const extra = hooks.onComplete
|
|
? await hooks.onComplete(full)
|
|
: undefined
|
|
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
// 先留痕再回前端:客户端已经断开的话下面这个 send 自己也会抛
|
|
await reportError(message)
|
|
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
|
|
} finally {
|
|
send("event: end\n\n")
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: {
|
|
"content-type": "text/event-stream; charset=utf-8",
|
|
"cache-control": "no-cache",
|
|
"x-accel-buffering": "no",
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 整段生成、拿到全文之后一次性推给前端(AI 时代 OJ 设计 2.6)。
|
|
*
|
|
* AI 提示走这条而不是 `streamChat`:提示要过**输出后过滤**,而边流式边过滤做不到 ——
|
|
* 发现违规时内容已经在学生屏幕上了。所以 `produce` 里把生成、过滤、重生成、落库全
|
|
* 做完,再把定稿的全文当**一个 delta** 发出去,逐字显示交给前端模拟。
|
|
*
|
|
* 事件形状和 `streamChat` 完全一样(start / delta / done / error + `event: end`),
|
|
* 前端那套 `consumeJSONEventStream` 不用分叉。`event: start` 在 `produce` 之前就发,
|
|
* 学生那边的等待态和原来一样先亮起来。
|
|
*
|
|
* **`produce` 期间必须发心跳。** 这条流和 `streamChat` 最大的不同是中间有一大段静默:
|
|
* 诊断 20s + 生成 60s + 重生成 60s,最坏能到 140 秒,而 NPM / nginx 的
|
|
* `proxy_read_timeout` 默认就是 60 秒 —— 超了学生看到「请求失败」,后端却还在烧第二次
|
|
* 调用,而且那条提示照样落库、照样把等级推上去(学生白花一级)。所以每
|
|
* `HEARTBEAT_MS` 发一行 SSE 注释:前端 `utils/stream.ts` 只认 `event:` / `data:` 开头的
|
|
* 行,注释行被静默跳过,不用改前端。
|
|
*/
|
|
export function streamWhole(
|
|
produce: () => Promise<{ content: string; extra?: Record<string, unknown> }>,
|
|
hooks: { onError?: (message: string) => Promise<void> } = {},
|
|
) {
|
|
const encoder = new TextEncoder()
|
|
// 反代的读超时是 60s(见上),取它的四分之一,够抗一次抖动
|
|
const HEARTBEAT_MS = 15_000
|
|
let closed = false
|
|
const body = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
// 学生关掉页面之后 enqueue 会抛,而这时 produce 还在跑(留痕要它跑完),
|
|
// 所以 send 自己吞掉异常并记下「已经断了」,后面几步不用各写一遍 try
|
|
const send = (value: string) => {
|
|
if (closed) return
|
|
try {
|
|
controller.enqueue(encoder.encode(value))
|
|
} catch {
|
|
closed = true
|
|
}
|
|
}
|
|
const heartbeat = setInterval(() => send(": ping\n\n"), HEARTBEAT_MS)
|
|
try {
|
|
send("event: start\n\n")
|
|
const { content, extra } = await produce()
|
|
send(`data: ${JSON.stringify({ type: "delta", content })}\n\n`)
|
|
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
// 先留痕再回前端
|
|
await hooks.onError?.(message).catch((e) => {
|
|
console.error("streamWhole onError hook failed", e)
|
|
})
|
|
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
|
|
} finally {
|
|
clearInterval(heartbeat)
|
|
send("event: end\n\n")
|
|
if (!closed) controller.close()
|
|
}
|
|
},
|
|
cancel() {
|
|
closed = true
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: {
|
|
"content-type": "text/event-stream; charset=utf-8",
|
|
"cache-control": "no-cache",
|
|
"x-accel-buffering": "no",
|
|
},
|
|
})
|
|
}
|