移动端拍照上传、奥数区、学段约束解题与 AI 模型配置。

- 手机/平板响应式布局,支持拍照与相册上传

- 学生详情新增奥数区,按初/高中学段生成解法并禁止超纲

- 系统设置可配置 Ollama 或 OpenAI 兼容 API

- 更新 frontend/dist 与使用说明
This commit is contained in:
dekun
2026-06-28 13:39:54 +08:00
parent 4375ea491e
commit 43483bf56f
26 changed files with 1193 additions and 592 deletions
+2
View File
@@ -11,6 +11,8 @@ class Settings(BaseSettings):
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024
OLLAMA_BASE_URL: str = "http://127.0.0.1:11434"
OLLAMA_MODEL: str = "qwen2.5:7b"
OPENAI_BASE_URL: str = "https://api.openai.com/v1"
OPENAI_MODEL: str = "gpt-4o-mini"
FLUCTUATION_THRESHOLD: float = 0.08
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:23566,http://localhost"
WEB_PORT: int = 23566
+19
View File
@@ -22,6 +22,16 @@ class WrongQuestionStatus(str, enum.Enum):
failed = "failed"
class WrongQuestionCategory(str, enum.Enum):
regular = "regular"
olympiad = "olympiad"
class AIProvider(str, enum.Enum):
ollama = "ollama"
openai = "openai"
class SchoolLevel(str, enum.Enum):
junior_high = "junior_high"
senior_high = "senior_high"
@@ -123,6 +133,9 @@ class WrongQuestion(Base):
status: Mapped[WrongQuestionStatus] = mapped_column(
Enum(WrongQuestionStatus), default=WrongQuestionStatus.pending
)
category: Mapped[WrongQuestionCategory] = mapped_column(
Enum(WrongQuestionCategory), default=WrongQuestionCategory.regular
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
@@ -136,6 +149,12 @@ class SystemSettings(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
registration_enabled: Mapped[bool] = mapped_column(default=True)
ai_provider: Mapped[str] = mapped_column(String(16), default="ollama")
ollama_base_url: Mapped[str | None] = mapped_column(String(256), nullable=True)
ollama_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
openai_base_url: Mapped[str | None] = mapped_column(String(256), nullable=True)
openai_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
openai_api_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+28 -2
View File
@@ -13,6 +13,7 @@ from app.schemas import (
AdminUserCreate,
AdminUserOut,
AdminUserPasswordUpdate,
AIProviderEnum,
PublicSettingsOut,
SystemSettingsOut,
SystemSettingsUpdate,
@@ -21,6 +22,19 @@ from app.schemas import (
router = APIRouter(prefix="/admin", tags=["admin"])
def settings_to_out(row: SystemSettings) -> SystemSettingsOut:
return SystemSettingsOut(
registration_enabled=row.registration_enabled,
ai_provider=AIProviderEnum(row.ai_provider or "ollama"),
ollama_base_url=row.ollama_base_url,
ollama_model=row.ollama_model,
openai_base_url=row.openai_base_url,
openai_model=row.openai_model,
openai_api_key_set=bool(row.openai_api_key),
updated_at=row.updated_at,
)
def get_or_create_settings(db: Session) -> SystemSettings:
row = db.get(SystemSettings, 1)
if row is None:
@@ -36,7 +50,7 @@ def get_settings(
db: Session = Depends(get_db),
_: User = Depends(get_superuser),
):
return get_or_create_settings(db)
return settings_to_out(get_or_create_settings(db))
@router.patch("/settings", response_model=SystemSettingsOut)
@@ -48,10 +62,22 @@ def update_settings(
row = get_or_create_settings(db)
if data.registration_enabled is not None:
row.registration_enabled = data.registration_enabled
if data.ai_provider is not None:
row.ai_provider = data.ai_provider.value
if data.ollama_base_url is not None:
row.ollama_base_url = data.ollama_base_url or None
if data.ollama_model is not None:
row.ollama_model = data.ollama_model or None
if data.openai_base_url is not None:
row.openai_base_url = data.openai_base_url or None
if data.openai_model is not None:
row.openai_model = data.openai_model or None
if data.openai_api_key is not None and data.openai_api_key.strip():
row.openai_api_key = data.openai_api_key.strip()
row.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return row
return settings_to_out(row)
@router.patch("/profile", response_model=AdminUserOut)
+31 -10
View File
@@ -8,10 +8,10 @@ from sqlalchemy.orm import Session, joinedload
from app.core.config import settings
from app.core.database import SessionLocal, get_db
from app.core.deps import get_current_user
from app.models.user import Subject, User, WrongQuestion, WrongQuestionStatus
from app.schemas import WrongQuestionOut, WrongQuestionUpdate
from app.models.user import Subject, User, WrongQuestion, WrongQuestionCategory, WrongQuestionStatus
from app.schemas import WrongQuestionCategoryEnum, WrongQuestionOut, WrongQuestionUpdate
from app.services import llm as llm_service
from app.services import ocr as ocr_service
from app.services import ollama as ollama_service
from app.services.student_access import get_student_for_user
router = APIRouter(tags=["wrong_questions"])
@@ -23,6 +23,7 @@ def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut:
student_id=wq.student_id,
subject_id=wq.subject_id,
subject_name=wq.subject.name if wq.subject else None,
category=wq.category,
image_path=wq.image_path,
ocr_raw_text=wq.ocr_raw_text,
question_text=wq.question_text,
@@ -60,16 +61,25 @@ def _process_wrong_question(question_id: uuid.UUID):
subject_name = wq.subject.name if wq.subject else "综合"
school_level = wq.student.school_level if wq.student else None
olympiad = wq.category == WrongQuestionCategory.olympiad
ai_cfg = llm_service.load_ai_config(db)
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
question_text = loop.run_until_complete(
ollama_service.format_question(subject_name, ocr_text, school_level)
llm_service.format_question(ai_cfg, subject_name, ocr_text, school_level)
)
solution_text = loop.run_until_complete(
ollama_service.generate_solution(subject_name, question_text, school_level)
llm_service.generate_solution(
ai_cfg,
subject_name,
question_text,
school_level,
olympiad=olympiad,
)
)
wq.question_text = question_text
wq.solution_text = solution_text
@@ -88,6 +98,7 @@ def _process_wrong_question(question_id: uuid.UUID):
def list_wrong_questions(
student_id: uuid.UUID,
subject_id: int | None = Query(None),
category: WrongQuestionCategoryEnum | None = Query(None),
q: str | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
@@ -100,6 +111,8 @@ def list_wrong_questions(
)
if subject_id is not None:
query = query.filter(WrongQuestion.subject_id == subject_id)
if category is not None:
query = query.filter(WrongQuestion.category == category.value)
if q:
pattern = f"%{q}%"
query = query.filter(
@@ -121,6 +134,7 @@ async def upload_wrong_question(
background_tasks: BackgroundTasks,
subject_id: int = Form(...),
file: UploadFile = File(...),
category: WrongQuestionCategoryEnum = Form(WrongQuestionCategoryEnum.regular),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -137,6 +151,7 @@ async def upload_wrong_question(
student_id=student_id,
subject_id=subject_id,
image_path="",
category=WrongQuestionCategory(category.value),
status=WrongQuestionStatus.pending,
)
db.add(wq)
@@ -270,21 +285,27 @@ async def regenerate_solution(
subject_name = wq.subject.name if wq.subject else "综合"
school_level = wq.student.school_level if wq.student else None
olympiad = wq.category == WrongQuestionCategory.olympiad
question_text = wq.question_text or wq.ocr_raw_text or ""
ai_cfg = llm_service.load_ai_config(db)
try:
if not wq.question_text and wq.ocr_raw_text:
wq.question_text = await ollama_service.format_question(
subject_name, wq.ocr_raw_text, school_level
wq.question_text = await llm_service.format_question(
ai_cfg, subject_name, wq.ocr_raw_text, school_level
)
question_text = wq.question_text
wq.solution_text = await ollama_service.generate_solution(
subject_name, question_text, school_level
wq.solution_text = await llm_service.generate_solution(
ai_cfg,
subject_name,
question_text,
school_level,
olympiad=olympiad,
)
wq.status = WrongQuestionStatus.solved
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Ollama 调用失败: {exc}"
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"AI 调用失败: {exc}"
) from exc
db.commit()
+23
View File
@@ -18,6 +18,16 @@ class WrongQuestionStatusEnum(str, Enum):
failed = "failed"
class WrongQuestionCategoryEnum(str, Enum):
regular = "regular"
olympiad = "olympiad"
class AIProviderEnum(str, Enum):
ollama = "ollama"
openai = "openai"
class SchoolLevelEnum(str, Enum):
junior_high = "junior_high"
senior_high = "senior_high"
@@ -58,6 +68,12 @@ class PublicSettingsOut(BaseModel):
class SystemSettingsOut(BaseModel):
registration_enabled: bool
ai_provider: AIProviderEnum
ollama_base_url: str | None = None
ollama_model: str | None = None
openai_base_url: str | None = None
openai_model: str | None = None
openai_api_key_set: bool = False
updated_at: datetime
model_config = {"from_attributes": True}
@@ -65,6 +81,12 @@ class SystemSettingsOut(BaseModel):
class SystemSettingsUpdate(BaseModel):
registration_enabled: bool | None = None
ai_provider: AIProviderEnum | None = None
ollama_base_url: str | None = None
ollama_model: str | None = None
openai_base_url: str | None = None
openai_model: str | None = None
openai_api_key: str | None = None
class AdminProfileUpdate(BaseModel):
@@ -207,6 +229,7 @@ class WrongQuestionOut(BaseModel):
student_id: UUID
subject_id: int
subject_name: str | None = None
category: WrongQuestionCategoryEnum
image_path: str
ocr_raw_text: str | None
question_text: str | None
+169
View File
@@ -0,0 +1,169 @@
import enum
import httpx
from sqlalchemy.orm import Session
from app.core.config import settings as app_settings
from app.models.user import SchoolLevel, SystemSettings
from app.services.school_level import school_level_label
CURRICULUM_JUNIOR = """初中课程标准:代数、几何(全等/相似/勾股)、一次函数与简单二次函数、基础概率统计。
严禁使用:高中导数、向量、解析几何、排列组合进阶、复数、微积分、大学线性代数等。"""
CURRICULUM_SENIOR = """高中课程标准:课内函数、三角、向量、解析几何、概率统计、导数(课内范围)等。
严禁使用:大学数学分析、抽象代数、高等几何、超出课内的竞赛高阶技巧。"""
CURRICULUM_JUNIOR_OLYMPIAD = """初中奥数培优范围:整数/整除、因数分解、简单数论、代数恒等变形、几何辅助线与全等相似、简单组合计数。
严禁使用:高中及以上方法(导数、向量、解析几何、微积分、复数运算等)。"""
CURRICULUM_SENIOR_OLYMPIAD = """高中奥数/竞赛入门范围:课内知识+常规竞赛技巧(不等式、构造、归纳、简单数论等)。
严禁使用:大学数学、超出高中奥数培优体系的 IMO 高阶理论。"""
def _curriculum_block(level: SchoolLevel | str | None, olympiad: bool) -> str:
label = school_level_label(level)
is_senior = level == SchoolLevel.senior_high or level == "senior_high"
if olympiad:
return CURRICULUM_SENIOR_OLYMPIAD if is_senior else CURRICULUM_JUNIOR_OLYMPIAD
return CURRICULUM_SENIOR if is_senior else CURRICULUM_JUNIOR
QUESTION_PROMPT = """你是一位{stage}老师。以下是从试卷 OCR 识别出的文字,可能含有噪声。
科目:{subject}
请整理出清晰的题目内容(保留题号、选项、公式),只输出题目正文,不要解释。
OCR 原文:
{ocr_text}
"""
SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请为以下题目给出详细解法。
【学段要求 — 严禁超纲】
{curriculum}
题目:
{question_text}
请按以下结构输出:
1. 考点分析({stage}范围内)
2. 解题步骤(逐步推导,每步说明依据)
3. 易错点提醒
4. 若必须使用超纲方法才能解,请改用{stage}可理解的方法重新解答,不得输出超纲解法。
"""
OLYMPIAD_SOLUTION_PROMPT = """你是一位{stage}奥数教练。请为以下奥数题给出详细解题思路与完整解答。
【奥数学段要求 — 严禁超纲】
{curriculum}
题目:
{question_text}
请按以下结构输出:
1. 题型与思路切入点({stage}奥数常见技巧)
2. 详细解答步骤
3. 关键技巧总结(仅限{stage}奥数范围)
4. 严禁使用超出上述范围的方法;若题目过难,给出{stage}可接受的培优思路。
"""
class AIConfig:
def __init__(
self,
provider: str,
ollama_base_url: str,
ollama_model: str,
openai_base_url: str,
openai_model: str,
openai_api_key: str | None,
):
self.provider = provider
self.ollama_base_url = ollama_base_url
self.ollama_model = ollama_model
self.openai_base_url = openai_base_url
self.openai_model = openai_model
self.openai_api_key = openai_api_key
def load_ai_config(db: Session) -> AIConfig:
row = db.get(SystemSettings, 1)
if row is None:
return AIConfig(
provider="ollama",
ollama_base_url=app_settings.OLLAMA_BASE_URL,
ollama_model=app_settings.OLLAMA_MODEL,
openai_base_url=app_settings.OPENAI_BASE_URL,
openai_model=app_settings.OPENAI_MODEL,
openai_api_key=None,
)
return AIConfig(
provider=row.ai_provider or "ollama",
ollama_base_url=row.ollama_base_url or app_settings.OLLAMA_BASE_URL,
ollama_model=row.ollama_model or app_settings.OLLAMA_MODEL,
openai_base_url=row.openai_base_url or app_settings.OPENAI_BASE_URL,
openai_model=row.openai_model or app_settings.OPENAI_MODEL,
openai_api_key=row.openai_api_key,
)
async def _ollama_generate(prompt: str, cfg: AIConfig) -> str:
url = f"{cfg.ollama_base_url.rstrip('/')}/api/generate"
payload = {"model": cfg.ollama_model, "prompt": prompt, "stream": False}
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
return (response.json().get("response") or "").strip()
async def _openai_generate(prompt: str, cfg: AIConfig) -> str:
if not cfg.openai_api_key:
raise ValueError("未配置 OpenAI API Key")
url = f"{cfg.openai_base_url.rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {cfg.openai_api_key}"}
payload = {
"model": cfg.openai_model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
}
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
return (data["choices"][0]["message"]["content"] or "").strip()
async def generate_text(prompt: str, cfg: AIConfig) -> str:
if cfg.provider == "openai":
return await _openai_generate(prompt, cfg)
return await _ollama_generate(prompt, cfg)
async def format_question(
cfg: AIConfig,
subject: str,
ocr_text: str,
school_level=None,
) -> str:
stage = school_level_label(school_level)
prompt = QUESTION_PROMPT.format(stage=stage, subject=subject, ocr_text=ocr_text)
return await generate_text(prompt, cfg)
async def generate_solution(
cfg: AIConfig,
subject: str,
question_text: str,
school_level=None,
*,
olympiad: bool = False,
) -> str:
stage = school_level_label(school_level)
curriculum = _curriculum_block(school_level, olympiad)
template = OLYMPIAD_SOLUTION_PROMPT if olympiad else SOLUTION_PROMPT
prompt = template.format(
stage=stage,
subject=subject,
curriculum=curriculum,
question_text=question_text,
)
return await generate_text(prompt, cfg)
+34 -2
View File
@@ -7,7 +7,8 @@ from app.core.database import engine
def run_migrations() -> None:
"""Apply lightweight schema updates for existing databases."""
inspector = inspect(engine)
if "students" not in inspector.get_table_names():
tables = set(inspector.get_table_names())
if "students" not in tables:
return
columns = {col["name"] for col in inspector.get_columns("students")}
@@ -20,7 +21,7 @@ def run_migrations() -> None:
)
)
if "users" in inspector.get_table_names():
if "users" in tables:
user_columns = {col["name"] for col in inspector.get_columns("users")}
if "is_superuser" not in user_columns:
with engine.begin() as conn:
@@ -33,3 +34,34 @@ def run_migrations() -> None:
f"WHERE username = '{settings.ADMIN_DEFAULT_USERNAME}'"
)
)
if "wrong_questions" in tables:
wq_columns = {col["name"] for col in inspector.get_columns("wrong_questions")}
if "category" not in wq_columns:
with engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE wrong_questions ADD COLUMN category VARCHAR(32) "
"NOT NULL DEFAULT 'regular'"
)
)
if "system_settings" in tables:
ss_columns = {col["name"] for col in inspector.get_columns("system_settings")}
alters: list[str] = []
if "ai_provider" not in ss_columns:
alters.append("ADD COLUMN ai_provider VARCHAR(16) NOT NULL DEFAULT 'ollama'")
if "ollama_base_url" not in ss_columns:
alters.append("ADD COLUMN ollama_base_url VARCHAR(256)")
if "ollama_model" not in ss_columns:
alters.append("ADD COLUMN ollama_model VARCHAR(128)")
if "openai_base_url" not in ss_columns:
alters.append("ADD COLUMN openai_base_url VARCHAR(256)")
if "openai_model" not in ss_columns:
alters.append("ADD COLUMN openai_model VARCHAR(128)")
if "openai_api_key" not in ss_columns:
alters.append("ADD COLUMN openai_api_key VARCHAR(512)")
if alters:
with engine.begin() as conn:
for clause in alters:
conn.execute(text(f"ALTER TABLE system_settings {clause}"))
+3 -45
View File
@@ -1,47 +1,5 @@
import httpx
"""Backward-compatible wrapper; prefer app.services.llm."""
from app.core.config import settings
from app.services.school_level import school_level_label
from app.services.llm import format_question, generate_solution, load_ai_config
QUESTION_PROMPT = """你是一位{stage}老师。以下是从试卷 OCR 识别出的文字,可能含有噪声。
科目:{subject}
请整理出清晰的题目内容(保留题号、选项、公式),只输出题目正文,不要解释。
OCR 原文:
{ocr_text}
"""
SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请为以下题目给出详细解法。
要求:步骤清晰,语言适合{stage}学生理解,指出考点和易错点。
题目:
{question_text}
"""
async def ollama_generate(prompt: str) -> str:
url = f"{settings.OLLAMA_BASE_URL.rstrip('/')}/api/generate"
payload = {
"model": settings.OLLAMA_MODEL,
"prompt": prompt,
"stream": False,
}
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
data = response.json()
return (data.get("response") or "").strip()
async def format_question(subject: str, ocr_text: str, school_level=None) -> str:
stage = school_level_label(school_level)
prompt = QUESTION_PROMPT.format(stage=stage, subject=subject, ocr_text=ocr_text)
return await ollama_generate(prompt)
async def generate_solution(subject: str, question_text: str, school_level=None) -> str:
stage = school_level_label(school_level)
prompt = SOLUTION_PROMPT.format(
stage=stage, subject=subject, question_text=question_text
)
return await ollama_generate(prompt)
__all__ = ["format_question", "generate_solution", "load_ai_config"]
+24 -2
View File
@@ -26,8 +26,30 @@ def seed_subjects(db: Session) -> None:
def seed_admin_and_settings(db: Session) -> None:
if db.get(SystemSettings, 1) is None:
db.add(SystemSettings(id=1, registration_enabled=True))
row = db.get(SystemSettings, 1)
if row is None:
db.add(
SystemSettings(
id=1,
registration_enabled=True,
ai_provider="ollama",
ollama_base_url=settings.OLLAMA_BASE_URL,
ollama_model=settings.OLLAMA_MODEL,
openai_base_url=settings.OPENAI_BASE_URL,
openai_model=settings.OPENAI_MODEL,
)
)
else:
if not row.ollama_base_url:
row.ollama_base_url = settings.OLLAMA_BASE_URL
if not row.ollama_model:
row.ollama_model = settings.OLLAMA_MODEL
if not row.openai_base_url:
row.openai_base_url = settings.OPENAI_BASE_URL
if not row.openai_model:
row.openai_model = settings.OPENAI_MODEL
if not row.ai_provider:
row.ai_provider = "ollama"
admin = db.query(User).filter(User.username == settings.ADMIN_DEFAULT_USERNAME).first()
if admin is None: