feat: 完成AI讲解与申论占位

This commit is contained in:
liyy 2026-09-01 17:36:28 +08:00
parent 6d8f23d037
commit c736b128f9
12 changed files with 708 additions and 15 deletions

View File

@ -158,6 +158,7 @@ export const aiApi = {
explain: (body: BodyOf<'/api/ai/explain-question', 'post'>) => explain: (body: BodyOf<'/api/ai/explain-question', 'post'>) =>
unwrap(apiClient.POST('/api/ai/explain-question', { body })) unwrap(apiClient.POST('/api/ai/explain-question', { body }))
} }
export type AiExplain = SuccessBody<paths['/api/ai/explain-question']['post']>
// ---- 个人档案 / 设置 ---- // ---- 个人档案 / 设置 ----
export const profileApi = { export const profileApi = {

View File

@ -5,6 +5,7 @@ import PracticeView from '../views/practice/PracticeView.vue'
import AnswerView from '../views/practice/AnswerView.vue' import AnswerView from '../views/practice/AnswerView.vue'
import CustomView from '../views/practice/CustomView.vue' import CustomView from '../views/practice/CustomView.vue'
import WrongDetailView from '../views/practice/WrongDetailView.vue' import WrongDetailView from '../views/practice/WrongDetailView.vue'
import AiExplainView from '../views/practice/AiExplainView.vue'
import MockView from '../views/mock/MockView.vue' import MockView from '../views/mock/MockView.vue'
import MockFormView from '../views/mock/MockFormView.vue' import MockFormView from '../views/mock/MockFormView.vue'
import PlanView from '../views/plan/PlanView.vue' import PlanView from '../views/plan/PlanView.vue'
@ -22,6 +23,7 @@ export const routes: RouteRecordRaw[] = [
{ path: '/practice/custom', name: 'practice-custom', component: CustomView, meta: { title: '自定义组卷' } }, { path: '/practice/custom', name: 'practice-custom', component: CustomView, meta: { title: '自定义组卷' } },
{ path: '/practice/wrong', name: 'practice-wrong', component: PracticeView, meta: { title: '错题本' } }, { path: '/practice/wrong', name: 'practice-wrong', component: PracticeView, meta: { title: '错题本' } },
{ path: '/practice/wrong/:id', name: 'practice-wrong-detail', component: WrongDetailView, meta: { title: '错题详情' } }, { path: '/practice/wrong/:id', name: 'practice-wrong-detail', component: WrongDetailView, meta: { title: '错题详情' } },
{ path: '/practice/explain', name: 'practice-explain', component: AiExplainView, meta: { title: 'AI 讲解' } },
{ path: '/practice/essay', name: 'practice-essay', component: PracticeView, meta: { title: '申论' } }, { path: '/practice/essay', name: 'practice-essay', component: PracticeView, meta: { title: '申论' } },
{ path: '/mock', name: 'mock', component: MockView, meta: { title: '模考分析' } }, { path: '/mock', name: 'mock', component: MockView, meta: { title: '模考分析' } },
{ path: '/mock/new', name: 'mock-new', component: MockFormView, meta: { title: '录入模考成绩' } }, { path: '/mock/new', name: 'mock-new', component: MockFormView, meta: { title: '录入模考成绩' } },

View File

@ -0,0 +1,257 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { aiApi, type AiExplain } from '../../api'
import { useResponsive } from '../../composables/useResponsive'
import AppCard from '../../components/base/AppCard.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppBadge from '../../components/base/AppBadge.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
const route = useRoute()
const router = useRouter()
const { isMobile } = useResponsive()
const questionId = computed(() => String(route.query.questionId ?? ''))
const userAnswer = computed(() => String(route.query.userAnswer ?? '').toUpperCase())
const requestType = computed<'standard' | 'deep'>(() =>
route.query.requestType === 'deep' ? 'deep' : 'standard'
)
const loading = ref(true)
const error = ref('')
const explain = ref<AiExplain | null>(null)
onMounted(() => void load())
async function load() {
if (!questionId.value) {
error.value = '缺少题目参数,无法生成讲解'
loading.value = false
return
}
loading.value = true
error.value = ''
try {
explain.value = await aiApi.explain({
questionId: questionId.value,
userAnswer: ['A', 'B', 'C', 'D'].includes(userAnswer.value)
? (userAnswer.value as 'A' | 'B' | 'C' | 'D')
: undefined,
requestType: requestType.value
})
} catch (err) {
error.value = err instanceof Error ? err.message : 'AI 讲解加载失败'
} finally {
loading.value = false
}
}
function back() {
router.back()
}
const title = computed(() => (requestType.value === 'deep' ? 'AI 深度讲解' : 'AI 讲解'))
const isAi = computed(() => explain.value?.source === 'ai')
const mistakeText = computed(() => {
const list = explain.value?.commonMistakes ?? []
return list.length > 0 ? list : ['无明显高频错误']
})
</script>
<template>
<div class="ai-explain">
<AppLoading v-if="loading" fullscreen text="正在生成讲解…" />
<AppError v-else-if="error" fullscreen title="讲解加载失败" :message="error" retry @retry="load" />
<template v-else-if="explain">
<!-- 顶栏 -->
<header class="ai-explain__top">
<button type="button" class="ai-explain__back" @click="back">
<AppIcon name="chevron-left" :size="20" />
</button>
<h1 class="ai-explain__title">{{ title }}</h1>
<AppBadge :variant="isAi ? 'primary' : 'soft'">
{{ isAi ? 'AI 生成' : '题库解析回退' }}
</AppBadge>
</header>
<!-- 结论 -->
<AppCard title="讲解结论" icon="sparkles">
<p class="ai-explain__summary">{{ explain.summary }}</p>
<p v-if="!isAi" class="ai-explain__fallback-tip">
AI 未配置或暂时不可用时自动回退到题库标准解析不会阻塞你的刷题流程
</p>
</AppCard>
<!-- 分步解析 -->
<AppCard title="分步解析" icon="book-open" v-if="explain.steps.length">
<ol class="ai-explain__steps">
<li v-for="(step, i) in explain.steps" :key="i" class="ai-explain__step">
<span class="ai-explain__step-index">{{ i + 1 }}</span>
<span class="ai-explain__step-text">{{ step }}</span>
</li>
</ol>
</AppCard>
<!-- 考点 -->
<AppCard title="考点" icon="target" v-if="explain.knowledgePoints.length">
<div class="ai-explain__tags">
<AppBadge v-for="(k, i) in explain.knowledgePoints" :key="i" variant="soft">{{ k }}</AppBadge>
</div>
</AppCard>
<!-- 常见错误 -->
<AppCard title="常见错误" icon="alert">
<ul class="ai-explain__mistakes">
<li v-for="(m, i) in mistakeText" :key="i">{{ m }}</li>
</ul>
</AppCard>
<!-- 答案 -->
<AppCard title="正确答案" icon="check">
<div class="ai-explain__answer">
<strong class="ai-explain__answer-key">{{ explain.answer }}</strong>
<span class="ai-explain__answer-label">本题答案为 {{ explain.answer }}</span>
</div>
</AppCard>
<AppButton v-if="!isMobile" variant="ghost" @click="back">
<AppIcon name="chevron-left" :size="16" />返回
</AppButton>
</template>
</div>
</template>
<style scoped>
.ai-explain {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.ai-explain__top {
display: flex;
align-items: center;
gap: var(--space-3);
}
.ai-explain__back {
display: grid;
place-items: center;
width: 36px;
height: 36px;
flex: none;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
cursor: pointer;
}
.ai-explain__title {
flex: 1;
margin: 0;
font-size: var(--fs-h2);
font-weight: var(--fw-h2);
color: var(--text-primary);
}
.ai-explain__summary {
margin: 0;
font-size: var(--fs-body);
line-height: var(--lh-body);
color: var(--text-primary);
white-space: pre-wrap;
}
.ai-explain__fallback-tip {
margin: var(--space-3) 0 0;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--bg-soft);
font-size: var(--fs-label);
color: var(--text-secondary);
}
.ai-explain__steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.ai-explain__step {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}
.ai-explain__step-index {
display: grid;
place-items: center;
width: 24px;
height: 24px;
flex: none;
border-radius: 50%;
background: var(--primary-soft, var(--bg-soft));
color: var(--primary);
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
}
.ai-explain__step-text {
font-size: var(--fs-subtitle);
line-height: var(--lh-subtitle);
color: var(--text-primary);
white-space: pre-wrap;
}
.ai-explain__tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.ai-explain__mistakes {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.ai-explain__mistakes li {
position: relative;
padding-left: var(--space-4);
font-size: var(--fs-subtitle);
line-height: var(--lh-subtitle);
color: var(--text-primary);
}
.ai-explain__mistakes li::before {
content: '';
position: absolute;
left: 0;
top: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--warning);
}
.ai-explain__answer {
display: flex;
align-items: center;
gap: var(--space-3);
}
.ai-explain__answer-key {
display: grid;
place-items: center;
width: 44px;
height: 44px;
flex: none;
border-radius: var(--radius-md);
background: var(--success);
color: var(--on-accent);
font-family: var(--font-num);
font-size: 20px;
font-weight: 700;
}
.ai-explain__answer-label {
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
</style>

View File

@ -138,6 +138,13 @@ async function finishSession() {
} }
} }
function goAiExplain(questionId: string, userAnswer: string) {
router.push({
name: 'practice-explain',
query: { questionId, userAnswer, requestType: 'standard' }
})
}
function backToPractice() { function backToPractice() {
router.push('/practice') router.push('/practice')
} }
@ -183,6 +190,14 @@ const finishAccuracy = computed(() => finishResult.value?.accuracy ?? 0)
<span>正确答案 <b class="is-right">{{ q.correctAnswer }}</b></span> <span>正确答案 <b class="is-right">{{ q.correctAnswer }}</b></span>
</div> </div>
<p class="wrong-list__analysis">{{ q.analysis }}</p> <p class="wrong-list__analysis">{{ q.analysis }}</p>
<AppButton
variant="ghost"
size="md"
class="wrong-list__ai"
@click="goAiExplain(q.questionId, q.userAnswer)"
>
<AppIcon name="sparkles" :size="14" />AI 讲解
</AppButton>
</div> </div>
</div> </div>
</div> </div>
@ -264,7 +279,15 @@ const finishAccuracy = computed(() => finishResult.value?.accuracy ?? 0)
<AppCard v-if="result.analysis" title="解析" icon="book-open"> <AppCard v-if="result.analysis" title="解析" icon="book-open">
<p class="single-result__analysis">{{ result.analysis }}</p> <p class="single-result__analysis">{{ result.analysis }}</p>
<AppButton block variant="secondary" icon="sparkles" class="single-result__ai">没看懂 AI 讲一讲</AppButton> <AppButton
block
variant="secondary"
icon="sparkles"
class="single-result__ai"
@click="goAiExplain(question.id, selected)"
>
没看懂 AI 讲一讲
</AppButton>
</AppCard> </AppCard>
<div v-if="!result.correct" class="single-result__reason"> <div v-if="!result.correct" class="single-result__reason">
@ -625,6 +648,9 @@ const finishAccuracy = computed(() => finishResult.value?.accuracy ?? 0)
line-height: var(--lh-body); line-height: var(--lh-body);
color: var(--text-secondary); color: var(--text-secondary);
} }
.wrong-list__ai {
margin-top: var(--space-2);
}
.result-note { .result-note {
margin: var(--space-4) 0 0; margin: var(--space-4) 0 0;
font-size: var(--fs-label); font-size: var(--fs-label);

View File

@ -0,0 +1,132 @@
<script setup lang="ts">
import AppCard from '../../components/base/AppCard.vue'
import AppBadge from '../../components/base/AppBadge.vue'
import AppIcon from '../../components/base/AppIcon.vue'
const QUESTION_TYPES = [
{ name: '归纳概括', desc: '概括给定资料的主要内容或问题' },
{ name: '综合分析', desc: '对观点、现象进行多角度分析' },
{ name: '提出对策', desc: '针对问题提出可行解决措施' },
{ name: '贯彻执行', desc: '撰写公文、讲话稿等应用文' },
{ name: '文章写作', desc: '围绕主题撰写议论文' }
] as const
</script>
<template>
<div class="essay">
<!-- 功能说明 -->
<AppCard title="申论功能说明" icon="book-open">
<p class="essay__intro">
申论是公务员考试笔试科目之一重点考查阅读理解综合分析解决问题与文字表达能力
备考通当前聚焦行测闭环申论功能正在规划中
</p>
<div class="essay__types">
<div v-for="t in QUESTION_TYPES" :key="t.name" class="essay__type">
<AppBadge variant="primary">{{ t.name }}</AppBadge>
<span class="essay__type-desc">{{ t.desc }}</span>
</div>
</div>
</AppCard>
<!-- 后续入口 -->
<AppCard title="后续能力" icon="sparkles">
<ul class="essay__future">
<li>
<AppIcon name="pen" :size="16" />
<span>在线写作按题型练习支持计时作答</span>
</li>
<li>
<AppIcon name="sparkles" :size="16" />
<span>AI 批改结合 AI 给出分项评分与改进建议</span>
</li>
<li>
<AppIcon name="chart" :size="16" />
<span>范文库历年真题范文与素材积累</span>
</li>
</ul>
</AppCard>
<!-- 说明提示 -->
<AppCard :padded="true">
<div class="essay__notice">
<AppIcon name="alert" :size="18" />
<p>当前为占位页面不保存任何写作内容建议先完成行测刷题 错题复习 模考分析闭环</p>
</div>
</AppCard>
</div>
</template>
<style scoped>
.essay {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.essay__intro {
margin: 0 0 var(--space-4);
font-size: var(--fs-body);
line-height: var(--lh-body);
color: var(--text-secondary);
}
.essay__types {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
}
.essay__type {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-soft);
}
.essay__type-desc {
flex: 1;
min-width: 0;
font-size: var(--fs-label);
color: var(--text-secondary);
}
.essay__future {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.essay__future li {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--fs-subtitle);
color: var(--text-primary);
}
.essay__future li svg {
color: var(--primary);
flex: none;
}
.essay__notice {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.essay__notice svg {
color: var(--warning);
flex: none;
margin-top: 2px;
}
.essay__notice p {
margin: 0;
font-size: var(--fs-subtitle);
line-height: var(--lh-subtitle);
color: var(--text-secondary);
}
@media (max-width: 767px) {
.essay__types {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -13,7 +13,7 @@ import AppIcon from '../../components/base/AppIcon.vue'
import AppLoading from '../../components/feedback/AppLoading.vue' import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue' import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue' import AppEmpty from '../../components/feedback/AppEmpty.vue'
import ComingSoon from '../../components/feedback/ComingSoon.vue' import EssayView from './EssayView.vue'
import { formatPercent, formatDateShort } from '../../utils/format' import { formatPercent, formatDateShort } from '../../utils/format'
import type { IconName } from '../../components/base/icons' import type { IconName } from '../../components/base/icons'
@ -387,7 +387,7 @@ const subtitle = computed(() => {
<!-- ============ 申论 tab ============ --> <!-- ============ 申论 tab ============ -->
<template v-else> <template v-else>
<ComingSoon /> <EssayView />
</template> </template>
</div> </div>
</template> </template>

View File

@ -96,6 +96,20 @@ function toggleReason(reason: string) {
function backToList() { function backToList() {
router.push('/practice/wrong') router.push('/practice/wrong')
} }
/** AI 讲解:优先携带最近一次作答(历史最新一条),无历史则省略用户答案 */
function goAiExplain() {
const questionId = detail.value?.questionId
if (!questionId) return
router.push({
name: 'practice-explain',
query: {
questionId,
userAnswer: detail.value?.history[0]?.userAnswer ?? '',
requestType: 'standard'
}
})
}
</script> </script>
<template> <template>
@ -149,7 +163,7 @@ function backToList() {
<AppButton block size="lg" icon="refresh" @click="router.push(`/practice/session`)" disabled> <AppButton block size="lg" icon="refresh" @click="router.push(`/practice/session`)" disabled>
重做此题 重做此题
</AppButton> </AppButton>
<AppButton block size="lg" variant="secondary" icon="sparkles" @click="router.push(`/questions`)" disabled> <AppButton block size="lg" variant="secondary" icon="sparkles" @click="goAiExplain">
AI 讲解 AI 讲解
</AppButton> </AppButton>
</div> </div>

76
docs/checks/task-11.md Normal file
View File

@ -0,0 +1,76 @@
# 任务 11 检查记录AI 讲解与申论占位
日期2026-09-01
## 交付内容
### 后端
- `server/src/ai.ts`(新增):
- `readAiConfig()`:从环境变量读取 `AI_BASE_URL` / `AI_API_KEY` / `AI_MODEL`(默认 gpt-4o-mini/ `AI_TIMEOUT_MS`(默认 20000未配置返回 null。
- `buildMessages()`:组装模块/题干/选项/标准答案/题库解析/标签/用户作答,要求模型输出严格 JSONsummary / steps / knowledgePoints / commonMistakes / answer
- `parseExplainJson()`:兼容 markdown 代码块等杂文的稳健 JSON 提取与字段校验。
- `explainWithAi()`OpenAI 兼容 Chat Completions 调用(`${baseUrl}/chat/completions` + BearerAbortController 超时;未配置/超时/网络/HTTP/解析失败分别抛可识别错误;任何错误信息不含 API Key。
- `server/src/handlers/ai.ts`(重写):配置有效 → 调用 AI 并返回 `source:'ai'`;未配置 → `source:'fallback'`「AI 尚未配置」);超时 → `source:'fallback'`「AI 请求超时」);其余失败 → `source:'fallback'`「AI 讲解暂时不可用」)。回退内容为题库解析 + 标签 + 答案,前端可识别。
- `server/src/schemas/api.ts``AiExplainBodySchema.userAnswer` 改为可选(从错题详情进入且无作答历史时省略)。
- `server/.env.example`(新增):记录 PORT / DATA_DIR / AI 配置项说明。
### 前端
- `client/src/views/practice/AiExplainView.vue`(新增):
- 路由 `/practice/explain?questionId=&userAnswer=&requestType=`,加载/错误/重试三态,缺少题目参数给出明确提示。
- 展示来源徽章AI 生成 / 题库解析回退、讲解结论、分步解析编号步骤、考点chip、常见错误、正确答案绿色高亮回退时显示说明提示条。
- `requestType=deep` 时标题为「AI 深度讲解」。
- `client/src/views/practice/AnswerView.vue`
- 单题结果「没看懂?让 AI 讲一讲」接入跳转(携带题目 ID + 用户答案)。
- 整卷结果页错题回顾每项新增「AI 讲解」入口。
- `client/src/views/practice/WrongDetailView.vue`AI 讲解按钮由占位禁用改为可用,携带题目 ID 与最近一次作答(无历史则省略)。
- `client/src/views/practice/EssayView.vue`(新增):申论占位页——功能说明(五大题型)、后续能力(在线写作/AI 批改/范文库)、不保存写作内容的提示。
- `client/src/views/practice/PracticeView.vue`:申论 tab 由通用 ComingSoon 替换为 `EssayView`
- `client/src/router/index.ts`:新增 `/practice/explain``client/src/api/index.ts` 导出 `AiExplain` 类型。
## 接口实测(本地 OpenAI 兼容 mock + curl
| 场景 | 结果 |
|---|---|
| 未配置 AI | `source:fallback`「AI 尚未配置已回退到题库标准解析用户答案Asteps=题库解析、answer=C |
| 配置有效 Tokenmock | `source:ai`summary/steps/knowledgePoints/commonMistakes/answer 结构化解析正确 |
| deep 模式且无 userAnswer | 正常返回 `source:ai`prompt 中用户作答为「未作答」) |
| 超时AI_TIMEOUT_MS=1200 + 8s 慢响应) | 1.2s 内返回 `source:fallback`「AI 请求超时」 |
| AI 返回不可解析内容 | `source:fallback`「AI 讲解暂时不可用」 |
| 非法输入 | 缺 questionId → VALIDATION_ERROR不存在题目 → NOT_FOUND非法 requestType → VALIDATION_ERROR |
| Token 不泄漏 | 后端日志仅含请求方法/URL/状态码,无 API KeyOpenAPI 无 AI Token仅有的 `apiKey` 字段是要闻 API 导入占位请求结构任务06 既有);前端源码无密钥 |
## 浏览器检查Chrome headless + CDP
| 场景 | 结果 |
|---|---|
| AI 讲解页(配置) | AI 生成徽章、结论、分步解析、考点、常见错误、答案 C 全部渲染 |
| deep 模式 | 标题「AI 深度讲解」 |
| 错题详情 AI 按钮 | 可用;点击跳转 `/practice/explain?questionId=demo-q-004&userAnswer=A` |
| AI 未配置回退 UI | 「题库解析回退」徽章 + 回退说明 + 题库解析步骤 + 正确答案 |
| 申论占位页 | 功能说明 + 五大题型 + 后续能力 + 不保存写作内容提示 |
| 移动 390 | AI 讲解页与申论页均无横向溢出390/390 |
| 类型检查与构建 | vue-tsc / tsc 通过;生产构建 139 模块通过;`api:generate` 与 OpenAPI 一致 |
截图:`deliverables/checks/task11-ai-desktop.png``task11-ai-mobile.png``task11-ai-fallback-desktop.png``task11-essay-desktop.png``task11-essay-mobile.png`
## 完成标准核对开发计划任务11
- ✅ OpenAI 兼容客户端Chat Completions + Bearer Token环境变量配置
- ✅ Token 配置读取(`AI_BASE_URL` / `AI_API_KEY` / `AI_MODEL` / `AI_TIMEOUT_MS``server/.env.example` 示例)。
- ✅ 结构化讲解summary / steps / knowledgePoints / commonMistakes / answer
- ✅ 超时处理AbortController + `AI_TIMEOUT_MS`,超时回退题库解析)。
- ✅ 题库解析回退(未配置/超时/解析失败均回退,`source:fallback` 可识别,不阻塞刷题)。
- ✅ 前端结果展示AI 讲解页 + 刷题结果/错题详情入口 + 未配置与失败提示)。
- ✅ 申论占位页面(功能说明与后续入口,不保存写作内容)。
- ✅ Token 不出现在前端、OpenAPI 与日志。
## 边界说明
- 测试使用本地 OpenAI 兼容 mock 服务127.0.0.1:3199/3198/3197验证「配置有效」「超时」「不可解析」三条链路未使用真实外部 Token。
- 未配置 AI 时不影响刷题、错题复习、模考等任何流程;设置页 `aiConfigured` 同步反映配置状态。
## 当前结论
任务 11 完成AI 讲解接口与前端结果页、未配置/超时/失败回退、申论占位页全部实测通过桌面与移动双端可操作Token 仅存于后端环境变量且未进入日志/文档/前端。

9
server/.env.example Normal file
View File

@ -0,0 +1,9 @@
# 服务端口与数据目录(可选,默认 3000 与 server/data
PORT=3000
# DATA_DIR=./data
# AI 讲解OpenAI 兼容 Chat Completions不配置时讲解自动回退题库解析
# AI_BASE_URL=https://api.openai.com/v1
# AI_API_KEY=sk-xxxxxxxx
# AI_MODEL=gpt-4o-mini
# AI_TIMEOUT_MS=20000

159
server/src/ai.ts Normal file
View File

@ -0,0 +1,159 @@
import type { Question } from './schemas/entities.js'
/**
* OpenAI Chat Completions Token //
* AI_BASE_URL / AI_API_KEY / AI_MODEL gpt-4o-mini/ AI_TIMEOUT_MS 20000
*/
export interface AiConfig {
baseUrl: string
apiKey: string
model: string
timeoutMs: number
}
export type RequestType = 'standard' | 'deep'
export interface AiExplainResult {
source: 'ai'
summary: string
steps: string[]
knowledgePoints: string[]
commonMistakes: string[]
answer: string
}
/** 读取 AI 配置;未配置(缺 baseUrl 或 apiKey返回 null不抛错 */
export function readAiConfig(): AiConfig | null {
const baseUrl = process.env.AI_BASE_URL?.trim()
const apiKey = process.env.AI_API_KEY?.trim()
if (!baseUrl || !apiKey) return null
const timeoutMs = Number(process.env.AI_TIMEOUT_MS ?? 20000)
return {
baseUrl: baseUrl.replace(/\/+$/, ''),
apiKey,
model: process.env.AI_MODEL?.trim() || 'gpt-4o-mini',
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 20000
}
}
export class AiTimeoutError extends Error {
constructor() {
super('AI 请求超时')
this.name = 'AiTimeoutError'
}
}
const OPTION_TEXT: Record<string, string> = { A: 'A', B: 'B', C: 'C', D: 'D' }
/** 组装题目上下文与用户作答,要求模型输出严格 JSON */
function buildMessages(
question: Question,
userAnswer: string | undefined,
requestType: RequestType
): Array<{ role: 'system' | 'user'; content: string }> {
const options = question.options
.map((o) => `${o.key}. ${o.text}`)
.join('\n')
const depth =
requestType === 'deep'
? '请深入剖析命题思路、干扰项设计与易错点,步骤更细致。'
: '请用简洁清晰的语言讲清楚解题思路。'
const system =
'你是一位经验丰富的公务员考试行测讲师。请基于题目与用户作答输出 JSON字段必须为' +
'"summary"(一句话结论,字符串)、"steps"(解题步骤,字符串数组)、' +
'"knowledgePoints"(考点,字符串数组)、"commonMistakes"(常见错误,字符串数组)、' +
'"answer"(正确答案,只能是 A/B/C/D 之一)。不要输出 JSON 以外的内容。'
const user = [
`模块:${question.module}${question.subModule ? `${question.subModule}` : ''}`,
`难度:${question.difficulty ?? '未标注'}`,
`题干:${question.stem}`,
`选项:\n${options}`,
`标准答案:${question.answer}`,
`题库解析:${question.analysis ?? '(无)'}`,
`标签:${(question.tags ?? []).join('、') || '(无)'}`,
`用户作答:${userAnswer ? (OPTION_TEXT[userAnswer] ?? userAnswer) : '(未作答)'}`,
`讲解要求:${depth}`
].join('\n')
return [
{ role: 'system', content: system },
{ role: 'user', content: user }
]
}
/** 从模型回复中稳健提取 JSON兼容 markdown 代码块与前后杂文) */
export function parseExplainJson(content: string): AiExplainResult | null {
const start = content.indexOf('{')
const end = content.lastIndexOf('}')
if (start === -1 || end <= start) return null
let data: unknown
try {
data = JSON.parse(content.slice(start, end + 1))
} catch {
return null
}
if (typeof data !== 'object' || data === null) return null
const obj = data as Record<string, unknown>
const summary = typeof obj.summary === 'string' ? obj.summary.trim() : ''
const answer = typeof obj.answer === 'string' ? obj.answer.trim().toUpperCase() : ''
if (!summary || !['A', 'B', 'C', 'D'].includes(answer)) return null
const asStrings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map((x) => x.trim()) : []
return {
source: 'ai',
summary,
steps: asStrings(obj.steps),
knowledgePoints: asStrings(obj.knowledgePoints),
commonMistakes: asStrings(obj.commonMistakes),
answer
}
}
/**
* OpenAI
* - handler 退
* - AiTimeoutError
* - /HTTP/ Error
* API Key
*/
export async function explainWithAi(
question: Question,
userAnswer: string | undefined,
requestType: RequestType
): Promise<AiExplainResult> {
const config = readAiConfig()
if (!config) throw new Error('AI 未配置')
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), config.timeoutMs)
try {
const res = await fetch(`${config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`
},
body: JSON.stringify({
model: config.model,
messages: buildMessages(question, userAnswer, requestType),
temperature: 0.4
}),
signal: controller.signal
})
if (!res.ok) throw new Error(`AI 服务响应异常HTTP ${res.status}`)
const data = (await res.json()) as { choices?: Array<{ message?: { content?: unknown } }> }
const content = data.choices?.[0]?.message?.content
if (typeof content !== 'string' || !content.trim()) throw new Error('AI 响应缺少内容')
const parsed = parseExplainJson(content)
if (!parsed) throw new Error('AI 响应解析失败')
return parsed
} catch (err) {
if (controller.signal.aborted) throw new AiTimeoutError()
throw err
} finally {
clearTimeout(timer)
}
}

