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

@@ -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>