Files
OJ2/apps/api/src/routes/ai.ts
T
xuyueandClaude Opus 5 954797a9a5
Deploy / deploy (push) Canceled after 0s
feat(AI 提示): 提示分级(L0 反问 / L1 定位 / L2 概念)与输出后过滤
提示不再一上来就把话说完。等级记在「学生 × 题目」上,没有单独的表 —— 它就是
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>
2026-09-21 19:03:21 -06:00

1150 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
aiAnalysisRequestSchema,
aiHintFeedbackRequestSchema,
aiHintRequestSchema,
classAnalysisRequestSchema,
classPkAnalysisRequestSchema,
HINT_MIN_FAILURES,
type AiAnalysisRecord,
type AiHintDone,
type AiDetail,
type DurationData,
type HintDiagnosis,
type Grade,
type HeatmapItem,
type LoginSummary,
type SolvedList,
type SolvedProblem,
} from "@oj2/contract"
import {
and,
asc,
count,
countDistinct,
eq,
gte,
inArray,
isNull,
lte,
min,
sql,
} from "drizzle-orm"
import { Hono, type Context } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { getPreviousLogin, type AuthUser } from "../auth/session"
import { config } from "../config"
import { db, schema } from "../db"
import { JudgeStatus, type JudgeStatusValue } from "../judge/status"
import { failure, success } from "../http"
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,
dayNumber,
dayText,
localTime,
localWeekday,
shiftMonthsByCalendar,
} from "../time"
import {
countFailedSubmissions,
isTeacherOrAbove,
objectValue,
queryInteger,
rounded,
} from "./helpers"
export const aiRoutes = new Hono<AppEnv>()
const accepted: JudgeStatusValue[] = [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]
const difficultyNames: Record<string, string> = {
Low: "简单",
Mid: "中等",
High: "困难",
}
/**
* 每次 AI 调用都过一遍令牌桶,复用 services/throttling 的那只桶(capacity 20 / 0.03 每秒)。
* key 与代码提交的 `throttling:user:<id>`、流程图评分的 `flowchart:<id>` 分开计数 ——
* 这几个端点每调用一次就是一次真金白银的 LLM 请求,以前一处限流都没有。
*/
function aiThrottleKey(userId: number) {
return `ai:${userId}`
}
async function throttleAi(c: Context<AppEnv>) {
const throttle = await consumeToken("user", aiThrottleKey(c.get("user")!.id))
if (throttle.allowed) return null
return failure(
c,
429,
"too-many-requests",
`Please wait ${Math.floor(throttle.wait)} seconds`,
)
}
function grade(rank: number | null, count: number, reference = count): Grade {
if (!rank || count <= 0) return "C"
const percentile = ((rank - 1) / count) * 100
let value: Grade =
percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value
return value
}
function averageGrade(grades: Grade[]): Grade {
const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 }
const values = grades.flatMap((item) => weights[item] ?? [])
if (!values.length) return ""
const average = values.reduce((sum, value) => sum + value, 0) / values.length
return average >= 3.5
? "S"
: average >= 2.5
? "A"
: average >= 1.5
? "B"
: "C"
}
async function targetUser(c: Context<AppEnv>, override?: string) {
const current = c.get("user")!
const username = override ?? c.req.query("username")
if (!username || !isTeacherOrAbove(current)) return current
const [target] = await db
.select({
id: schema.user.id,
username: schema.user.username,
email: schema.user.email,
adminType: schema.user.adminType,
problemPermission: schema.user.problemPermission,
isDisabled: schema.user.isDisabled,
className: schema.user.className,
})
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
return target ?? null
}
type FirstAcRow = { problemId: number; first: string | null }
/** 区间内首次 AC 的题,按通过时间升序。limit/offset 给分页用,不传就是全部 */
function firstAcQuery(
user: AuthUser,
start: string,
end: string,
limit?: number,
offset?: number,
) {
const first = min(schema.submission.createTime)
const query = db
.select({ problemId: schema.submission.problemId, first })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
)
.groupBy(schema.submission.problemId)
.orderBy(asc(first))
return limit === undefined ? query : query.limit(limit).offset(offset ?? 0)
}
/**
* 把一批「首次 AC」的题算成逐题明细(排名、等级、尝试次数)。
* 排名只跟这批题有关,所以分页那支只需要给一页的 problemIds,不必把整年算一遍。
*/
async function buildSolved(
user: AuthUser,
start: string,
end: string,
firstAc: FirstAcRow[],
) {
const problemIds = firstAc.map((item) => item.problemId)
if (!problemIds.length)
return {
solved: [],
problems: [] as {
problem: typeof schema.problem.$inferSelect
contestTitle: string | null
}[],
scopeIds: null as number[] | null,
}
const classUsers = user.className
? await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.className, user.className))
: []
const scopeIds =
classUsers.length > 1 ? classUsers.map((item) => item.id) : null
const [problems, rankRows, periodRows, attemptRows] = await Promise.all([
db
.select({ problem: schema.problem, contestTitle: schema.contest.title })
.from(schema.problem)
.leftJoin(schema.contest, eq(schema.problem.contestId, schema.contest.id))
.where(inArray(schema.problem.id, problemIds)),
db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
first: min(schema.submission.createTime),
})
.from(schema.submission)
.where(
and(
inArray(schema.submission.result, accepted),
inArray(schema.submission.problemId, problemIds),
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
),
)
.groupBy(schema.submission.userId, schema.submission.problemId),
db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
first: min(schema.submission.createTime),
})
.from(schema.submission)
.where(
and(
inArray(schema.submission.result, accepted),
inArray(schema.submission.problemId, problemIds),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
),
)
.groupBy(schema.submission.userId, schema.submission.problemId),
db
.select({
problemId: schema.submission.problemId,
time: schema.submission.createTime,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.problemId, problemIds),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
),
])
const byProblem = new Map(problems.map((item) => [item.problem.id, item]))
// 到首次通过为止提交了几次:只数首次 AC 那一刻(含)之前的提交
const firstAcTime = new Map(
firstAc.flatMap((item) =>
item.first ? ([[item.problemId, Date.parse(item.first)]] as const) : [],
),
)
const attemptsByProblem = new Map<number, number>()
for (const row of attemptRows) {
const deadline = firstAcTime.get(row.problemId)
if (deadline === undefined || Date.parse(row.time) > deadline) continue
attemptsByProblem.set(
row.problemId,
(attemptsByProblem.get(row.problemId) ?? 0) + 1,
)
}
function ranks(rows: typeof rankRows, problemId: number) {
return rows
.filter((item) => item.problemId === problemId)
.sort(
(a, b) =>
Date.parse(a.first ?? "") - Date.parse(b.first ?? "") ||
a.userId - b.userId,
)
}
const solved = firstAc
.flatMap((item) => {
const problem = byProblem.get(item.problemId)
if (!problem || !item.first) return []
const all = ranks(rankRows, item.problemId)
const period = ranks(periodRows, item.problemId)
const rank = all.findIndex((row) => row.userId === user.id) + 1 || null
const periodRank =
period.findIndex((row) => row.userId === user.id) + 1 || null
return {
problem: {
title: problem.problem.title,
displayId: problem.problem.displayId,
contestTitle: problem.contestTitle ?? "",
contestId: problem.problem.contestId,
},
acTime: item.first,
rank,
acCount: all.length,
grade: grade(periodRank, period.length, all.length),
periodRank,
periodAcCount: period.length,
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
attempts: attemptsByProblem.get(item.problemId) ?? 1,
} satisfies SolvedProblem
})
.sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
return { solved, problems, scopeIds }
}
/** 分页版:只算这一页的题 */
async function listSolved(
user: AuthUser,
start: string,
end: string,
limit: number,
offset: number,
) {
const [firstAc, totalRows] = await Promise.all([
firstAcQuery(user, start, end, limit, offset),
db
.select({ value: countDistinct(schema.submission.problemId) })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
),
])
const { solved } = await buildSolved(user, start, end, firstAc)
return {
results: solved,
total: totalRows[0]?.value ?? 0,
} satisfies SolvedList
}
async function buildDetail(user: AuthUser, start: string, end: string) {
// 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次
// 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。
// 星期和小时都按东八区取,和热力图同口径
const weekday =
sql<number>`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(
Number,
)
const period =
sql<number>`floor(extract(hour from ${localTime(schema.submission.createTime)}) / 6)::int`.mapWith(
Number,
)
const activityRows = await db
.select({ weekday, period, value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
)
.groupBy(weekday, period)
const activity = activityRows.map((row) => ({
weekday: row.weekday,
period: row.period,
count: row.value,
}))
// 区间内该用户的全部提交,一次拉回来喂两处:错题类型分布、每题到首次通过的尝试次数。
// 放在 problemIds 的空判断之前 —— 一道题都没做出来的学生,错题分布照样有意义
const submissions = await db
.select({
problemId: schema.submission.problemId,
time: schema.submission.createTime,
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
)
const settledFail = (result: JudgeStatusValue) =>
!accepted.includes(result) &&
result !== JudgeStatus.PENDING &&
result !== JudgeStatus.JUDGING
const errorCounts = new Map<number, number>()
for (const row of submissions) {
if (!settledFail(row.result)) continue
errorCounts.set(row.result, (errorCounts.get(row.result) ?? 0) + 1)
}
const errors = [...errorCounts]
.map(([result, count]) => ({ result, count }))
.sort((a, b) => b.count - a.count || a.result - b.result)
const firstAc = await firstAcQuery(user, start, end)
const problemIds = firstAc.map((item) => item.problemId)
if (!problemIds.length)
return {
user: user.username,
className: user.className,
start,
end,
solvedCount: 0,
attempts: [],
flowcharts: [],
grade: "",
tags: {},
difficulty: {},
contestCount: 0,
activity,
errors,
rankScope: "global",
} satisfies AiDetail
const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all(
[
buildSolved(user, start, end, firstAc),
db
.select({
problemId: schema.problemTags.problemId,
name: schema.problemTag.name,
})
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.where(inArray(schema.problemTags.problemId, problemIds)),
db
.select({
flow: schema.flowchartSubmission,
displayId: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.flowchartSubmission)
.innerJoin(
schema.problem,
eq(schema.flowchartSubmission.problemId, schema.problem.id),
)
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
eq(schema.flowchartSubmission.status, 2),
gte(schema.flowchartSubmission.createTime, start),
lte(schema.flowchartSubmission.createTime, end),
),
),
],
)
const tags: Record<string, number> = {}
for (const tag of tagRows) tags[tag.name] = (tags[tag.name] ?? 0) + 1
const topTags = Object.fromEntries(
Object.entries(tags)
.sort((a, b) => b[1] - a[1])
.slice(0, 5),
)
const difficulty: Record<string, number> = { 简单: 0, 中等: 0, 困难: 0 }
for (const item of problems) {
const name = difficultyNames[item.problem.difficulty] ?? "中等"
difficulty[name] = (difficulty[name] ?? 0) + 1
}
const flowGroups = new Map<string, typeof flowRows>()
for (const flow of flowRows)
flowGroups.set(flow.displayId, [
...(flowGroups.get(flow.displayId) ?? []),
flow,
])
const flowcharts = [...flowGroups]
.map(([displayId, rows]) => {
const scores = rows.flatMap((row) => row.flow.aiScore ?? [])
// 直接留住得分最高的那一次,等级读它。原来是拿 max 回头 find 分数相等的行 ——
// ai_score 是 double,相等比较本就不可靠;全是 null 时 max 退成 0,更是谁都匹配不上
const top = rows.reduce(
(best, row) =>
(row.flow.aiScore ?? -1) > (best.flow.aiScore ?? -1) ? row : best,
rows[0]!,
)
return {
problemId: displayId,
problemTitle: rows[0]?.title ?? "",
submissionCount: rows.length,
bestScore: Math.max(0, top.flow.aiScore ?? 0),
bestGrade: top.flow.aiGrade ?? "",
latestSubmissionTime:
rows
.map((row) => row.flow.createTime)
.sort()
.at(-1) ?? start,
avgScore: rounded(
scores.length
? scores.reduce((sum, value) => sum + value, 0) / scores.length
: 0,
0,
),
}
})
.sort((a, b) =>
b.latestSubmissionTime.localeCompare(a.latestSubmissionTime),
)
return {
user: user.username,
className: user.className,
start,
end,
flowcharts,
solvedCount: solved.length,
attempts: solved.map((item) => item.attempts),
grade: averageGrade(solved.map((item) => item.grade)),
tags: topTags,
difficulty,
contestCount: new Set(
solved.flatMap((item) => item.problem.contestId ?? []),
).size,
activity,
errors,
rankScope: scopeIds ? "class" : "global",
} satisfies AiDetail
}
aiRoutes.get("/ai/detail", requireAuth, async (c) => {
const start = c.req.query("start")
const end = c.req.query("end")
if (
!start ||
!end ||
Number.isNaN(Date.parse(start)) ||
Number.isNaN(Date.parse(end))
) {
return failure(
c,
400,
"invalid-range",
"start and end must be ISO 8601 timestamps",
)
}
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
return success(c, await buildDetail(user, start, end))
})
aiRoutes.get("/ai/solved", requireAuth, async (c) => {
const start = c.req.query("start")
const end = c.req.query("end")
if (
!start ||
!end ||
Number.isNaN(Date.parse(start)) ||
Number.isNaN(Date.parse(end))
) {
return failure(
c,
400,
"invalid-range",
"start and end must be ISO 8601 timestamps",
)
}
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 100 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
return success(c, await listSolved(user, start, end, limit, offset))
})
async function buildDuration(
user: AuthUser,
endText: string,
duration: string,
) {
const config =
duration === "months:2"
? {
count: 8,
unit: "weeks",
rewind: (date: Date) => new Date(date.getTime() - 9 * 7 * 864e5),
advance: (date: Date) => new Date(date.getTime() + 7 * 864e5),
}
: duration === "months:6"
? {
count: 6,
unit: "months",
rewind: (date: Date) => shiftMonthsByCalendar(date, -7),
advance: (date: Date) => shiftMonthsByCalendar(date, 1),
}
: duration === "years:1"
? {
count: 12,
unit: "months",
rewind: (date: Date) => shiftMonthsByCalendar(date, -13),
advance: (date: Date) => shiftMonthsByCalendar(date, 1),
}
: {
count: 4,
unit: "weeks",
rewind: (date: Date) => new Date(date.getTime() - 5 * 7 * 864e5),
advance: (date: Date) => new Date(date.getTime() + 7 * 864e5),
}
// 先把 count 个时间桶算出来,再一条查询把整段区间的提交拉回来在内存里分桶。
// 以前是每个桶两条查询、桶之间还是串行的,一年 12 个桶就是 24 次往返。
// 相邻桶首尾相接、两端都是闭区间(end_i == start_{i+1}),落在边界上的提交
// 两个桶都算 —— 这是旧行为,照搬,不要「顺手」改成半开区间。
let cursor = config.rewind(new Date(endText))
const buckets: { start: Date; end: Date }[] = []
for (let index = 0; index < config.count; index++) {
const start = config.advance(cursor)
buckets.push({ start, end: config.advance(start) })
cursor = start
}
// 时间戳取 epoch 毫秒回来,比较在 JS 里做,和原来在 SQL 里比 timestamptz 等价,
// 不受 pg 那个「空格分隔 + +00 偏移」字符串格式能否被 Date.parse 认的影响
const rows = await db
.select({
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(
Number,
),
problemId: schema.submission.problemId,
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
lte(schema.submission.createTime, buckets.at(-1)!.end.toISOString()),
),
)
// 每个桶的等级 = 桶内解出的每道题各算一个等级再取平均,排名按「同班同学在这个桶里
// 解出该题的先后」。和旧后端 OnlineJudge/ai/views/oj.py:484 一条一条对齐,包括这里
// 不传 reference(不打小规模折扣)—— 那个折扣只在 /ai/detail 那支用。
// 迁移时这里被写死成 `solved ? "B" : ""`DurationChart 上那条等级折线因此恒定在 B。
const solvedIds = [
...new Set(
rows
.filter((row) => accepted.includes(row.result))
.map((row) => row.problemId),
),
]
const classUsers = user.className
? await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.className, user.className))
: []
const scopeIds =
classUsers.length > 1 ? classUsers.map((item) => item.id) : null
const peers = solvedIds.length
? await db
.select({
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(
Number,
),
userId: schema.submission.userId,
problemId: schema.submission.problemId,
})
.from(schema.submission)
.where(
and(
inArray(schema.submission.result, accepted),
inArray(schema.submission.problemId, solvedIds),
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
lte(
schema.submission.createTime,
buckets.at(-1)!.end.toISOString(),
),
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
),
)
: []
// 一次查回来在内存里按题分组再按桶切,别在循环里发查询:一年 12 个桶 × 几十道题
const peersByProblem = new Map<number, typeof peers>()
for (const row of peers)
peersByProblem.set(row.problemId, [
...(peersByProblem.get(row.problemId) ?? []),
row,
])
function bucketGrade(problemIds: number[], from: number, to: number) {
return averageGrade(
problemIds.map((problemId) => {
const firstAc = new Map<number, number>()
for (const row of peersByProblem.get(problemId) ?? []) {
if (row.time < from || row.time > to) continue
const seen = firstAc.get(row.userId)
if (seen === undefined || row.time < seen)
firstAc.set(row.userId, row.time)
}
const ordered = [...firstAc].sort((a, b) => a[1] - b[1] || a[0] - b[0])
const rank = ordered.findIndex(([id]) => id === user.id) + 1 || null
return grade(rank, ordered.length)
}),
)
}
return buckets.map((bucket, index) => {
const from = bucket.start.getTime()
const to = bucket.end.getTime()
const inRange = rows.filter((row) => row.time >= from && row.time <= to)
const acceptedRows = inRange.filter((row) => accepted.includes(row.result))
const solved = [...new Set(acceptedRows.map((row) => row.problemId))]
return {
unit: config.unit,
index: config.count - 1 - index,
start: bucket.start.toISOString(),
end: bucket.end.toISOString(),
grade: solved.length ? bucketGrade(solved, from, to) : "",
problemCount: solved.length,
acceptedCount: acceptedRows.length,
submissionCount: inRange.length,
} satisfies DurationData
})
}
aiRoutes.get("/ai/duration", requireAuth, async (c) => {
const endText = c.req.query("end")
if (!endText || Number.isNaN(Date.parse(endText)))
return failure(c, 400, "invalid-end", "end must be an ISO timestamp")
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
return success(
c,
await buildDuration(user, endText, c.req.query("duration") ?? "months:1"),
)
})
aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
const end = new Date()
// 一格一周,共 53 格,最后一格是「本周」。周一算一周的开头(不用 GitHub 的周日)。
// 整段以东八区的**日历日序号**为单位算(`dayNumber` / `dayText`),不构造本地 Date。
const today = dayNumber(calendarDay(end))
const mondayOffset = (localWeekday(today) + 6) % 7
const firstMonday = today - mondayOffset - 52 * 7
// SQL 两端各放宽一天:范围只用来少拉行,精确匹配靠下面按日历日 key 查表
const date = sql<string>`date(${localTime(schema.submission.createTime)})::text`
const rows = await db
.select({ date, value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(
schema.submission.createTime,
new Date((firstMonday - 1) * 864e5).toISOString(),
),
lte(
schema.submission.createTime,
new Date(end.getTime() + 864e5).toISOString(),
),
),
)
.groupBy(date)
.orderBy(date)
const counts = new Map(rows.map((row) => [row.date, row.value]))
return success(
c,
Array.from({ length: 53 }, (_, week) => {
const monday = firstMonday + week * 7
let value = 0
for (let offset = 0; offset < 7; offset++)
value += counts.get(dayText(monday + offset)) ?? 0
// timestamp 是该周周一的 UTC 零点,前端按东八区只取年月日部件
return { timestamp: monday * 864e5, value } satisfies HeatmapItem
}),
)
})
aiRoutes.get("/ai/login-summary", requireAuth, async (c) => {
const user = c.get("user")!
const end = new Date()
const [userRow] = await db
.select({
createTime: schema.user.createTime,
lastLogin: schema.user.lastLogin,
})
.from(schema.user)
.where(eq(schema.user.id, user.id))
.limit(1)
const previous = await getPreviousLogin(c)
let start = new Date(
previous ??
userRow?.lastLogin ??
userRow?.createTime ??
end.getTime() - 7 * 864e5,
)
if (start >= end) start = new Date(end.getTime() - 864e5)
const range = and(
gte(schema.submission.createTime, start.toISOString()),
lte(schema.submission.createTime, end.toISOString()),
)
const [newProblems, submissions, acceptedRows, solvedRows, flowRows] =
await Promise.all([
db
.select({ value: count() })
.from(schema.problem)
.where(
and(
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
gte(schema.problem.createTime, start.toISOString()),
lte(schema.problem.createTime, end.toISOString()),
),
),
db
.select({ value: count() })
.from(schema.submission)
.where(and(eq(schema.submission.userId, user.id), range)),
db
.select({ value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
range,
),
),
db
.select({ value: countDistinct(schema.submission.problemId) })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
range,
),
),
db
.select({ value: count() })
.from(schema.flowchartSubmission)
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
gte(schema.flowchartSubmission.createTime, start.toISOString()),
lte(schema.flowchartSubmission.createTime, end.toISOString()),
),
),
])
const summary = {
start: start.toISOString(),
end: end.toISOString(),
newProblemCount: newProblems[0]?.value ?? 0,
submissionCount: submissions[0]?.value ?? 0,
acceptedCount: acceptedRows[0]?.value ?? 0,
solvedCount: solvedRows[0]?.value ?? 0,
flowchartSubmissionCount: flowRows[0]?.value ?? 0,
}
let analysis = ""
let analysisError: string | undefined
// 这支是登录后自动触发的,没有用户点击 —— 更要过限流,否则反复刷新就是反复调模型。
// 被限住时安静跳过:analysis 本来就是可选的,弹窗里的统计数字照常显示。
if (
summary.submissionCount >= 3 &&
(await consumeToken("user", aiThrottleKey(user.id))).allowed
) {
try {
analysis = await completeChat(
"你是 OnlineJudge 的学习助教。请根据统计数据给出简短分析(1-2句),再给出一行以“结论:”开头的结论。",
JSON.stringify(summary),
)
} catch (error) {
analysisError = error instanceof Error ? error.message : String(error)
}
}
return success(c, { summary, analysis, analysisError } satisfies LoginSummary)
})
aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
const [row] = await db
.select({ analysis: schema.aiAnalysis, username: schema.user.username })
.from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(
and(
eq(schema.aiAnalysis.userId, c.get("user")!.id),
eq(schema.aiAnalysis.isPinned, true),
),
)
.limit(1)
if (!row) return success(c, null)
return success(c, {
id: row.analysis.id,
provider: row.analysis.provider,
model: row.analysis.model,
data: objectValue(row.analysis.data),
analysis: row.analysis.analysis,
createTime: row.analysis.createTime,
isPinned: row.analysis.isPinned,
username: row.username,
} satisfies AiAnalysisRecord)
})
aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
const parsed = aiAnalysisRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(
c,
400,
"invalid-request",
"start, end and duration are required",
)
if (
Number.isNaN(Date.parse(parsed.data.start)) ||
Number.isNaN(Date.parse(parsed.data.end))
) {
return failure(
c,
400,
"invalid-range",
"start and end must be ISO 8601 timestamps",
)
}
// 传 username 的鉴权走 targetUser:非教师传了也只会拿到自己
const user = await targetUser(c, parsed.data.username)
if (!user) return failure(c, 404, "user-not-found", "User not found")
const limited = await throttleAi(c)
if (limited) return limited
// 学情数据一律服务端重算,客户端只说看谁、哪段时间
// detail 现在只带聚合,逐题明细单独取一页给模型看。顺带把喂进 prompt 的条数
// 卡在 200 —— 以前是整份 solved 无上限塞进去,题做得多的学生一次调用能顶好几倍 token
const [details, duration, solved] = await Promise.all([
buildDetail(user, parsed.data.start, parsed.data.end),
buildDuration(user, parsed.data.end, parsed.data.duration),
listSolved(user, parsed.data.start, parsed.data.end, 200, 0),
])
const system =
"你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown,不要放在代码块中。"
const prompt = `详细数据: ${JSON.stringify({ ...details, solved: solved.results })}\n每周或每月数据: ${JSON.stringify(duration)}`
return streamChat(system, prompt, {
onComplete: async (analysis) => {
// 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的
// GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到
await db.insert(schema.aiAnalysis).values({
provider: config.aiProvider,
model: config.aiModel,
data: { details, duration, solved: solved.results },
systemPrompt: system,
userPrompt: "学习详情与周期数据",
analysis,
createTime: new Date().toISOString(),
userId: user.id,
isPinned: false,
})
},
})
})
/**
* 记一条提示(成功或失败)。**失败只打日志、返回 null** —— 留痕是附带的,
* 不能因为它写不进去就让学生看到「AI 提示生成失败」。
*/
async function recordHint(
base: {
submissionId: string
startedAt: number
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
.insert(schema.aiHint)
.values({
submissionId: base.submissionId,
model: config.aiModel,
promptVersion: base.promptVersion,
content,
error,
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 })
return row?.id ?? null
} catch (e) {
console.error("Failed to record AI hint", e)
return null
}
}
aiRoutes.post("/ai/hint", requireAuth, async (c) => {
const parsed = aiHintRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "submissionId is required")
const [row] = await db
.select({ submission: schema.submission, problem: schema.problem })
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.where(
and(
eq(schema.submission.id, parsed.data.submissionId),
eq(schema.submission.userId, c.get("user")!.id),
),
)
.limit(1)
if (!row)
return failure(c, 404, "submission-not-found", "Submission not found")
// 比赛里不给 AI 提示,和「求助」按钮同一个口径。前端在比赛路由下压根不显示按钮,
// 这里是防直接 POST 的那一道 —— 比赛只有 ACM 模式,提示等于变相放水。
if (row.submission.contestId !== null)
return failure(
c,
403,
"contest-hint-disabled",
"Hint is disabled in contests",
)
// 失败次数在端点这边也要卡一道:直接 POST 完全绕开前端的显示条件 ——
// 不然这就是个不限次数的免费 LLM 接口。数法(判题中的不算、判题机自己崩的不算)
// 由 countFailedSubmissions 统一,题目详情的 myFailedCount 走的是同一个函数,
// 所以前端亮出按钮的时刻和这里放行的时刻严格对齐。
// 编译失败不数次数(理由见 HINT_MIN_FAILURES 的注释)。放开的只是这一次提交本身,
// 下面的 throttleAi 照样卡着,不会因此变成不限次数的接口。
if (row.submission.result !== JudgeStatus.COMPILE_ERROR) {
const failed = await countFailedSubmissions(
c.get("user")!.id,
row.submission.problemId,
)
if (failed < HINT_MIN_FAILURES)
return failure(
c,
403,
"hint-locked",
`Hint unlocks after ${HINT_MIN_FAILURES} failed submissions`,
)
}
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, level)
const base = {
submissionId: row.submission.id,
startedAt,
promptVersion: version,
diagnosis,
diagnosisError,
level,
}
// 不是 streamChat:提示要整段生成、过滤通过才推给学生(设计 2.6),
// 边流式边过滤做不到 —— 发现违规时内容已经在屏幕上了
return streamWhole(
async () => {
const filtered = await generateFilteredHint({
system,
prompt,
level,
// 标程只用来核「有没有把它抄出来」,不进任何 prompt
referenceCode: referenceAnswer(row)?.code ?? null,
})
// 落库失败就不带 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)
},
},
)
})
aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = aiHintFeedbackRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!id || !parsed.success)
return failure(c, 400, "invalid-request", "helpful is required")
// 只能评自己的提示:顺着 submission 核对是不是本人。别人的和不存在的一样回 404,
// 不透露那个 id 上有没有东西
const [updated] = await db
.update(schema.aiHint)
.set({
helpful: parsed.data.helpful,
feedbackTime: new Date().toISOString(),
})
.where(
and(
eq(schema.aiHint.id, id),
inArray(
schema.aiHint.submissionId,
db
.select({ id: schema.submission.id })
.from(schema.submission)
.where(eq(schema.submission.userId, c.get("user")!.id)),
),
),
)
.returning({ id: schema.aiHint.id })
if (!updated) return failure(c, 404, "hint-not-found", "Hint not found")
return success(c, null)
})
aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
if (!isTeacherOrAbove(c.get("user")))
return failure(c, 403, "permission-denied", "Permission denied")
const parsed = classAnalysisRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Class data is required")
const limited = await throttleAi(c)
if (limited) return limited
return streamChat(
"你是编程教育数据分析专家。根据班级 OJ 数据,从整体水平、参与积极性、均衡性、梯队和改进建议五方面输出中文 Markdown 报告。",
JSON.stringify(parsed.data.comparison),
)
})
aiRoutes.post("/ai/class-pk-analysis", requireAuth, async (c) => {
if (!isTeacherOrAbove(c.get("user")))
return failure(c, 403, "permission-denied", "Permission denied")
const parsed = classPkAnalysisRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(
c,
400,
"invalid-request",
"At least two classes are required",
)
const limited = await throttleAi(c)
if (limited) return limited
return streamChat(
"你是编程教育数据分析专家。根据多个班级 OJ 对比数据,从排名、参与度、典型学生水平、均衡性、梯队、提交质量和教学建议七方面输出中文 Markdown 报告。",
`${parsed.data.timeRangeLabel}\n${JSON.stringify(parsed.data.comparisons)}`,
)
})