加入 SQLite 历史记录 + 错题本接口 + 前端历史记录页面

0 阅读6分钟

本节项目加上:

  1. SQLite 历史记录
  2. 查询历史接口
  3. 标记错题接口
  4. 错题本列表接口
  5. 前端历史记录 / 错题本展示

后端新增依赖

backend 目录执行:

pip install sqlalchemy

后端新增文件

1)新增 app/database.py

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base

DATABASE_URL = "sqlite:///./math_tutor.db"

engine = create_engine(
    DATABASE_URL,
    connect_args={"check_same_thread": False},
)

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

2)新增 app/models.py

from sqlalchemy import Column, Integer, Text, Boolean
from app.database import Base


class QuestionHistory(Base):
    __tablename__ = "question_history"

    id = Column(Integer, primary_key=True, index=True)
    question = Column(Text, nullable=False)
    answer = Column(Text, nullable=False)
    steps = Column(Text, nullable=False)  # JSON 字符串
    knowledge_points = Column(Text, nullable=False)  # JSON 字符串
    similar_question = Column(Text, nullable=False)
    is_wrong = Column(Boolean, default=False, nullable=False)

后端修改文件

1)修改 app/schemas.py

from pydantic import BaseModel
from typing import List


class SolveQuestionRequest(BaseModel):
    question: str


class SolveQuestionResponse(BaseModel):
    id: int
    answer: str
    steps: List[str]
    knowledge_points: List[str]
    similar_question: str
    is_wrong: bool


class HistoryItem(BaseModel):
    id: int
    question: str
    answer: str
    steps: List[str]
    knowledge_points: List[str]
    similar_question: str
    is_wrong: bool

    class Config:
        from_attributes = True


class MarkWrongRequest(BaseModel):
    is_wrong: bool

2)修改 app/main.py

import json
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session

from app.schemas import (
    SolveQuestionRequest,
    SolveQuestionResponse,
    HistoryItem,
    MarkWrongRequest,
)
from app.llm_service import solve_math_question
from app.database import Base, engine, get_db
from app.models import QuestionHistory

Base.metadata.create_all(bind=engine)

app = FastAPI(title="AI Math Tutor API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/")
def health():
    return {"message": "AI Math Tutor API is running"}


@app.post("/api/solve", response_model=SolveQuestionResponse)
def solve_question(req: SolveQuestionRequest, db: Session = Depends(get_db)):
    result = solve_math_question(req.question)

    row = QuestionHistory(
        question=req.question,
        answer=result["answer"],
        steps=json.dumps(result["steps"], ensure_ascii=False),
        knowledge_points=json.dumps(result["knowledge_points"], ensure_ascii=False),
        similar_question=result["similar_question"],
        is_wrong=False,
    )
    db.add(row)
    db.commit()
    db.refresh(row)

    return SolveQuestionResponse(
        id=row.id,
        answer=row.answer,
        steps=json.loads(row.steps),
        knowledge_points=json.loads(row.knowledge_points),
        similar_question=row.similar_question,
        is_wrong=row.is_wrong,
    )


@app.get("/api/history", response_model=list[HistoryItem])
def get_history(db: Session = Depends(get_db)):
    rows = db.query(QuestionHistory).order_by(QuestionHistory.id.desc()).all()
    return [
        HistoryItem(
            id=row.id,
            question=row.question,
            answer=row.answer,
            steps=json.loads(row.steps),
            knowledge_points=json.loads(row.knowledge_points),
            similar_question=row.similar_question,
            is_wrong=row.is_wrong,
        )
        for row in rows
    ]


@app.patch("/api/history/{question_id}/wrong", response_model=HistoryItem)
def mark_wrong(question_id: int, body: MarkWrongRequest, db: Session = Depends(get_db)):
    row = db.query(QuestionHistory).filter(QuestionHistory.id == question_id).first()
    if not row:
        raise HTTPException(status_code=404, detail="记录不存在")

    row.is_wrong = body.is_wrong
    db.commit()
    db.refresh(row)

    return HistoryItem(
        id=row.id,
        question=row.question,
        answer=row.answer,
        steps=json.loads(row.steps),
        knowledge_points=json.loads(row.knowledge_points),
        similar_question=row.similar_question,
        is_wrong=row.is_wrong,
    )


