新增作文区与 AI 解读开关,修复 CSV 导出。
系统设置可关闭成绩复盘 AI;学生详情增加作文区(OCR/手动题目、方案与范文、历史与 MD 下载);导出改用 UTF-8 文件名响应。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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}'
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user