本节项目加上:
- SQLite 历史记录
- 查询历史接口
- 标记错题接口
- 错题本列表接口
- 前端历史记录 / 错题本展示
后端新增依赖
在 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
}
}
效果展示
这是第二个题目
第一道题添加到错题本
nice !