feat(AI 提示): 提示落库并收集学生评价,落进 ai_hint
Some checks failed
Deploy / deploy (push) Has been cancelled

AI 时代 OJ 设计的 2a:先把现有提示记下来,为后面的分级和两段式诊断攒对照数据。
提示本身的行为不变,发给模型的 prompt 一字未改。
- 迁移 0017 建 ai_hint:模型、prompt 版本、内容、错误、耗时、学生评价;
  成功和失败都记(失败率是 AI 悄悄变差时最先动的数)。挂在 submission 上 CASCADE
- streamChat 第三个参数改成 { onComplete, onError }:onComplete 的返回值并进
  done 事件,提示 id 由此带回前端;班级学情分析那处调用同步改写,行为不变
- 落库失败只记日志,不影响学生拿到提示
- 新增 POST /ai/hint/:id/feedback:只能评自己的提示(别人的一律 404),可以改票
- 前端提示生成完出「有帮助 / 没帮助」,选中的高亮;后端没存上时不出按钮
- 实跑:本地假 LLM 验证成功 / provider 断开 / 缺 AI_KEY 三条路径的落库,
  评价接口六种请求,浏览器里按钮出现、改票、重复点不发请求

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 06:00:10 -06:00
parent a0ef204bd2
commit 47d8f46bdb
9 changed files with 4220 additions and 21 deletions

View File

@@ -0,0 +1,17 @@
-- AI 提示的留痕与学生评价AI 时代 OJ 设计 2a先记录、不改行为字段含义见 schema.ts 的 aiHint。
-- 纯建表。上线之前的提示从未落库,这张表从空开始。
CREATE TABLE "ai_hint" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "ai_hint_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"submission_id" text NOT NULL,
"model" text NOT NULL,
"prompt_version" integer NOT NULL,
"content" text NOT NULL,
"error" text,
"duration_ms" integer NOT NULL,
"helpful" boolean,
"feedback_time" timestamp with time zone,
"create_time" timestamp with time zone NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ai_hint" ADD CONSTRAINT "ai_hint_submission_id_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ai_hint_submission_id_idx" ON "ai_hint" USING btree ("submission_id");

File diff suppressed because it is too large Load Diff

View File

@@ -120,6 +120,13 @@
"when": 1789817209482,
"tag": "0016_add_submission_trace",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1789818766735,
"tag": "0017_add_ai_hint",
"breakpoints": true
}
]
}

View File

