新增作文区与 AI 解读开关,修复 CSV 导出。

系统设置可关闭成绩复盘 AI;学生详情增加作文区(OCR/手动题目、方案与范文、历史与 MD 下载);导出改用 UTF-8 文件名响应。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 17:42:17 +08:00
parent aaa08cdf38
commit 1cb3c7fad5
20 changed files with 1441 additions and 555 deletions
+2 -1
View File
@@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
from app.core.config import settings
from app.core.database import Base, SessionLocal, engine
from app.routers import admin, auth, exams, export, settings as settings_router, students, subjects, wrong_questions
from app.routers import admin, auth, compositions, exams, export, settings as settings_router, students, subjects, wrong_questions
from app.services import ocr as ocr_service
from app.services.migrate import run_migrations
from app.services.seed import seed_admin_and_settings, seed_subjects
@@ -57,6 +57,7 @@ app.include_router(students.router, prefix="/api")
app.include_router(subjects.router, prefix="/api")
app.include_router(exams.router, prefix="/api")
app.include_router(wrong_questions.router, prefix="/api")
app.include_router(compositions.router, prefix="/api")
app.include_router(export.router, prefix="/api")
+41
View File
@@ -27,6 +27,18 @@ class WrongQuestionCategory(str, enum.Enum):
olympiad = "olympiad"
class CompositionStatus(str, enum.Enum):
pending = "pending"
generating = "generating"
done = "done"
failed = "failed"
class CompositionInputMode(str, enum.Enum):
manual = "manual"
ocr = "ocr"
class AIProvider(str, enum.Enum):
ollama = "ollama"
openai = "openai"
@@ -73,6 +85,9 @@ class Student(Base):
wrong_questions: Mapped[list["WrongQuestion"]] = relationship(
back_populates="student", cascade="all, delete-orphan"
)
compositions: Mapped[list["Composition"]] = relationship(
back_populates="student", cascade="all, delete-orphan"
)
class Subject(Base):
@@ -150,11 +165,37 @@ class WrongQuestion(Base):
subject: Mapped["Subject"] = relationship(back_populates="wrong_questions")
class Composition(Base):
__tablename__ = "compositions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
student_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("students.id"), index=True)
topic: Mapped[str] = mapped_column(Text)
input_mode: Mapped[CompositionInputMode] = mapped_column(
Enum(CompositionInputMode), default=CompositionInputMode.manual
)
writing_plan: Mapped[str | None] = mapped_column(Text, nullable=True)
sample_essay: Mapped[str | None] = mapped_column(Text, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[CompositionStatus] = mapped_column(
Enum(CompositionStatus), default=CompositionStatus.pending
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
student: Mapped["Student"] = relationship(back_populates="compositions")
class SystemSettings(Base):
__tablename__ = "system_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
registration_enabled: Mapped[bool] = mapped_column(default=True)
ai_review_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)
+3
View File
@@ -26,6 +26,7 @@ router = APIRouter(prefix="/admin", tags=["admin"])
def settings_to_out(row: SystemSettings) -> SystemSettingsOut:
return SystemSettingsOut(
registration_enabled=row.registration_enabled,
ai_review_enabled=getattr(row, "ai_review_enabled", True),
ai_provider=AIProviderEnum(row.ai_provider or "ollama"),
ollama_base_url=row.ollama_base_url,
ollama_model=row.ollama_model,
@@ -64,6 +65,8 @@ 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_review_enabled is not None:
row.ai_review_enabled = data.ai_review_enabled
if data.ai_provider is not None:
row.ai_provider = data.ai_provider.value
if data.ollama_base_url is not None:
+261
View File
@@ -0,0 +1,261 @@
import re
import uuid
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
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 Composition, CompositionInputMode, CompositionStatus, SystemSettings, User
from app.schemas import CompositionCreate, CompositionInputModeEnum, CompositionOcrOut, CompositionOut
from app.services import llm as llm_service
from app.services import ocr as ocr_service
from app.services.student_access import get_student_for_user
router = APIRouter(tags=["compositions"])
def _ocr_service_url(db: Session) -> str | None:
row = db.get(SystemSettings, 1)
if row and row.ocr_service_url:
return row.ocr_service_url.strip() or None
return ocr_service.resolve_ocr_service_url()
def _to_out(item: Composition) -> CompositionOut:
return CompositionOut(
id=item.id,
student_id=item.student_id,
topic=item.topic,
input_mode=CompositionInputModeEnum(item.input_mode.value),
writing_plan=item.writing_plan,
sample_essay=item.sample_essay,
error_message=item.error_message,
status=item.status,
created_at=item.created_at,
updated_at=item.updated_at,
)
def _safe_filename(name: str) -> str:
cleaned = re.sub(r'[\\/:*?"<>|]', "_", name).strip() or "composition"
return cleaned[:40]
async def _generate_composition(composition_id: uuid.UUID):
db = SessionLocal()
try:
item = (
db.query(Composition)
.options(joinedload(Composition.student))
.filter(Composition.id == composition_id)
.first()
)
if item is None:
return
student = item.student
item.status = CompositionStatus.generating
item.error_message = None
db.commit()
ai_cfg = llm_service.load_ai_config(db)
try:
plan, essay = await llm_service.generate_composition(
ai_cfg,
item.topic,
student.school_level if student else None,
student.grade if student else None,
)
item.writing_plan = plan or None
item.sample_essay = essay or None
if not item.writing_plan and not item.sample_essay:
raise ValueError("AI 未返回有效内容")
item.status = CompositionStatus.done
item.error_message = None
except Exception as exc:
item.status = CompositionStatus.failed
item.error_message = str(exc)[:500]
item.updated_at = datetime.now(timezone.utc)
db.commit()
finally:
db.close()
@router.get("/students/{student_id}/compositions", response_model=list[CompositionOut])
def list_compositions(
student_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
get_student_for_user(db, student_id, current_user.id)
items = (
db.query(Composition)
.filter(Composition.student_id == student_id)
.order_by(Composition.created_at.desc())
.all()
)
return [_to_out(item) for item in items]
@router.post(
"/students/{student_id}/compositions",
response_model=CompositionOut,
status_code=status.HTTP_201_CREATED,
)
async def create_composition(
student_id: uuid.UUID,
data: CompositionCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
get_student_for_user(db, student_id, current_user.id)
item = Composition(
student_id=student_id,
topic=data.topic.strip(),
input_mode=CompositionInputMode(data.input_mode.value),
status=CompositionStatus.pending,
)
db.add(item)
db.commit()
db.refresh(item)
background_tasks.add_task(_generate_composition, item.id)
return _to_out(item)
@router.post("/students/{student_id}/compositions/ocr", response_model=CompositionOcrOut)
async def ocr_composition_topic(
student_id: uuid.UUID,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
get_student_for_user(db, student_id, current_user.id)
content = await file.read()
if len(content) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件超过10MB限制")
tmp_dir = Path(settings.UPLOAD_DIR) / "ocr-tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
tmp_path = tmp_dir / f"{uuid.uuid4()}.jpg"
tmp_path.write_bytes(content)
ocr_url = _ocr_service_url(db)
try:
with ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(ocr_service.run_ocr_with_regions, str(tmp_path), ocr_url)
result = future.result(timeout=settings.OCR_TIMEOUT_SECONDS)
text = (result.get("text") or "").strip()
if not text:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OCR 未识别到文字")
return CompositionOcrOut(text=text)
except FuturesTimeout:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"OCR 识别超时(>{settings.OCR_TIMEOUT_SECONDS}秒)",
) from None
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"OCR 识别失败:{exc}",
) from exc
finally:
tmp_path.unlink(missing_ok=True)
@router.get("/compositions/{composition_id}", response_model=CompositionOut)
def get_composition(
composition_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = (
db.query(Composition)
.join(Composition.student)
.filter(Composition.id == composition_id)
.first()
)
if item is None or item.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作文记录不存在")
return _to_out(item)
@router.post("/compositions/{composition_id}/regenerate", response_model=CompositionOut)
async def regenerate_composition(
composition_id: uuid.UUID,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = (
db.query(Composition)
.join(Composition.student)
.filter(Composition.id == composition_id)
.first()
)
if item is None or item.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作文记录不存在")
item.status = CompositionStatus.pending
item.error_message = None
item.writing_plan = None
item.sample_essay = None
db.commit()
background_tasks.add_task(_generate_composition, item.id)
db.refresh(item)
return _to_out(item)
@router.get("/compositions/{composition_id}/download")
def download_composition(
composition_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = (
db.query(Composition)
.join(Composition.student)
.filter(Composition.id == composition_id)
.first()
)
if item is None or item.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作文记录不存在")
if item.status != CompositionStatus.done:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="作文尚未生成完成")
body = llm_service.composition_markdown(item.topic, item.writing_plan, item.sample_essay)
filename = f"{_safe_filename(item.topic)}.md"
from urllib.parse import quote
encoded = quote(filename)
return Response(
content=body.encode("utf-8"),
media_type="text/markdown; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="composition.md"; filename*=UTF-8\'\'{encoded}'
},
)
@router.delete("/compositions/{composition_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_composition(
composition_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = (
db.query(Composition)
.join(Composition.student)
.filter(Composition.id == composition_id)
.first()
)
if item is None or item.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作文记录不存在")
db.delete(item)
db.commit()
+5 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session, joinedload
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import ExamRecord, SubjectScore, User
from app.models.user import ExamRecord, SubjectScore, SystemSettings, User
from app.schemas import (
ExamCreate,
ExamOut,
@@ -322,6 +322,10 @@ async def review_insight(
current_user: User = Depends(get_current_user),
):
student = get_student_for_user(db, student_id, current_user.id)
settings_row = db.get(SystemSettings, 1)
if settings_row is not None and not getattr(settings_row, "ai_review_enabled", True):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="AI 复盘解读已在系统设置中关闭")
exams = (
db.query(ExamRecord)
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
+12 -7
View File
@@ -1,9 +1,10 @@
import csv
import io
import uuid
from urllib.parse import quote
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from fastapi.responses import Response
from sqlalchemy.orm import Session, joinedload
from app.core.database import get_db
@@ -34,10 +35,11 @@ def export_scores_csv(
writer.writerow(["考试日期", "考试类型", "标题", "科目", "总分", "得分", "占比"])
type_map = {"weekly": "周考", "monthly": "月考", "final": "期末"}
for exam in exams:
exam_type = exam.exam_type.value if hasattr(exam.exam_type, "value") else str(exam.exam_type)
for score in exam.scores:
writer.writerow([
exam.exam_date.isoformat(),
type_map.get(exam.exam_type.value, exam.exam_type.value),
type_map.get(exam_type, exam_type),
exam.title or "",
score.subject.name if score.subject else "",
float(score.total_score),
@@ -45,10 +47,13 @@ def export_scores_csv(
f"{float(score.ratio) * 100:.2f}%",
])
output.seek(0)
content = output.getvalue().encode("utf-8-sig")
filename = f"{student.name}_scores.csv"
return StreamingResponse(
iter([output.getvalue().encode("utf-8-sig")]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
encoded = quote(filename)
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="scores.csv"; filename*=UTF-8\'\'{encoded}'
},
)
+12 -2
View File
@@ -2,8 +2,9 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.user import SystemSettings
from app.schemas import PublicSettingsOut
from app.core.deps import get_current_user
from app.models.user import SystemSettings, User
from app.schemas import AppFeaturesOut, PublicSettingsOut
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -22,3 +23,12 @@ def get_or_create_settings(db: Session) -> SystemSettings:
def public_settings(db: Session = Depends(get_db)):
row = get_or_create_settings(db)
return PublicSettingsOut(registration_enabled=row.registration_enabled)
@router.get("/app-features", response_model=AppFeaturesOut)
def app_features(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
):
row = get_or_create_settings(db)
return AppFeaturesOut(ai_review_enabled=getattr(row, "ai_review_enabled", True))
+42
View File
@@ -76,8 +76,13 @@ class PublicSettingsOut(BaseModel):
registration_enabled: bool
class AppFeaturesOut(BaseModel):
ai_review_enabled: bool
class SystemSettingsOut(BaseModel):
registration_enabled: bool
ai_review_enabled: bool = True
ai_provider: AIProviderEnum
ollama_base_url: str | None = None
ollama_model: str | None = None
@@ -92,6 +97,7 @@ class SystemSettingsOut(BaseModel):
class SystemSettingsUpdate(BaseModel):
registration_enabled: bool | None = None
ai_review_enabled: bool | None = None
ai_provider: AIProviderEnum | None = None
ollama_base_url: str | None = None
ollama_model: str | None = None
@@ -293,3 +299,39 @@ class WrongQuestionUpdate(BaseModel):
solution_approach: str | None = None
solution_text: str | None = None
subject_id: int | None = None
class CompositionStatusEnum(str, Enum):
pending = "pending"
generating = "generating"
done = "done"
failed = "failed"
class CompositionInputModeEnum(str, Enum):
manual = "manual"
ocr = "ocr"
class CompositionCreate(BaseModel):
topic: str = Field(..., min_length=1, max_length=4000)
input_mode: CompositionInputModeEnum = CompositionInputModeEnum.manual
class CompositionOcrOut(BaseModel):
text: str
class CompositionOut(BaseModel):
id: UUID
student_id: UUID
topic: str
input_mode: CompositionInputModeEnum
writing_plan: str | None = None
sample_essay: str | None = None
error_message: str | None = None
status: CompositionStatusEnum
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+78 -1
View File
@@ -306,4 +306,81 @@ async def generate_review_insight(
careless_hint=careless_hint,
subject_hints=subject_hints,
)
return await generate_text(prompt, cfg, temperature=0.2)
return await generate_text(prompt, cfg, temperature=0.2)
CURRICULUM_CHINESE_JUNIOR = """初中作文:记叙文、写人记事、简单议论文为主,通常 600-800 字。
语言平实素材来自课内与日常生活禁止成人化腔调与超纲典故堆砌"""
CURRICULUM_CHINESE_SENIOR = """高中作文:记叙、议论、材料作文为主,通常 800-1000 字。
可适度展开论证仍须符合课内要求禁止大学论文式写法与超纲理论"""
COMPOSITION_PROMPT = """你是一位{stage}语文老师,正在辅导{grade_text}学生完成作文。
学段年级 严禁超纲
{curriculum}
作文题目
{topic}
请严格按以下 Markdown 结构输出不要增加其他一级标题
## 写作方案
审题立意结构提纲段落安排可用素材方向分条列出贴合{grade_text}水平
## 范文
完整作文一篇字数与语言风格必须符合{grade_text}课内要求禁止超纲
注意范文必须是可直接参考的学生习作水准不要写成评论或教案
"""
def _chinese_curriculum(level, grade: str | None) -> str:
is_senior = level == SchoolLevel.senior_high or level == "senior_high"
return CURRICULUM_CHINESE_SENIOR if is_senior else CURRICULUM_CHINESE_JUNIOR
def _grade_text(grade: str | None) -> str:
if grade and grade.strip():
return grade.strip()
return "该学段学生"
def split_composition_sections(text: str) -> tuple[str, str]:
import re
text = text.strip()
if "## 范文" not in text:
return text.replace("## 写作方案", "").strip(), ""
parts = re.split(r"\n##\s*范文\s*\n", text, maxsplit=1)
plan = parts[0].replace("## 写作方案", "").strip()
essay = parts[1].strip() if len(parts) > 1 else ""
return plan, essay
async def generate_composition(
cfg: AIConfig,
topic: str,
school_level=None,
grade: str | None = None,
) -> tuple[str, str]:
stage = school_level_label(school_level)
grade_text = _grade_text(grade)
curriculum = _chinese_curriculum(school_level, grade)
prompt = COMPOSITION_PROMPT.format(
stage=stage,
grade_text=grade_text,
curriculum=curriculum,
topic=topic.strip(),
)
full = await generate_text(prompt, cfg, temperature=0.35)
return split_composition_sections(full)
def composition_markdown(topic: str, writing_plan: str | None, sample_essay: str | None) -> str:
parts = [f"# 作文题目\n\n{topic.strip()}", ""]
if writing_plan:
parts.extend(["## 写作方案", "", writing_plan.strip(), ""])
if sample_essay:
parts.extend(["## 范文", "", sample_essay.strip(), ""])
return "\n".join(parts).strip() + "\n"
+10
View File
@@ -91,3 +91,13 @@ def run_migrations() -> None:
if "review_statuses_json" not in ss_columns:
with engine.begin() as conn:
conn.execute(text("ALTER TABLE subject_scores ADD COLUMN review_statuses_json TEXT"))
if "system_settings" in tables:
ss_columns = {col["name"] for col in inspector.get_columns("system_settings")}
if "ai_review_enabled" not in ss_columns:
with engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE system_settings ADD COLUMN ai_review_enabled BOOLEAN NOT NULL DEFAULT TRUE"
)
)