@app.get("/api/wrong-questions", response_model=list[HistoryItem])
def get_wrong_questions(db: Session = Depends(get_db)):
    rows = (
        db.query(QuestionHistory)
        .filter(QuestionHistory.is_wrong == True)
        .order_by(QuestionHistory.id.desc())
        .all()
    )

    return [
        HistoryItem(
            id=row.id,
            question=row.question,
            answer=row.answer,
            steps=json.loads(row.steps),
            knowledge_points=json.loads(row.knowledge_points),
            similar_question=row.similar_question,
            is_wrong=row.is_wrong,
        )
        for row in rows
    ]

前端修改文件

1)修改 src/api/math.ts

import axios from 'axios'

const request = axios.create({
  baseURL: 'http://127.0.0.1:8000',
  timeout: 30000,
})

export interface SolveRequest {
  question: string
}

export interface HistoryItem {
  id: number
  question: string
  answer: string
  steps: string[]
  knowledge_points: string[]
  similar_question: string
  is_wrong: boolean
}

export type SolveResponse = HistoryItem

export function solveMathQuestion(data: SolveRequest) {
  return request.post<SolveResponse>('/api/solve', data)
}

export function getHistoryList() {
  return request.get<HistoryItem[]>('/api/history')
}

export function getWrongQuestionList() {
  return request.get<HistoryItem[]>('/api/wrong-questions')
}

export function markWrongQuestion(id: number, is_wrong: boolean) {
  return request.patch<HistoryItem>(`/api/history/${id}/wrong`, { is_wrong })
}

2)修改 src/App.vue

<template>
  <div class="page">
    <div class="container">
      <h1>AI 数学辅导老师</h1>

      <div class="tabs">
        <button
          :class="['tab-btn', activeTab === 'solve' ? 'active' : '']"
          @click="activeTab = 'solve'"
        >
          题目解析
        </button>
        <button
          :class="['tab-btn', activeTab === 'history' ? 'active' : '']"
          @click="switchToHistory"
        >
          历史记录
        </button>
        <button
          :class="['tab-btn', activeTab === 'wrong' ? 'active' : '']"
          @click="switchToWrong"
        >
          错题本
        </button>
      </div>

      <template v-if="activeTab === 'solve'">
        <textarea
          v-model="question"
          class="question-input"
          placeholder="请输入一道数学题,例如:解方程 3x + 5 = 11"
        />

        <button class="submit-btn" @click="handleSubmit" :disabled="loading">
          {{ loading ? '解析中...' : '开始解析' }}
        </button>

        <div v-if="result" class="result-card">
          <div class="card-header">
            <h2>本次解析结果</h2>
            <button class="wrong-btn" @click="toggleWrong(result)">
              {{ result.is_wrong ? '取消错题' : '加入错题本' }}
            </button>
          </div>

          <h3>题目</h3>
          <p>{{ result.question }}</p>

          <h3>答案</h3>
          <p>{{ result.answer }}</p>

          <h3>步骤解析</h3>
          <ol>
            <li v-for="(item, index) in result.steps" :key="index">{{ item }}</li>
          </ol>

          <h3>知识点</h3>
          <ul>
            <li v-for="(item, index) in result.knowledge_points" :key="index">
              {{ item }}
            </li>
          </ul>

          <h3>相似题</h3>
          <p>{{ result.similar_question }}</p>
        </div>
      </template>

      <template v-else-if="activeTab === 'history'">
        <div v-if="historyList.length === 0" class="empty">暂无历史记录</div>

        <div v-for="item in historyList" :key="item.id" class="result-card">
          <div class="card-header">
            <h2>记录 #{{ item.id }}</h2>
            <button class="wrong-btn" @click="toggleWrong(item)">
              {{ item.is_wrong ? '取消错题' : '加入错题本' }}
            </button>
          </div>

          <h3>题目</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>

          <h3>知识点</h3>
          <ul>
            <li v-for="(kp, idx) in item.knowledge_points" :key="idx">{{ kp }}</li>
          </ul>

          <h3>相似题</h3>
          <p>{{ item.similar_question }}</p>
        </div>
      </template>

      <template v-else>
        <div v-if="wrongList.length === 0" class="empty">暂无错题</div>

        <div v-for="item in wrongList" :key="item.id" class="result-card">
          <div class="card-header">
            <h2>错题 #{{ item.id }}</h2>
            <button class="wrong-btn" @click="toggleWrong(item)">
              取消错题
            </button>
          </div>

          <h3>题目</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>

          <h3>知识点</h3>
          <ul>
            <li v-for="(kp, idx) in item.knowledge_points" :key="idx">{{ kp }}</li>
          </ul>

          <h3>相似题</h3>
          <p>{{ item.similar_question }}</p>
        </div>
      </template>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
  solveMathQuestion,
  getHistoryList,
  getWrongQuestionList,
  markWrongQuestion,
  type SolveResponse,
  type HistoryItem,
} from './api/math'