@@ -991,6 +991,60 @@ export const submissionTrace = pgTable(
],
)
/**
* 每一次「让 AI 分析我的代码」POST /ai/hint的留痕连同学生的评价。
*
* 在此之前提示一条都没落库,用了多少次、在哪些题上用、用完有没有做出来都无从知道;
* 之后要做的提示分级、错误归因都得拿这张表做对照。**生成失败的也记**content 为空、
* error 有值),失败率是 AI 功能悄悄变差时最先动的那个数。
*
* - 不存 prompt 原文:学生代码在 submission 里、题面在 problem 里,重复存一遍没有意义。
* 存的是 `prompt_version` —— 改 system / prompt 的拼法时在 routes/ai.ts 里加一,
* 事后才分得清哪批提示是按哪版生成的。
* - 人和题顺着 submission 查submission_id 上有索引)。和 submission_trace 一样
* CASCADE 挂在提交上:提交没了,这条提示也就没有上下文了。
* - 同一条提交可以有多条:刷新页面之后按钮会重新出现。
*/
export const aiHint = pgTable(
"ai_hint",
{
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({
name: "ai_hint_id_seq",
startWith: 1,
increment: 1,
minValue: 1,
maxValue: "9223372036854775807",
cache: 1,
}),
submissionId: text("submission_id").notNull(),
model: text().notNull(),
promptVersion: integer("prompt_version").notNull(),
// 生成失败时为空串,失败原因在 error
content: text().notNull(),
error: text(),
// 从收到请求到生成结束(或失败)的毫秒数
durationMs: integer("duration_ms").notNull(),
// 学生的评价null = 没评
helpful: boolean(),
feedbackTime: timestamp("feedback_time", {
withTimezone: true,
mode: "string",
}),
createTime: timestamp("create_time", {
withTimezone: true,
mode: "string",
}).notNull(),
},
(table) => [
index("ai_hint_submission_id_idx").on(table.submissionId),
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: "ai_hint_submission_id_fk_submission_id",
}).onDelete("cascade"),
],
)
export const tutorial = pgTable(
"tutorial",
{

View File

@@ -1,5 +1,6 @@
import {
aiAnalysisRequestSchema,
aiHintFeedbackRequestSchema,
aiHintRequestSchema,
classAnalysisRequestSchema,
classPkAnalysisRequestSchema,
@@ -913,23 +914,61 @@ aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
const system =
"你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown不要放在代码块中。"
const prompt = `详细数据: ${JSON.stringify({ ...details, solved: solved.results })}\n每周或每月数据: ${JSON.stringify(duration)}`
return streamChat(system, prompt, 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,
})
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,
})
},
})
})
/**
* 改了 /ai/hint 的 system 或 prompt 拼法就把这个数加一,落进 ai_hint.prompt_version
* 事后对比「改之前 / 改之后」的评价和做出率才分得开两批数据。
*/
const HINT_PROMPT_VERSION = 1
/**
* 记一条提示(成功或失败)。**失败只打日志、返回 null** —— 留痕是附带的,
* 不能因为它写不进去就让学生看到「AI 提示生成失败」。
*/
async function recordHint(
submissionId: string,
startedAt: number,
content: string,
error: string | null,
) {
try {
const [row] = await db
.insert(schema.aiHint)
.values({
submissionId,
model: config.aiModel,
promptVersion: HINT_PROMPT_VERSION,
content,
error,
durationMs: Math.round(performance.now() - startedAt),
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),
@@ -989,7 +1028,50 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
const system =
"你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown不超过6句话。"
const prompt = `题目:${row.problem.title}\n描述${row.problem.description.slice(0, 2000)}\n语言${row.submission.language}\n结果${judgeStatusName(row.submission.result)}\n错误${String(objectValue(row.submission.statisticInfo).err_info ?? "无")}\n代码${row.submission.code.slice(0, 2000)}`
return streamChat(system, prompt)
const submissionId = row.submission.id
const startedAt = performance.now()
return streamChat(system, prompt, {
onComplete: async (content) => {
const id = await recordHint(submissionId, startedAt, content, null)
// 落库失败就不带 id前端据此不出评价按钮提示本身照常显示
return id === null ? undefined : { hintId: id }
},
onError: async (message) => {
await recordHint(submissionId, startedAt, "", 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) => {

View File

@@ -51,16 +51,34 @@ export async function completeChat(system: string, user: 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,
onComplete?: (value: string) => Promise<void>,
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`,
)
@@ -127,12 +145,15 @@ export function streamChat(
if (done) break
}
const full = chunks.join("").trim()
if (onComplete) await onComplete(full)
send(`data: ${JSON.stringify({ type: "done" })}\n\n`)
const extra = hooks.onComplete
? await hooks.onComplete(full)
: undefined
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
} catch (error) {
send(
`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`,
)
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()

View File

@@ -1,5 +1,6 @@
import {
type AiAnalysisRecord,
type AiHintFeedbackRequest,
type Contest as OjContest,
type ContestAccess,
type ContestList,
@@ -380,6 +381,13 @@ export function getAIPinnedReport() {
return api.get<AiAnalysisRecord | null>("ai/pinned")
}
/** 学生评价一条 AI 提示。id 来自 /ai/hint 流的 done 事件,可以改票 */
export function submitHintFeedback(hintId: number, helpful: boolean) {
return api.post<null>(`ai/hint/${hintId}/feedback`, {
helpful,
} satisfies AiHintFeedbackRequest)
}
// ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) {

View File

@@ -15,6 +15,7 @@ import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useProblemStore } from "oj/store/problem"
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
import { submitHintFeedback } from "oj/api"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { useDark } from "@vueuse/core"
@@ -31,6 +32,10 @@ const theme = useThemeVars()
const hintContent = ref("")
const hintLoading = ref(false)
const hintError = ref("")
// 这条提示在 ai_hint 里的 id生成完由 done 事件带回来;后端落库失败时没有,就不出评价按钮
const hintId = ref<number | null>(null)
const hintHelpful = ref<boolean | null>(null)
const hintFeedbackSending = ref(false)
// 错误信息格式化
const msg = computed(() => {
@@ -95,6 +100,8 @@ watch(
hintContent.value = ""
hintError.value = ""
hintLoading.value = false
hintId.value = null
hintHelpful.value = null
},
)
@@ -102,6 +109,8 @@ async function fetchHint(submissionId: string) {
hintLoading.value = true
hintContent.value = ""
hintError.value = ""
hintId.value = null
hintHelpful.value = null
try {
const response = await fetch("/api/ai/hint", {
@@ -117,9 +126,12 @@ async function fetchHint(submissionId: string) {
type: string
content?: string
message?: string
hintId?: number
}) => {
if (data.type === "delta" && data.content) {
hintContent.value += data.content
} else if (data.type === "done") {
hintId.value = data.hintId ?? null
} else if (data.type === "error") {
hintError.value = data.message || "AI 提示生成失败"
}
@@ -132,6 +144,22 @@ async function fetchHint(submissionId: string) {
}
}
// 可以改票:点另一个就覆盖。失败了不打扰学生,按钮恢复原样就行 ——
// 评价是给我们看的,不值得为它弹一条报错
async function sendHintFeedback(helpful: boolean) {
if (hintId.value === null || hintFeedbackSending.value) return
if (hintHelpful.value === helpful) return
hintFeedbackSending.value = true
try {
await submitHintFeedback(hintId.value, helpful)
hintHelpful.value = helpful
} catch {
// 静默
} finally {
hintFeedbackSending.value = false
}
}
// 测试用例表格数据(只在部分通过时显示)
const infoTable = computed(() => {
const submission = props.submission
@@ -254,6 +282,30 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
/>
<n-flex
v-if="hintId !== null && !hintLoading"
align="center"
size="small"
style="margin-top: 8px"
>
<n-text depth="3">这条提示对你有帮助吗?</n-text>
<n-button
size="tiny"
:type="hintHelpful === true ? 'primary' : 'default'"
:disabled="hintFeedbackSending"
@click="sendHintFeedback(true)"
>
有帮助
</n-button>
<n-button
size="tiny"
:type="hintHelpful === false ? 'warning' : 'default'"
:disabled="hintFeedbackSending"
@click="sendHintFeedback(false)"
>
没帮助
</n-button>
</n-flex>
</n-card>
</template>
</div>

View File

@@ -123,6 +123,12 @@ export const HINT_MIN_FAILURES = 3
export const aiHintRequestSchema = z.object({ submissionId: z.string().min(1) })
/**
* 学生对一条 AI 提示的评价POST /ai/hint/:id/feedback。提示的 id 由 /ai/hint 流的
* `done` 事件带回来。可以改票,以最后一次为准。
*/
export const aiHintFeedbackRequestSchema = z.object({ helpful: z.boolean() })
export const classAnalysisRequestSchema = z.object({
comparison: z.record(z.string(), z.unknown()),
})
@@ -181,6 +187,7 @@ export type LoginSummary = z.infer<typeof loginSummarySchema>
export type AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema>
export type AiHintRequest = z.infer<typeof aiHintRequestSchema>
export type AiHintFeedbackRequest = z.infer<typeof aiHintFeedbackRequestSchema>
export type ClassAnalysisRequest = z.infer<typeof classAnalysisRequestSchema>
export type ClassPkAnalysisRequest = z.infer<
typeof classPkAnalysisRequestSchema