实现
-
对某道题生成 1 道相似再练题
-
按知识点生成 3 道练习题
后端 schema 修改
修改 backend/app/schemas.py
新增:
class PracticeQuestionItem(BaseModel):
question: str
answer: str
steps: List[str]
class GeneratePracticeRequest(BaseModel):
knowledge_point: str
count: int = 3
class GeneratePracticeResponse(BaseModel):
knowledge_point: str
questions: List[PracticeQuestionItem]
class RegenerateQuestionResponse(BaseModel):
source_question_id: int
question: str
answer: str
steps: List[str]
新增练习题生成服务
新增 backend/app/practice_service.py
import json
from app.llm_service import client, MODEL
from app.rag_service import build_context
PRACTICE_SYSTEM_PROMPT = """
你是一位专业的初中数学老师。
请严格返回 JSON,不要返回 markdown,不要加 ```json。
如果用户要求生成练习题,请返回:
{
"knowledge_point": "知识点名称",
"questions": [
{
"question": "题目1",
"answer": "答案1",
"steps": ["步骤1", "步骤2"]
}
]
}
如果用户要求基于某道题生成再练一题,请返回:
{
"question": "新的相似题",
"answer": "对应答案",
"steps": ["步骤1", "步骤2"]
}
"""
def _clean_json_text(content: str) -> str:
content = (content or "").strip()
if content.startswith("```json"):
content = content.removeprefix("```json").strip()
if content.startswith("```"):
content = content.removeprefix("```").strip()
if content.endswith("```"):
content = content.removesuffix("```").strip()
return content
def generate_practice_by_knowledge(knowledge_point: str, count: int = 3):
context = build_context(knowledge_point)
user_content = (
f"请围绕知识点“{knowledge_point}”生成 {count} 道适合初中学生的练习题,"
"每道题都要包含题目、答案、分步解析。"
)
if context:
user_content += f"\n\n可参考知识库内容:\n{context}"
resp = client.chat.completions.create(
model=MODEL,
temperature=0.5,
messages=[
{"role": "system", "content": PRACTICE_SYSTEM_PROMPT},
{"role": "user", "content": user_content},
],
)
content = _clean_json_text(resp.choices[0].message.content or "")
data = json.loads(content)
if "knowledge_point" not in data or "questions" not in data:
raise ValueError(f"练习题返回格式错误:{data}")
if not isinstance(data["questions"], list):
raise ValueError(f"questions 不是数组:{data}")
return data
def regenerate_similar_question(source_question: str):
context = build_context(source_question)
user_content = (
f"基于下面这道题,生成 1 道同类型、同难度、但数字不同的新题,并给出答案和分步解析:\n"
f"{source_question}"
)
if context:
user_content += f"\n\n可参考知识库内容:\n{context}"
resp = client.chat.completions.create(
model=MODEL,
temperature=0.5,
messages=[
{"role": "system", "content": PRACTICE_SYSTEM_PROMPT},
{"role": "user", "content": user_content},
],
)
content = _clean_json_text(resp.choices[0].message.content or "")
data = json.loads(content)
for key in ["question", "answer", "steps"]:
if key not in data:
raise ValueError(f"再练题返回格式错误,缺少 {key}:{data}")
if not isinstance(data["steps"], list):
raise ValueError(f"steps 不是数组:{data}")
return data
主接口新增两个 API
修改 backend/app/main.py
1)先补充 import
在顶部 import 区域新增:
from app.practice_service import (
generate_practice_by_knowledge,
regenerate_similar_question,
)
from app.schemas import (
GeneratePracticeRequest,
GeneratePracticeResponse,
RegenerateQuestionResponse,
)
2)新增“按知识点生成练习题”接口
把这个接口加到 main.py 里:
@app.post("/api/generate-practice", response_model=GeneratePracticeResponse)
def generate_practice(req: GeneratePracticeRequest):
try:
result = generate_practice_by_knowledge(req.knowledge_point, req.count)
return GeneratePracticeResponse(**result)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
3)新增“再练一题”接口
把这个接口也加到 main.py 里:
@app.post("/api/history/{question_id}/regenerate", response_model=RegenerateQuestionResponse)
def regenerate_question(question_id: int, db: Session = Depends(get_db)):
row = db.query(QuestionHistory).filter(QuestionHistory.id == question_id).first()
if not row:
raise HTTPException(status_code=404, detail="记录不存在")
try:
result = regenerate_similar_question(row.question)
return RegenerateQuestionResponse(
source_question_id=question_id,
question=result["question"],
answer=result["answer"],
steps=result["steps"],
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
后端新增 backend/app/rag_service.py
import json
import os
from typing import List, Dict
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
DATA_PATH = os.path.join(BASE_DIR, "data", "math_knowledge.json")
def load_knowledge_base() -> List[Dict]:
if not os.path.exists(DATA_PATH):
return []
with open(DATA_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def score_item(question: str, item: Dict) -> int:
score = 0
text = f"{item.get('title', '')} {' '.join(item.get('keywords', []))} {item.get('content', '')}"
for kw in item.get("keywords", []):
if kw and kw in question:
score += 5
for ch in set(question):
if ch.strip() and ch in text:
score += 1
return score
def retrieve_knowledge(question: str, top_k: int = 3) -> List[Dict]:
kb = load_knowledge_base()
scored = []
for item in kb:
score = score_item(question, item)
if score > 0:
scored.append((score, item))
scored.sort(key=lambda x: x[0], reverse=True)
return [item for _, item in scored[:top_k]]
def build_context(question: str) -> str:
items = retrieve_knowledge(question, top_k=3)
if not items:
return ""
parts = []
for idx, item in enumerate(items, start=1):
parts.append(
f"知识片段{idx}:\n"
f"标题:{item['title']}\n"
f"内容:{item['content']}\n"
)
return "\n".join(parts)
def get_knowledge_titles(question: str) -> List[str]:
items = retrieve_knowledge(question, top_k=3)
return [item["title"] for item in items]
新增 backend/data/math_knowledge.json
[ { "id": 1, "title": "一元一次方程基础", "keywords": ["方程", "一元一次方程", "解方程", "移项", "系数"],
"content": "一元一次方程是只含有一个未知数,并且未知数的次数是1的方程。解题时通常先移项,再合并同类项,最后把未知数系数化为1。"
},
{
"id": 2,
"title": "分数加减法",
"keywords": ["分数", "通分", "分数加减"],
"content": "分数加减法的关键是先通分,把分母化成相同,再把分子相加减,最后能约分的要约分。"
},
{
"id": 3,
"title": "一次函数基础",
"keywords": ["一次函数", "y=kx+b", "函数", "图像", "斜率"],
"content": "一次函数通常写成 y = kx + b 的形式,其中 k 表示斜率,b 表示与 y 轴交点。判断函数图像和增减性时,重点看 k 的正负。"
},
{
"id": 4,
"title": "二元一次方程组",
"keywords": ["二元一次方程组", "代入消元", "加减消元"],
"content": "解二元一次方程组常用代入消元法和加减消元法。先消去一个未知数,再求出另一个未知数,最后代回求解。"
},
{
"id": 5,
"title": "三角形基础性质",
"keywords": ["三角形", "内角和", "等腰三角形", "几何"],
"content": "三角形内角和等于180度。等腰三角形两腰相等、两个底角相等。解决几何题时要注意已知条件和图形性质结合。"
}
]
前端 API 新增
修改 frontend/src/api/math.ts
新增:
export interface PracticeQuestionItem {
question: string
answer: string
steps: string[]
}
export interface GeneratePracticeResponse {
knowledge_point: string
questions: PracticeQuestionItem[]
}
export interface RegenerateQuestionResponse {
source_question_id: number
question: string
answer: string
steps: string[]
}
再新增两个请求方法:
export function generatePracticeByKnowledge(knowledge_point: string, count = 3) {
return request.post<GeneratePracticeResponse>('/api/generate-practice', {
knowledge_point,
count,
})
}
export function regenerateQuestion(id: number) {
return request.post<RegenerateQuestionResponse>(`/api/history/${id}/regenerate`)
}
前端页面增加状态
修改 frontend/src/App.vue
1)修改 import
把 api import 改成下面这样补齐:
import {
solveMathQuestion,
solveMathImage,
getHistoryList,
getWrongQuestionList,
markWrongQuestion,
generatePracticeByKnowledge,
regenerateQuestion,
type SolveResponse,
type HistoryItem,
type PracticeQuestionItem,
} from './api/math'
2)新增状态
在 script setup 里新增:
const practiceKnowledge = ref('')
const practiceLoading = ref(false)
const practiceList = ref<PracticeQuestionItem[]>([])
const regenerateLoadingMap = ref<Record<number, boolean>>({})
const regeneratedMap = ref<Record<number, PracticeQuestionItem>>({})
3)新增方法
在 script setup 里新增:
const handleGeneratePractice = async () => {
if (!practiceKnowledge.value.trim()) return
practiceLoading.value = true
try {
const { data } = await generatePracticeByKnowledge(practiceKnowledge.value, 3)
practiceList.value = data.questions
} catch (error: any) {
console.error('生成练习题失败:', error)
alert(error?.response?.data?.detail || '生成练习题失败')
} finally {
practiceLoading.value = false
}
}
const handleRegenerateQuestion = async (id: number) => {
regenerateLoadingMap.value[id] = true
try {
const { data } = await regenerateQuestion(id)
regeneratedMap.value[id] = {
question: data.question,
answer: data.answer,
steps: data.steps,
}
} catch (error: any) {
console.error('再练一题失败:', error)
alert(error?.response?.data?.detail || '再练一题失败')
} finally {
regenerateLoadingMap.value[id] = false
}
}
前端“题目解析”页增加知识点练习生成
修改 frontend/src/App.vue
<div class="practice-panel">
<h2>按知识点生成练习题</h2>
<div class="practice-form">
<input
v-model="practiceKnowledge"
class="practice-input"
placeholder="请输入知识点,例如:一元一次方程"
/>
<button class="submit-btn" @click="handleGeneratePractice" :disabled="practiceLoading">
{{ practiceLoading ? '生成中...' : '生成练习题' }}
</button>
</div>
<div v-if="practiceList.length" class="practice-list">
<div v-for="(item, index) in practiceList" :key="index" class="result-card">
<h3>练习题 {{ index + 1 }}</h3>
<p>{{ item.question }}</p>
<h3>答案</h3>
<p>{{ item.answer }}</p>
<h3>步骤解析</h3>
<ol>
<li v-for="(step, idx) in item.steps" :key="idx">{{ step }}</li>
</ol>
</div>
</div>
</div>
前端历史记录 / 错题本增加“再练一题”
修改 frontend/src/App.vue
1)历史记录区域
<div class="card-actions">
<button class="retry-btn" @click="handleRegenerateQuestion(item.id)">
{{ regenerateLoadingMap[item.id] ? '生成中...' : '再练一题' }}
</button>
<button class="wrong-btn" @click="toggleWrong(item)">
{{ item.is_wrong ? '取消错题' : '加入错题本' }}
</button>
</div>
<div v-if="regeneratedMap[item.id]" class="regenerated-box">
<h3>再练一题</h3>
<p>{{ regeneratedMap[item.id].question }}</p>
<h3>答案</h3>
<p>{{ regeneratedMap[item.id].answer }}</p>
<h3>步骤解析</h3>
<ol>
<li v-for="(step, idx) in regeneratedMap[item.id].steps" :key="idx">
{{ step }}
</li>
</ol>
</div>
2)错题本区域
<div class="card-actions">
<button class="retry-btn" @click="handleRegenerateQuestion(item.id)">
{{ regenerateLoadingMap[item.id] ? '生成中...' : '再练一题' }}
</button>
<button class="wrong-btn" @click="toggleWrong(item)">
取消错题
</button>
</div>
<div v-if="regeneratedMap[item.id]" class="regenerated-box">
<h3>再练一题</h3>
<p>{{ regeneratedMap[item.id].question }}</p>
<h3>答案</h3>
<p>{{ regeneratedMap[item.id].answer }}</p>
<h3>步骤解析</h3>
<ol>
<li v-for="(step, idx) in regeneratedMap[item.id].steps" :key="idx">
{{ step }}
</li>
</ol>
</div>
前端样式
修改 frontend/src/App.vue
.card-actions {
display: flex;
gap: 8px;
}
.retry-btn {
padding: 8px 14px;
border: none;
background: #2080f0;
color: #fff;
border-radius: 8px;
cursor: pointer;
}
.practice-panel {
margin-top: 32px;
}
.practice-form {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.practice-input {
flex: 1;
height: 40px;
padding: 0 12px;
border: 1px solid #ddd;
border-radius: 8px;
outline: none;
}
.practice-list {
margin-top: 16px;
}
.regenerated-box {
margin-top: 16px;
padding: 16px;
background: #f0f7ff;
border-radius: 8px;
}
启动查看效果
重启后端
uvicorn app.main:app --reload --port 8000
重启前端
npm run dev