feat: 完成AI讲解与申论占位
This commit is contained in:
parent
6d8f23d037
commit
c736b128f9
@ -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 = {
|
||||||
|
|||||||
@ -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: '录入模考成绩' } },
|
||||||
|
|||||||
257
client/src/views/practice/AiExplainView.vue
Normal file
257
client/src/views/practice/AiExplainView.vue
Normal 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>
|
||||||
@ -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);
|
||||||
|
|||||||
132
client/src/views/practice/EssayView.vue
Normal file
132
client/src/views/practice/EssayView.vue
Normal 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>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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
76
docs/checks/task-11.md
Normal 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()`:组装模块/题干/选项/标准答案/题库解析/标签/用户作答,要求模型输出严格 JSON(summary / steps / knowledgePoints / commonMistakes / answer)。
|
||||||
|
- `parseExplainJson()`:兼容 markdown 代码块等杂文的稳健 JSON 提取与字段校验。
|
||||||
|
- `explainWithAi()`:OpenAI 兼容 Chat Completions 调用(`${baseUrl}/chat/completions` + Bearer),AbortController 超时;未配置/超时/网络/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 尚未配置,已回退到题库标准解析(用户答案:A)」,steps=题库解析、answer=C |
|
||||||
|
| 配置有效 Token(mock) | `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 Key;OpenAPI 无 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
9
server/.env.example
Normal 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
159
server/src/ai.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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. 配置了 AI(AI_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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user