错题再练一题 + 知识点练习

0 阅读6分钟

实现

  • 对某道题生成 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

image.png

image.png

image.png

image.png

image.png