feat(智能分析): 后端改成按「每节课」给数据,AI 小结跟着改,不再把交了好几次的题夸成做得好

GET /ai/report 一次给整页:每天分课上的题和自己练的,和班上中间那位比,差一点的、错在哪、
知识点;时间段改成 这周 / 这个月 / 这学期 / 全部。删了 /ai/detail、/ai/solved、
/ai/duration、/ai/heatmap、/ai/pinned,换成 /ai/analysis/latest。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 23:20:34 +08:00
1 parent 69f8d89153
commit 890e731a1c
4 files changed
+507 -721

No files matched your search

+83 -157
View File
@@ -5,15 +5,15 @@ import {
classAnalysisRequestSchema,
classPkAnalysisRequestSchema,
HINT_MIN_FAILURES,
LEARNING_PERIOD_LABELS,
learningPeriodSchema,
type AiAnalysisRecord,
type AiDetail,
type AiHintDone,
type DurationData,
type HintDiagnosis,
type HeatmapItem,
type SolvedProblem,
type LearningItem,
type LearningReport,
} from "@oj2/contract"
import { and, count, eq, gte, inArray, lte, sql } from "drizzle-orm"
import { and, desc, eq, inArray, or, sql } from "drizzle-orm"
import { Hono, type Context } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
@@ -23,12 +23,12 @@ import { JudgeStatus, judgeStatusName } from "../judge/status"
import { failure, parseBody, readJson, success } from "../http"
import { streamChat, streamWhole } from "../services/ai"
import { generateFilteredHint } from "../services/hint-filter"
import { buildDetail, buildDuration, listSolved } from "../services/learning-stats"
import { buildReport } from "../services/learning-stats"
import { decideHintLevel } from "../services/hint-level"
import { hintDiagnosis, hintPrompt, referenceAnswer } from "../services/hint-diagnosis"
import { classDetail, classPk } from "../services/ranking"
import { consumeToken } from "../services/throttling"
import { calendarDay, dayNumber, dayText, localTime, localWeekday } from "../time"
import { dayNumber, localWeekday } from "../time"
import { countFailedSubmissions, isTeacherOrAbove, asRecord, queryInteger } from "./helpers"
export const aiRoutes = new Hono<AppEnv>()
@@ -68,83 +68,41 @@ async function targetUser(c: Context<AppEnv>, override?: string) {
return target ?? null
}
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")
}
aiRoutes.get("/ai/report", requireAuth, async (c) => {
const period = learningPeriodSchema.safeParse(c.req.query("period") ?? "term")
if (!period.success)
return failure(c, 400, "invalid-period", "period must be week, month, term or all")
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))
// 老师看别人时给出同班前后一个人,好在页面上 ‹ › 翻
const viewingOther = user.id !== c.get("user")!.id
return success(c, await buildReport(user, period.data, { neighbors: viewingOther }))
})
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")
}
/**
* 页面上「AI 帮你分析」那张卡要显示的小结:老师在后台钉住的那份优先(不分时间段),
* 否则是这个时间段最近生成的一份。都没有就是 null,前端出「AI 帮我分析」按钮。
*/
aiRoutes.get("/ai/analysis/latest", requireAuth, async (c) => {
const period = learningPeriodSchema.safeParse(c.req.query("period") ?? "term")
if (!period.success)
return failure(c, 400, "invalid-period", "period must be week, month, term or all")
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))
})
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/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)),
and(
eq(schema.aiAnalysis.userId, user.id),
or(
eq(schema.aiAnalysis.isPinned, true),
sql`${schema.aiAnalysis.data}->>'period' = ${period.data}`,
),
),
)
.orderBy(desc(schema.aiAnalysis.isPinned), desc(schema.aiAnalysis.createTime))
.limit(1)
if (!row) return success(c, null)
return success(c, {
@@ -159,130 +117,98 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
} satisfies AiAnalysisRecord)
})
const ANALYSIS_RANGE: Record<string, string> = {
"hours:1": "这一节课",
"hours:2": "这两节课",
"days:1": "最近一天",
"weeks:1": "最近一周",
"months:1": "最近一个月",
"months:2": "最近两个月",
"months:6": "最近半年",
"years:1": "最近一年",
}
const WEEKDAY = "日一二三四五六"
/**
* 喂给模型的学情数据,全部翻成中文、带单位。原来是 detail / duration 原样 JSON 进
* prompt,模型读不懂裸编码:「错误类型 -1」(答案错误)被说成编译错误,
* `period 1` 原样出现在报告里,用户名 xuyue 被猜成了真名。
* 喂给模型的学情数据,全部翻成中文、带单位,和页面同一份(buildReport)。原来是
* detail / duration 原样 JSON 进 prompt,模型读不懂裸编码:「错误类型 -1」(答案错误)
* 被说成编译错误,`period 1` 原样出现在报告里,用户名 xuyue 被猜成了真名。
*
* 时间活跃度不给:八成提交在课上,几点做题是课表定的,模型只会据此编出
* 「你是早起鸟」「改到工作日分散练」这类建议。名字和班级也不给,报告里一律称「你」。
* 节奏按「每节课」给,不再按从现在往回推的一周一周切 —— 那样切出来的桶边界在半夜,
* 标签写「9月23日起」其实是 24 日起,模型据此说「23 日那周一道没做」,而那天做对了两道。
* 名字和班级不给,报告里一律称「你」。
*/
function analysisFacts(
range: string,
details: AiDetail,
periods: DurationData[],
solved: SolvedProblem[],
) {
const scope = details.rankScope === "class" ? "班上" : "全站"
// 两个超时码合成一种说法;判题机自己崩的不算学生的错
function analysisFacts(report: LearningReport) {
const result = (item: LearningItem) =>
item.status === "first"
? "一次就对"
: item.status === "tries"
? `交了 ${item.attempts} 次才对`
: item.status === "fail"
? `交了 ${item.attempts} 次还没做对`
: "没交"
const problem = (item: LearningItem) => ({ 题目: item.title, 结果: result(item) })
const date = (day: string) => {
const [, month, date] = day.split("-").map(Number)
return `${month}月${date}日 周${WEEKDAY[localWeekday(dayNumber(day))]}`
}
// 两个超时码合成一种说法
const errors = new Map<string, number>()
for (const item of details.errors) {
if (item.result === JudgeStatus.SYSTEM_ERROR) continue
for (const item of report.errors) {
const name = judgeStatusName(item.result)
errors.set(name, (errors.get(name) ?? 0) + item.count)
}
const monthDay = (iso: string) => {
const [, month, day] = calendarDay(iso).split("-").map(Number)
return `${month}月${day}日`
}
// 一周以内的范围,buildDuration 给的是前四周,和「这段时间」对不上,不给
const showTrend = ["months:1", "months:2", "months:6", "years:1"].includes(range)
return {
时间段: ANALYSIS_RANGE[range] ?? "这段时间",
做对的题数: details.solvedCount,
一次就对的题数: details.attempts.filter((value) => value === 1).length,
难度: details.difficulty,
做得最多的知识点: details.tags,
总等级: details.grade || "无",
时间段: LEARNING_PERIOD_LABELS[report.period],
做对的题数: report.solved,
一次就对的题数: report.firstTry,
提交次数: report.submissionCount,
...(report.classStats && {
班上中间那位做对的题数: report.classStats.medianSolved,
班上中间那位一次就对的百分比: report.classStats.medianFirstPercent,
}),
没通过的提交: [...errors].map(([结果, 次数]) => ({ 结果, 次数 })),
...(showTrend && {
分段: periods.map((item) => ({
时间: `${monthDay(item.start)}起的一${item.unit === "weeks" ? "周" : "个月"}`,
做对几道题: item.problemCount,
提交次数: item.submissionCount,
等级: item.grade || "无",
})),
}),
...(details.flowcharts.length && {
流程图: details.flowcharts.map((item) => ({
题目: item.problemTitle,
画了几次: item.submissionCount,
最好等级: item.bestGrade || "无",
})),
}),
做对的题: solved.map((item) => ({
题目: item.problem.title,
难度: item.difficulty,
交了几次才对: item.attempts,
...(item.periodRank && {
第几个做对: `${scope}第 ${item.periodRank} 个(共 ${item.periodAcCount} 人做对)`,
}),
等级: item.grade || "无",
...(item.problem.contestTitle && { 课堂测验: item.problem.contestTitle }),
做过的知识点: report.knowledge
.filter((item) => item.tried)
.map((item) => ({ 知识点: item.name, 做对几道: item.solved })),
// 新的在前;「全部」可能有上百天,只给最近 30 天,够模型说节奏
每天: report.days.slice(0, 30).map((day) => ({
日期: date(day.day),
...(day.lesson.length && { 课上的题: day.lesson.map(problem) }),
...(day.own.length && { 自己练的题: day.own.map(problem) }),
})),
}
}
aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
const parsed = await parseBody(c, aiAnalysisRequestSchema, "start, end and duration are required")
const parsed = await parseBody(c, aiAnalysisRequestSchema, "period is required")
if (!parsed.success) return parsed.response
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 report = await buildReport(user, parsed.data.period, { neighbors: false })
const system = [
"你是中职学校的编程课老师,在给自己班上的一位学生写学习小结,学生会在网站上自己读到。",
"下面是这位学生在判题网站上的做题数据(JSON)。页面上方已经有图表,不用把数字挨个复述一遍。",
"下面是这位学生在判题网站上的做题数据(JSON)。页面上已经按天列出了每道题,不用把数字挨个复述一遍。",
"用中文 Markdown 写,总共 300 字以内,分四段,每段一到三句,段首加粗:",
"**这段时间**(做了多少、节奏怎么样,挑一两个最能说明问题的数字,节奏照「分段」里的数说)、",
"**做得好的**(点出具体题目名)、",
"**这段时间**(做了多少、节奏怎么样,挑一两个最能说明问题的数字,节奏照「每天」里的说)、",
"**做得好的**(点出具体题目名,挑一次就对的题;交了好几次才对的不算做得好)、",
"**要注意的**(错得最多的是哪种,教一个具体的检查办法:答案错误就自己多想几组输入试,特别是最大最小的情况;",
"编译失败、运行时错误就先读报错信息,找到它指出的那一行和错误名;运行超时就看循环能不能停下来;交了好几次才对的题点出名字)、",
"**下一步**(一两件下节课就能做的事,比如把交了好几次才对的题重做一遍、在题目列表里按知识点找题练)。",
"编译失败、运行时错误就先读报错信息,找到它指出的那一行和错误名;运行超时就看循环能不能停下来;",
"交了好几次才对、还没做对、课上没交的题点出名字)、",
"**下一步**(一两件下节课就能做的事,比如把还没做对的题接着做完、把交了好几次才对的题重做一遍、在题目列表里按知识点找题练)。",
"最后另起一段,只写一句鼓励的话,要和这位学生的数据挂钩,不要空口号。",
"用「你」称呼学生,不要写名字。说大白话,不要英文和术语,不要「追」「对手」这类拿同学比的说法。",
"只用数据里有的数字和题目名,不要编;数据里没有的方面就不写(比如错误次数没按题目分,就别说错在哪类题上)。",
"不要表格、标题、代码块、字符画、分割线,不要 emoji。",
"数据说明:等级 S/A/B/C 看的是同一道题你是第几个做对的(前 10% 是 S、前 35% 是 A、前 75% 是 B),",
"说明做得快不快,不说明代码好不好。比赛是老师安排的课堂测验,不要建议学生多参加比赛。",
"数据说明:「课上的题」是班上那天一起做的题,「没交」是班上做了、这位学生一次没交;「自己练的题」是课外自己找来做的。",
"如果这段时间一道题都没做对,就简短说说看到的情况,鼓励下节课先把老师布置的题做出来。",
].join("")
const prompt = JSON.stringify(
analysisFacts(parsed.data.duration, details, duration, solved.results),
)
const prompt = JSON.stringify(analysisFacts(report))
return streamChat(system, prompt, {
onComplete: async (analysis) => {
// 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的
// GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到
// GET /ai/analysis/latest 都是按 user_id 找报告的,记在教师名下学生就永远看不到
await db.insert(schema.aiAnalysis).values({
provider: config.aiProvider,
model: config.aiModel,
data: { details, duration, solved: solved.results },
// period 是 /ai/analysis/latest 按时间段找回这份小结的依据
data: { period: report.period, report },
systemPrompt: system,
userPrompt: "学习详情与周期数据",
userPrompt: prompt,
analysis,
createTime: new Date().toISOString(),
userId: user.id,