const question = ref('')
const loading = ref(false)
const result = ref<(SolveResponse & { question: string }) | null>(null)

const activeTab = ref<'solve' | 'history' | 'wrong'>('solve')
const historyList = ref<HistoryItem[]>([])
const wrongList = ref<HistoryItem[]>([])

const loadHistory = async () => {
  const { data } = await getHistoryList()
  historyList.value = data
}

const loadWrongList = async () => {
  const { data } = await getWrongQuestionList()
  wrongList.value = data
}

const handleSubmit = async () => {
  if (!question.value.trim()) return

  loading.value = true
  try {
    const currentQuestion = question.value
    const { data } = await solveMathQuestion({
      question: currentQuestion,
    })
    result.value = {
      ...data,
      question: currentQuestion,
    }
    await loadHistory()
    await loadWrongList()
  } catch (error) {
    console.error(error)
    alert('解析失败,请检查后端是否启动')
  } finally {
    loading.value = false
  }
}

const toggleWrong = async (item: HistoryItem | (SolveResponse & { question: string })) => {
  try {
    const { data } = await markWrongQuestion(item.id, !item.is_wrong)

    if (result.value && result.value.id === item.id) {
      result.value = {
        ...result.value,
        is_wrong: data.is_wrong,
      }
    }

    await loadHistory()
    await loadWrongList()
  } catch (error) {
    console.error(error)
    alert('更新错题状态失败')
  }
}

const switchToHistory = async () => {
  activeTab.value = 'history'
  await loadHistory()
}

const switchToWrong = async () => {
  activeTab.value = 'wrong'
  await loadWrongList()
}

onMounted(async () => {
  await loadHistory()
  await loadWrongList()
})
</script>

<style scoped>
.page {
  min-height: 100vh;
  background: #f5f7fa;
  padding: 40px 16px;
}
.container {
  max-width: 900px;
  margin: 0 auto;
  background: #fff;
  padding: 24px;
  border-radius: 12px;
}
.tabs {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
}
.tab-btn {
  padding: 8px 16px;
  border: 1px solid #ddd;
  background: #fff;
  border-radius: 8px;
  cursor: pointer;
}
.tab-btn.active {
  background: #18a058;
  color: #fff;
  border-color: #18a058;
}
.question-input {
  width: 100%;
  min-height: 140px;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  resize: vertical;
  font-size: 16px;
  box-sizing: border-box;
}
.submit-btn {
  margin-top: 16px;
  padding: 10px 18px;
  border: none;
  background: #18a058;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}
.result-card {
  margin-top: 24px;
  padding: 20px;
  background: #fafafa;
  border-radius: 8px;
}
.card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.wrong-btn {
  padding: 8px 14px;
  border: none;
  background: #f0a020;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}
.empty {
  padding: 32px 0;
  text-align: center;
  color: #999;
}
</style>

重启

1)重启后端

uvicorn app.main:app --reload --port 8000

2)重启前端

