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:
@@ -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'"
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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, "初中")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user