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:
17
apps/api/src/db/0017_add_ai_hint.sql
Normal file
17
apps/api/src/db/0017_add_ai_hint.sql
Normal 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");
|
||||||
3951
apps/api/src/db/meta/0017_snapshot.json
Normal file
3951
apps/api/src/db/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
|||||||
"when": 1789817209482,
|
"when": 1789817209482,
|
||||||
"tag": "0016_add_submission_trace",
|
"tag": "0016_add_submission_trace",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789818766735,
|
||||||
|
"tag": "0017_add_ai_hint",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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(
|
export const tutorial = pgTable(
|
||||||
"tutorial",
|
"tutorial",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
aiAnalysisRequestSchema,
|
aiAnalysisRequestSchema,
|
||||||
|
aiHintFeedbackRequestSchema,
|
||||||
aiHintRequestSchema,
|
aiHintRequestSchema,
|
||||||
classAnalysisRequestSchema,
|
classAnalysisRequestSchema,
|
||||||
classPkAnalysisRequestSchema,
|
classPkAnalysisRequestSchema,
|
||||||
@@ -913,23 +914,61 @@ aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
|
|||||||
const system =
|
const system =
|
||||||
"你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown,不要放在代码块中。"
|
"你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown,不要放在代码块中。"
|
||||||
const prompt = `详细数据: ${JSON.stringify({ ...details, solved: solved.results })}\n每周或每月数据: ${JSON.stringify(duration)}`
|
const prompt = `详细数据: ${JSON.stringify({ ...details, solved: solved.results })}\n每周或每月数据: ${JSON.stringify(duration)}`
|
||||||
return streamChat(system, prompt, async (analysis) => {
|
return streamChat(system, prompt, {
|
||||||
// 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的
|
onComplete: async (analysis) => {
|
||||||
// GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到
|
// 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的
|
||||||
await db.insert(schema.aiAnalysis).values({
|
// GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到
|
||||||
provider: config.aiProvider,
|
await db.insert(schema.aiAnalysis).values({
|
||||||
model: config.aiModel,
|
provider: config.aiProvider,
|
||||||
data: { details, duration, solved: solved.results },
|
model: config.aiModel,
|
||||||
systemPrompt: system,
|
data: { details, duration, solved: solved.results },
|
||||||
userPrompt: "学习详情与周期数据",
|
systemPrompt: system,
|
||||||
analysis,
|
userPrompt: "学习详情与周期数据",
|
||||||
createTime: new Date().toISOString(),
|
analysis,
|
||||||
userId: user.id,
|
createTime: new Date().toISOString(),
|
||||||
isPinned: false,
|
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) => {
|
aiRoutes.post("/ai/hint", requireAuth, async (c) => {
|
||||||
const parsed = aiHintRequestSchema.safeParse(
|
const parsed = aiHintRequestSchema.safeParse(
|
||||||
await c.req.json().catch(() => null),
|
await c.req.json().catch(() => null),
|
||||||
@@ -989,7 +1028,50 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
|
|||||||
const system =
|
const system =
|
||||||
"你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。"
|
"你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 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)}`
|
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) => {
|
aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
|
||||||
|
|||||||
@@ -51,16 +51,34 @@ export async function completeChat(system: string, user: string) {
|
|||||||
return payload.choices?.[0]?.message?.content?.trim() ?? ""
|
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(
|
export function streamChat(
|
||||||
system: string,
|
system: string,
|
||||||
user: string,
|
user: string,
|
||||||
onComplete?: (value: string) => Promise<void>,
|
hooks: StreamChatHooks = {},
|
||||||
) {
|
) {
|
||||||
const encoder = new TextEncoder()
|
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>({
|
const body = new ReadableStream<Uint8Array>({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
const send = (value: string) => controller.enqueue(encoder.encode(value))
|
const send = (value: string) => controller.enqueue(encoder.encode(value))
|
||||||
if (!config.aiKey) {
|
if (!config.aiKey) {
|
||||||
|
await reportError("缺少 AI_KEY")
|
||||||
send(
|
send(
|
||||||
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
|
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
|
||||||
)
|
)
|
||||||
@@ -127,12 +145,15 @@ export function streamChat(
|
|||||||
if (done) break
|
if (done) break
|
||||||
}
|
}
|
||||||
const full = chunks.join("").trim()
|
const full = chunks.join("").trim()
|
||||||
if (onComplete) await onComplete(full)
|
const extra = hooks.onComplete
|
||||||
send(`data: ${JSON.stringify({ type: "done" })}\n\n`)
|
? await hooks.onComplete(full)
|
||||||
|
: undefined
|
||||||
|
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
send(
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`,
|
// 先留痕再回前端:客户端已经断开的话下面这个 send 自己也会抛
|
||||||
)
|
await reportError(message)
|
||||||
|
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
|
||||||
} finally {
|
} finally {
|
||||||
send("event: end\n\n")
|
send("event: end\n\n")
|
||||||
controller.close()
|
controller.close()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type AiAnalysisRecord,
|
type AiAnalysisRecord,
|
||||||
|
type AiHintFeedbackRequest,
|
||||||
type Contest as OjContest,
|
type Contest as OjContest,
|
||||||
type ContestAccess,
|
type ContestAccess,
|
||||||
type ContestList,
|
type ContestList,
|
||||||
@@ -380,6 +381,13 @@ export function getAIPinnedReport() {
|
|||||||
return api.get<AiAnalysisRecord | null>("ai/pinned")
|
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) {
|
export function getSimilarProblems(problemId: string) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { Submission } from "utils/types"
|
|||||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
||||||
|
import { submitHintFeedback } from "oj/api"
|
||||||
import { MdPreview } from "md-editor-v3"
|
import { MdPreview } from "md-editor-v3"
|
||||||
import "md-editor-v3/lib/preview.css"
|
import "md-editor-v3/lib/preview.css"
|
||||||
import { useDark } from "@vueuse/core"
|
import { useDark } from "@vueuse/core"
|
||||||
@@ -31,6 +32,10 @@ const theme = useThemeVars()
|
|||||||
const hintContent = ref("")
|
const hintContent = ref("")
|
||||||
const hintLoading = ref(false)
|
const hintLoading = ref(false)
|
||||||
const hintError = ref("")
|
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(() => {
|
const msg = computed(() => {
|
||||||
@@ -95,6 +100,8 @@ watch(
|
|||||||
hintContent.value = ""
|
hintContent.value = ""
|
||||||
hintError.value = ""
|
hintError.value = ""
|
||||||
hintLoading.value = false
|
hintLoading.value = false
|
||||||
|
hintId.value = null
|
||||||
|
hintHelpful.value = null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,6 +109,8 @@ async function fetchHint(submissionId: string) {
|
|||||||
hintLoading.value = true
|
hintLoading.value = true
|
||||||
hintContent.value = ""
|
hintContent.value = ""
|
||||||
hintError.value = ""
|
hintError.value = ""
|
||||||
|
hintId.value = null
|
||||||
|
hintHelpful.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/ai/hint", {
|
const response = await fetch("/api/ai/hint", {
|
||||||
@@ -117,9 +126,12 @@ async function fetchHint(submissionId: string) {
|
|||||||
type: string
|
type: string
|
||||||
content?: string
|
content?: string
|
||||||
message?: string
|
message?: string
|
||||||
|
hintId?: number
|
||||||
}) => {
|
}) => {
|
||||||
if (data.type === "delta" && data.content) {
|
if (data.type === "delta" && data.content) {
|
||||||
hintContent.value += data.content
|
hintContent.value += data.content
|
||||||
|
} else if (data.type === "done") {
|
||||||
|
hintId.value = data.hintId ?? null
|
||||||
} else if (data.type === "error") {
|
} else if (data.type === "error") {
|
||||||
hintError.value = data.message || "AI 提示生成失败"
|
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 infoTable = computed(() => {
|
||||||
const submission = props.submission
|
const submission = props.submission
|
||||||
@@ -254,6 +282,30 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
|||||||
preview-theme="vuepress"
|
preview-theme="vuepress"
|
||||||
:theme="isDark ? 'dark' : 'light'"
|
: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>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ export const HINT_MIN_FAILURES = 3
|
|||||||
|
|
||||||
export const aiHintRequestSchema = z.object({ submissionId: z.string().min(1) })
|
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({
|
export const classAnalysisRequestSchema = z.object({
|
||||||
comparison: z.record(z.string(), z.unknown()),
|
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 AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema>
|
||||||
export type AiHintRequest = z.infer<typeof aiHintRequestSchema>
|
export type AiHintRequest = z.infer<typeof aiHintRequestSchema>
|
||||||
|
export type AiHintFeedbackRequest = z.infer<typeof aiHintFeedbackRequestSchema>
|
||||||
export type ClassAnalysisRequest = z.infer<typeof classAnalysisRequestSchema>
|
export type ClassAnalysisRequest = z.infer<typeof classAnalysisRequestSchema>
|
||||||
export type ClassPkAnalysisRequest = z.infer<
|
export type ClassPkAnalysisRequest = z.infer<
|
||||||
typeof classPkAnalysisRequestSchema
|
typeof classPkAnalysisRequestSchema
|
||||||
|
|||||||
Reference in New Issue
Block a user