npm run dev

如果服务报错 500

1)修改 app/llm_service.py

import os
import json
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL")
MODEL = os.getenv("OPENAI_MODEL", "moonshot-v1-8k")

client = OpenAI(
    api_key=OPENAI_API_KEY,
    base_url=OPENAI_BASE_URL,
)

SYSTEM_PROMPT = """
你是一位专业的初中数学辅导老师。

用户会输入一道数学题,请你严格返回 JSON,格式如下:
{
  "answer": "最终答案",
  "steps": ["步骤1", "步骤2", "步骤3"],
  "knowledge_points": ["知识点1", "知识点2"],
  "similar_question": "一道类似的新题目"
}

要求:
1. 只返回 JSON,不要返回 markdown,不要加 ```json
2. steps 必须清晰易懂,适合初中学生
3. knowledge_points 只返回核心知识点
4. similar_question 必须和原题难度接近
5. 如果题目不清晰,也要尽量给出合理提示,并保持 JSON 格式
"""


def solve_math_question(question: str):
    if not OPENAI_API_KEY:
        raise ValueError("未读取到 OPENAI_API_KEY")
    if not OPENAI_BASE_URL:
        raise ValueError("未读取到 OPENAI_BASE_URL")
    if not MODEL:
        raise ValueError("未读取到 OPENAI_MODEL")

    resp = client.chat.completions.create(
        model=MODEL,
        temperature=0.3,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"题目:{question}"},
        ],
    )

    content = (resp.choices[0].message.content or "").strip()

    if not content:
        raise ValueError("模型返回为空")

    try:
        data = json.loads(content)
    except Exception:
        raise ValueError(f"模型返回的不是合法 JSON:{content}")

    for key in ["answer", "steps", "knowledge_points", "similar_question"]:
        if key not in data:
            raise ValueError(f"模型返回缺少字段 {key}{data}")

    if not isinstance(data["steps"], list):
        raise ValueError(f"steps 不是数组:{data}")

    if not isinstance(data["knowledge_points"], list):
        raise ValueError(f"knowledge_points 不是数组:{data}")

    return data

2)修改 app/main.py

只改 /api/solve

@app.post("/api/solve", response_model=SolveQuestionResponse)
def solve_question(req: SolveQuestionRequest, db: Session = Depends(get_db)):
    try:
        result = solve_math_question(req.question)

        row = QuestionHistory(
            question=req.question,
            answer=result["answer"],
            steps=json.dumps(result["steps"], ensure_ascii=False),
            knowledge_points=json.dumps(result["knowledge_points"], ensure_ascii=False),
            similar_question=result["similar_question"],
            is_wrong=False,
        )
        db.add(row)
        db.commit()
        db.refresh(row)

        return SolveQuestionResponse(
            id=row.id,
            answer=row.answer,
            steps=json.loads(row.steps),
            knowledge_points=json.loads(row.knowledge_points),
            similar_question=row.similar_question,
            is_wrong=row.is_wrong,
        )
    except Exception as e:
        db.rollback()
        raise HTTPException(status_code=500, detail=str(e))

你前端请求失败时,把 catch 也改一下,方便直接看到后端 detail。

修改 src/App.vue 里的 handleSubmit

const handleSubmit = async () => {
  if (!question.value.trim()) return

  loading.value = true
  try {
    const currentQuestion = question.value
    const { data } = await solveMathQuestion({
      question: currentQuestion,
    })
    result.value = {
      ...data,
      question: currentQuestion,
    }
    await loadHistory()
    await loadWrongList()
  } catch (error: any) {
    console.error('解析失败:', error)
    alert(error?.response?.data?.detail || '解析失败,请检查后端日志')
  } finally {
    loading.value = false
  }
}

效果展示

image.png

这是第二个题目

image.png

image.png

第一道题添加到错题本

image.png

nice !