Initial commit: secondary school grade archive system.

Add FastAPI/React app with Docker deployment, Ubuntu one-click install, and docs for junior/senior high score tracking and mistake bank.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 11:18:58 +08:00
commit e329d3398a
76 changed files with 8506 additions and 0 deletions
View File
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import inspect, text
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():
return
columns = {col["name"] for col in inspector.get_columns("students")}
if "school_level" not in columns:
with engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE students ADD COLUMN school_level VARCHAR(32) "
"NOT NULL DEFAULT 'junior_high'"
)
)
+40
View File
@@ -0,0 +1,40 @@
from pathlib import Path
from app.core.config import settings
_ocr_engine = None
def get_ocr_engine():
global _ocr_engine
if _ocr_engine is None:
from paddleocr import PaddleOCR
_ocr_engine = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False)
return _ocr_engine
def run_ocr(image_path: str) -> str:
engine = get_ocr_engine()
result = engine.ocr(image_path, cls=True)
if not result or not result[0]:
return ""
lines = []
for line in result[0]:
if line and len(line) >= 2:
text = line[1][0]
if text:
lines.append(text)
return "\n".join(lines)
def save_upload_file(user_id: str, question_id: str, filename: str, content: bytes) -> str:
ext = Path(filename).suffix.lower() or ".jpg"
if ext not in {".jpg", ".jpeg", ".png", ".webp"}:
ext = ".jpg"
user_dir = Path(settings.UPLOAD_DIR) / user_id
user_dir.mkdir(parents=True, exist_ok=True)
rel_path = f"{user_id}/{question_id}{ext}"
full_path = Path(settings.UPLOAD_DIR) / rel_path
full_path.write_bytes(content)
return rel_path
+47
View File
@@ -0,0 +1,47 @@
import httpx
from app.core.config import settings
from app.services.school_level import school_level_label
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)
+17
View File
@@ -0,0 +1,17 @@
from app.models.user import SchoolLevel
SCHOOL_LEVEL_LABELS: dict[SchoolLevel, str] = {
SchoolLevel.junior_high: "初中",
SchoolLevel.senior_high: "高中",
}
def school_level_label(level: SchoolLevel | str | None) -> str:
if level is None:
return "初中"
if isinstance(level, str):
try:
level = SchoolLevel(level)
except ValueError:
return "初中"
return SCHOOL_LEVEL_LABELS.get(level, "初中")
+66
View File
@@ -0,0 +1,66 @@
from sqlalchemy.orm import Session, joinedload
from app.core.config import settings
from app.models.user import ExamRecord, Subject, SubjectScore
from app.schemas import ExamTypeEnum, TrendPoint, TrendResponse
def build_trend(db: Session, student_id, subject_id: int) -> TrendResponse:
subject = db.get(Subject, subject_id)
if subject is None:
raise ValueError("科目不存在")
scores = (
db.query(SubjectScore)
.join(ExamRecord)
.options(joinedload(SubjectScore.exam_record))
.filter(
ExamRecord.student_id == student_id,
SubjectScore.subject_id == subject_id,
)
.order_by(ExamRecord.exam_date.asc(), ExamRecord.created_at.asc())
.all()
)
threshold = settings.FLUCTUATION_THRESHOLD
points: list[TrendPoint] = []
prev_ratio: float | None = None
for score in scores:
exam = score.exam_record
ratio = float(score.ratio)
delta = None if prev_ratio is None else ratio - prev_ratio
direction = None
is_volatile = False
if delta is not None:
if delta > 0:
direction = "up"
elif delta < 0:
direction = "down"
else:
direction = "flat"
is_volatile = abs(delta) >= threshold
points.append(
TrendPoint(
exam_id=exam.id,
exam_type=ExamTypeEnum(exam.exam_type.value),
exam_date=exam.exam_date,
title=exam.title,
ratio=ratio,
ratio_percent=round(ratio * 100, 2),
delta=delta,
delta_percent=round(delta * 100, 2) if delta is not None else None,
is_volatile=is_volatile,
direction=direction,
)
)
prev_ratio = ratio
return TrendResponse(
subject_id=subject_id,
subject_name=subject.name,
threshold=threshold,
points=points,
)
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy.orm import Session
from app.models.user import Subject
DEFAULT_SUBJECTS = [
"语文",
"数学",
"英语",
"物理",
"化学",
"生物",
"历史",
"地理",
"政治",
]
def seed_subjects(db: Session) -> None:
existing = {s.name for s in db.query(Subject).all()}
for name in DEFAULT_SUBJECTS:
if name not in existing:
db.add(Subject(name=name))
db.commit()
+13
View File
@@ -0,0 +1,13 @@
import uuid
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models.user import Student
def get_student_for_user(db: Session, student_id: uuid.UUID, user_id: uuid.UUID) -> Student:
student = db.get(Student, student_id)
if student is None or student.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="学生不存在")
return student