View File

@ -2,14 +2,28 @@ import type { FastifyRequest } from 'fastify'
import { readData } from '../data/store.js' import { readData } from '../data/store.js'
import { dataFiles } from '../data/files.js' import { dataFiles } from '../data/files.js'
import { ApiError } from '../errors.js' import { ApiError } from '../errors.js'
import { AiTimeoutError, explainWithAi, readAiConfig } from '../ai.js'
import type { Question } from '../schemas/entities.js' import type { Question } from '../schemas/entities.js'
import type { z } from 'zod' import type { z } from 'zod'
import { AiExplainBodySchema } from '../schemas/api.js' import { AiExplainBodySchema } from '../schemas/api.js'
/** 题库解析回退结果AI 未配置 / 超时 / 失败时返回source=fallback 便于前端识别) */
function fallbackExplain(question: Question, userAnswer: string | undefined, reason: string) {
return {
source: 'fallback' as const,
summary: `${reason},已回退到题库标准解析(用户答案:${userAnswer ?? '未作答'}`,
steps: [question.analysis],
knowledgePoints: question.tags ?? [],
commonMistakes: [],
answer: question.answer
}
}
/** /**
* AI * AI
* TODO 11 OpenAI Token 退 * 1. AIAI_BASE_URL + AI_API_KEY OpenAI
* 退 * 2. / / 退source=fallback
* 3. Token OpenAPI
*/ */
export async function explainQuestion(request: FastifyRequest) { export async function explainQuestion(request: FastifyRequest) {
const body = request.body as z.infer<typeof AiExplainBodySchema> const body = request.body as z.infer<typeof AiExplainBodySchema>
@ -17,12 +31,14 @@ export async function explainQuestion(request: FastifyRequest) {
const question = questions.find((q) => q.id === body.questionId) const question = questions.find((q) => q.id === body.questionId)
if (!question) throw ApiError.notFound('题目不存在') if (!question) throw ApiError.notFound('题目不存在')
return { if (!readAiConfig()) {
source: 'fallback' as const, return fallbackExplain(question, body.userAnswer, 'AI 尚未配置')
summary: `AI 讲解尚未接入,已回退到题库标准解析(用户答案:${body.userAnswer}`, }
steps: [question.analysis],
knowledgePoints: question.tags, try {
commonMistakes: [], return await explainWithAi(question, body.userAnswer, body.requestType)
answer: question.answer } catch (err) {
const reason = err instanceof AiTimeoutError ? 'AI 请求超时' : 'AI 讲解暂时不可用'
return fallbackExplain(question, body.userAnswer, reason)
} }
} }

View File

@ -436,7 +436,8 @@ export const QuestionsRefsResponseSchema = z.object({
// ---------- AI 讲解 ---------- // ---------- AI 讲解 ----------
export const AiExplainBodySchema = z.object({ export const AiExplainBodySchema = z.object({
questionId: z.string().min(1), questionId: z.string().min(1),
userAnswer: z.enum(OPTION_KEYS), /** 用户答案;从错题详情进入且无作答历史时可省略 */
userAnswer: z.enum(OPTION_KEYS).optional(),
requestType: z.enum(['standard', 'deep']).default('standard') requestType: z.enum(['standard', 'deep']).default('standard')
}) })
export const AiExplainResponseSchema = z.object({ export const AiExplainResponseSchema = z.object({