feat: add versioned backend scoring workflow
This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from redis import Redis
|
||||
from rq import Queue, Retry
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import get_settings
|
||||
from .db import get_db
|
||||
from .exports import export_excel, export_project_pdf, project_result
|
||||
from .models import (AppSetting, AuditLog, Course, CourseVersion, CriterionScore, ExportTask,
|
||||
KnowledgeChunk, KnowledgeDocument, Project, RubricVersion, ScoringBatch,
|
||||
Student, Submission, SubmissionFile, TeacherRevision)
|
||||
from .materialized import sync_course_entities, sync_rubric_entities
|
||||
from .parsing import parse_document
|
||||
from .rag import ingest
|
||||
from .rules import RubricData
|
||||
from .scoring import run_batch
|
||||
from .settings_store import key_configured, model_config, save_model_config
|
||||
from .storage import CATEGORIES, SUPPORTED, directories, scan, secure_path, sha256
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
def found(db, cls, entity_id):
|
||||
item = db.get(cls, entity_id)
|
||||
if not item:
|
||||
raise HTTPException(404, f"{cls.__name__}不存在")
|
||||
return item
|
||||
|
||||
|
||||
def audit(db, action, entity_type, entity_id, details=None):
|
||||
db.add(AuditLog(action=action, entity_type=entity_type, entity_id=entity_id, details=details or {}))
|
||||
|
||||
|
||||
def remove_file_index(db, file):
|
||||
for document in db.scalars(select(KnowledgeDocument).where(KnowledgeDocument.submission_file_id == file.id)).all():
|
||||
db.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
|
||||
db.delete(document)
|
||||
file.parsed, file.parse_status = {}, "pending"
|
||||
|
||||
|
||||
def version_dict(item):
|
||||
return {key: getattr(item, key) for key in ("id", "course_id", "version", "title", "objectives", "indicators", "directions", "prompts", "templates", "published", "created_at")}
|
||||
|
||||
|
||||
def rubric_dict(item):
|
||||
return {key: getattr(item, key) for key in ("id", "course_version_id", "version", "criteria", "weights", "published", "created_at")}
|
||||
|
||||
|
||||
class CourseInput(BaseModel):
|
||||
name: str = Field(min_length=1)
|
||||
code: str = Field(min_length=1)
|
||||
|
||||
|
||||
class VersionInput(BaseModel):
|
||||
title: str = Field(min_length=1)
|
||||
source_version_id: int | None = None
|
||||
|
||||
|
||||
class VersionEdit(BaseModel):
|
||||
title: str = Field(min_length=1)
|
||||
objectives: list[dict]
|
||||
indicators: list[dict]
|
||||
directions: list[dict]
|
||||
prompts: dict[str, str]
|
||||
templates: list[str]
|
||||
|
||||
|
||||
class ImportScan(BaseModel):
|
||||
relative_dir: str
|
||||
|
||||
|
||||
class FileConfirm(BaseModel):
|
||||
relative_path: str
|
||||
category: str
|
||||
sha256: str
|
||||
status: str = "recognized"
|
||||
|
||||
|
||||
class ProjectConfirm(BaseModel):
|
||||
relative_dir: str
|
||||
student_no: str = Field(pattern=r"^\d{5,20}$")
|
||||
name: str = Field(min_length=1)
|
||||
topic: str = Field(min_length=1)
|
||||
direction: str = ""
|
||||
files: list[FileConfirm]
|
||||
|
||||
|
||||
class ImportConfirm(BaseModel):
|
||||
course_version_id: int
|
||||
projects: list[ProjectConfirm]
|
||||
|
||||
|
||||
class BatchInput(BaseModel):
|
||||
course_version_id: int
|
||||
rubric_version_id: int
|
||||
|
||||
|
||||
class ReviewInput(BaseModel):
|
||||
score: float = Field(ge=0)
|
||||
comment: str = Field(min_length=1)
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class ModelSettingsInput(BaseModel):
|
||||
base_url: str | None = None
|
||||
model: str | None = None
|
||||
timeout_seconds: int | None = Field(default=None, ge=10, le=600)
|
||||
concurrency: int | None = Field(default=None, ge=1, le=16)
|
||||
retries: int | None = Field(default=None, ge=0, le=5)
|
||||
api_key: str | None = None
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def dashboard(db: Session = Depends(get_db)):
|
||||
return {"courses": db.scalar(select(func.count(Course.id))), "projects": db.scalar(select(func.count(Project.id))),
|
||||
"batches": db.scalar(select(func.count(ScoringBatch.id))),
|
||||
"needs_attention": db.scalar(select(func.count(CriterionScore.id)).where(CriterionScore.status.in_(["failed", "unscorable"]))) }
|
||||
|
||||
|
||||
@router.get("/courses")
|
||||
def list_courses(db: Session = Depends(get_db)):
|
||||
return [{"id": c.id, "name": c.name, "code": c.code} for c in db.scalars(select(Course).order_by(Course.id))]
|
||||
|
||||
|
||||
@router.post("/courses")
|
||||
def create_course(data: CourseInput, db: Session = Depends(get_db)):
|
||||
if db.scalar(select(Course.id).where((Course.code == data.code) | (Course.name == data.name))):
|
||||
raise HTTPException(409, "课程名称或代码已存在")
|
||||
course = Course(**data.model_dump())
|
||||
db.add(course)
|
||||
db.flush()
|
||||
version = CourseVersion(course_id=course.id, version=1, title="初始草稿", objectives=[], indicators=[], directions=[], prompts={}, templates=[])
|
||||
db.add(version)
|
||||
db.flush()
|
||||
sync_course_entities(db, version)
|
||||
audit(db, "create", "course", course.id)
|
||||
db.commit()
|
||||
return {"id": course.id}
|
||||
|
||||
|
||||
@router.get("/courses/{course_id}/versions")
|
||||
def list_versions(course_id: int, db: Session = Depends(get_db)):
|
||||
found(db, Course, course_id)
|
||||
return [version_dict(v) for v in db.scalars(select(CourseVersion).where(CourseVersion.course_id == course_id).order_by(CourseVersion.version.desc()))]
|
||||
|
||||
|
||||
@router.post("/courses/{course_id}/versions")
|
||||
def copy_version(course_id: int, data: VersionInput, db: Session = Depends(get_db)):
|
||||
found(db, Course, course_id)
|
||||
source = found(db, CourseVersion, data.source_version_id) if data.source_version_id else db.scalar(select(CourseVersion).where(CourseVersion.course_id == course_id).order_by(CourseVersion.version.desc()))
|
||||
if source and source.course_id != course_id:
|
||||
raise HTTPException(400, "源版本不属于课程")
|
||||
next_version = (db.scalar(select(func.max(CourseVersion.version)).where(CourseVersion.course_id == course_id)) or 0) + 1
|
||||
fields = {key: json.loads(json.dumps(getattr(source, key))) if source else ([] if key != "prompts" else {}) for key in ("objectives", "indicators", "directions", "prompts", "templates")}
|
||||
version = CourseVersion(course_id=course_id, version=next_version, title=data.title, published=False, **fields)
|
||||
db.add(version)
|
||||
db.flush()
|
||||
sync_course_entities(db, version)
|
||||
if source:
|
||||
rubric = db.scalar(select(RubricVersion).where(RubricVersion.course_version_id == source.id).order_by(RubricVersion.version.desc()))
|
||||
if rubric:
|
||||
copied = RubricVersion(course_version_id=version.id, version=1, criteria=rubric.criteria, weights=rubric.weights, published=False)
|
||||
db.add(copied)
|
||||
db.flush()
|
||||
sync_rubric_entities(db, copied)
|
||||
audit(db, "copy", "course_version", version.id, {"source_id": source.id if source else None})
|
||||
db.commit()
|
||||
return version_dict(version)
|
||||
|
||||
|
||||
@router.put("/course-versions/{version_id}")
|
||||
def edit_version(version_id: int, data: VersionEdit, db: Session = Depends(get_db)):
|
||||
version = found(db, CourseVersion, version_id)
|
||||
if version.published:
|
||||
raise HTTPException(409, "已发布版本不可修改,请复制新版本")
|
||||
for key, value in data.model_dump().items():
|
||||
setattr(version, key, value)
|
||||
sync_course_entities(db, version)
|
||||
audit(db, "edit", "course_version", version.id)
|
||||
db.commit()
|
||||
return version_dict(version)
|
||||
|
||||
|
||||
@router.post("/course-versions/{version_id}/publish")
|
||||
def publish_version(version_id: int, db: Session = Depends(get_db)):
|
||||
version = found(db, CourseVersion, version_id)
|
||||
if not version.objectives or not version.indicators or not all(k in version.prompts for k in ("facts", "score", "consistency")):
|
||||
raise HTTPException(400, "课程目标、指标点和三个Prompt不能为空")
|
||||
version.published = True
|
||||
audit(db, "publish", "course_version", version.id)
|
||||
db.commit()
|
||||
return version_dict(version)
|
||||
|
||||
|
||||
@router.get("/course-versions/{version_id}/rubrics")
|
||||
def list_rubrics(version_id: int, db: Session = Depends(get_db)):
|
||||
found(db, CourseVersion, version_id)
|
||||
return [rubric_dict(r) for r in db.scalars(select(RubricVersion).where(RubricVersion.course_version_id == version_id).order_by(RubricVersion.version.desc()))]
|
||||
|
||||
|
||||
@router.post("/course-versions/{version_id}/rubrics")
|
||||
def create_rubric(version_id: int, data: RubricData, db: Session = Depends(get_db)):
|
||||
found(db, CourseVersion, version_id)
|
||||
next_version = (db.scalar(select(func.max(RubricVersion.version)).where(RubricVersion.course_version_id == version_id)) or 0) + 1
|
||||
rubric = RubricVersion(course_version_id=version_id, version=next_version, criteria=[c.model_dump() for c in data.criteria], weights=data.weights)
|
||||
db.add(rubric)
|
||||
db.flush()
|
||||
sync_rubric_entities(db, rubric)
|
||||
db.commit()
|
||||
return rubric_dict(rubric)
|
||||
|
||||
|
||||
@router.post("/course-versions/{version_id}/rubrics/copy-from/{source_id}")
|
||||
def create_rubric_from_source(version_id: int, source_id: int, db: Session = Depends(get_db)):
|
||||
found(db, CourseVersion, version_id)
|
||||
source = found(db, RubricVersion, source_id)
|
||||
next_version = (db.scalar(select(func.max(RubricVersion.version)).where(RubricVersion.course_version_id == version_id)) or 0) + 1
|
||||
rubric = RubricVersion(course_version_id=version_id, version=next_version,
|
||||
criteria=json.loads(json.dumps(source.criteria)), weights=dict(source.weights), published=False)
|
||||
db.add(rubric)
|
||||
db.flush()
|
||||
sync_rubric_entities(db, rubric)
|
||||
audit(db, "copy", "rubric", rubric.id, {"source_id": source.id})
|
||||
db.commit()
|
||||
return rubric_dict(rubric)
|
||||
|
||||
|
||||
@router.get("/rubrics/catalog")
|
||||
def rubric_catalog(db: Session = Depends(get_db)):
|
||||
return [{"id": r.id, "course_version_id": r.course_version_id, "version": r.version,
|
||||
"criteria_count": len(r.criteria)} for r in db.scalars(select(RubricVersion).where(RubricVersion.published.is_(True)).order_by(RubricVersion.id))]
|
||||
|
||||
|
||||
@router.get("/rubrics/{rubric_id}")
|
||||
def get_rubric(rubric_id: int, db: Session = Depends(get_db)):
|
||||
return rubric_dict(found(db, RubricVersion, rubric_id))
|
||||
|
||||
|
||||
@router.post("/rubrics/{rubric_id}/copy")
|
||||
def copy_rubric(rubric_id: int, db: Session = Depends(get_db)):
|
||||
source = found(db, RubricVersion, rubric_id)
|
||||
next_version = (db.scalar(select(func.max(RubricVersion.version)).where(RubricVersion.course_version_id == source.course_version_id)) or 0) + 1
|
||||
rubric = RubricVersion(course_version_id=source.course_version_id, version=next_version,
|
||||
criteria=json.loads(json.dumps(source.criteria)), weights=dict(source.weights), published=False)
|
||||
db.add(rubric)
|
||||
db.flush()
|
||||
sync_rubric_entities(db, rubric)
|
||||
audit(db, "copy", "rubric", rubric.id, {"source_id": source.id})
|
||||
db.commit()
|
||||
return rubric_dict(rubric)
|
||||
|
||||
|
||||
@router.put("/rubrics/{rubric_id}")
|
||||
def edit_rubric(rubric_id: int, data: RubricData, db: Session = Depends(get_db)):
|
||||
rubric = found(db, RubricVersion, rubric_id)
|
||||
if rubric.published:
|
||||
raise HTTPException(409, "已发布规则不可修改,请复制新版本")
|
||||
rubric.criteria, rubric.weights = [c.model_dump() for c in data.criteria], data.weights
|
||||
sync_rubric_entities(db, rubric)
|
||||
audit(db, "edit", "rubric", rubric.id)
|
||||
db.commit()
|
||||
return rubric_dict(rubric)
|
||||
|
||||
|
||||
@router.post("/rubrics/{rubric_id}/publish")
|
||||
def publish_rubric(rubric_id: int, db: Session = Depends(get_db)):
|
||||
rubric = found(db, RubricVersion, rubric_id)
|
||||
data = RubricData(criteria=rubric.criteria, weights=rubric.weights)
|
||||
version = found(db, CourseVersion, rubric.course_version_id)
|
||||
objective_codes = {str(item.get("code")) for item in version.objectives}
|
||||
indicator_codes = {str(item.get("code")) for item in version.indicators}
|
||||
for criterion in data.criteria:
|
||||
if not set(criterion.linked_objectives) <= objective_codes or not set(criterion.linked_outcomes) <= indicator_codes:
|
||||
raise HTTPException(400, f"{criterion.criterion_id}关联了课程版本不存在的目标或指标点")
|
||||
rubric.published = True
|
||||
audit(db, "publish", "rubric", rubric.id)
|
||||
db.commit()
|
||||
return rubric_dict(rubric)
|
||||
|
||||
|
||||
@router.get("/storage/directories")
|
||||
def list_directories(relative: str = "."):
|
||||
return directories(relative)
|
||||
|
||||
|
||||
@router.post("/import/scan")
|
||||
def scan_import(data: ImportScan):
|
||||
return scan(data.relative_dir)
|
||||
|
||||
|
||||
@router.post("/import/confirm")
|
||||
def confirm_import(data: ImportConfirm, db: Session = Depends(get_db)):
|
||||
version = found(db, CourseVersion, data.course_version_id)
|
||||
if not version.published:
|
||||
raise HTTPException(400, "请先发布课程版本")
|
||||
imported = []
|
||||
for item in data.projects:
|
||||
folder = secure_path(item.relative_dir)
|
||||
if not folder.is_dir():
|
||||
raise ValueError("学生目录不存在")
|
||||
student = db.scalar(select(Student).where(Student.student_no == item.student_no))
|
||||
if not student:
|
||||
student = Student(student_no=item.student_no, name=item.name)
|
||||
db.add(student)
|
||||
db.flush()
|
||||
else:
|
||||
student.name = item.name
|
||||
project = db.scalar(select(Project).where(Project.course_version_id == version.id, Project.source_dir == item.relative_dir))
|
||||
if not project:
|
||||
project = Project(course_version_id=version.id, student_id=student.id, source_dir=item.relative_dir,
|
||||
topic=item.topic, direction=item.direction)
|
||||
db.add(project)
|
||||
db.flush()
|
||||
else:
|
||||
project.student_id, project.topic, project.direction = student.id, item.topic, item.direction
|
||||
selected = set()
|
||||
selected_paths = set()
|
||||
for file in item.files:
|
||||
path = secure_path(file.relative_path)
|
||||
if path.parent != folder or not path.is_file() or path.suffix.lower() not in SUPPORTED:
|
||||
raise ValueError("材料必须是学生目录内的PDF、DOCX或PPTX文件")
|
||||
if sha256(path) != file.sha256:
|
||||
raise ValueError("文件已变化,请重新扫描")
|
||||
if file.status == "ignored":
|
||||
continue
|
||||
if file.category not in CATEGORIES or file.category in selected or file.relative_path in selected_paths:
|
||||
raise ValueError("请为每类材料只确认一个文件")
|
||||
selected.add(file.category)
|
||||
selected_paths.add(file.relative_path)
|
||||
stat = path.stat()
|
||||
record = db.scalar(select(SubmissionFile).where(SubmissionFile.project_id == project.id, SubmissionFile.relative_path == file.relative_path))
|
||||
if not record:
|
||||
record = SubmissionFile(project_id=project.id, relative_path=file.relative_path, category=file.category,
|
||||
mime_type=mimetypes.guess_type(path.name)[0] or "application/octet-stream",
|
||||
size=stat.st_size, mtime=stat.st_mtime, sha256=file.sha256, recognition_status="recognized")
|
||||
db.add(record)
|
||||
else:
|
||||
if record.sha256 != file.sha256 or record.category != file.category:
|
||||
remove_file_index(db, record)
|
||||
record.category, record.sha256, record.size, record.mtime, record.recognition_status = file.category, file.sha256, stat.st_size, stat.st_mtime, "recognized"
|
||||
for old in db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all():
|
||||
if old.relative_path not in selected_paths:
|
||||
old.recognition_status = "ignored"
|
||||
if old.parse_status == "ready":
|
||||
remove_file_index(db, old)
|
||||
for category in CATEGORIES:
|
||||
submission = db.scalar(select(Submission).where(Submission.project_id == project.id, Submission.category == category))
|
||||
if not submission:
|
||||
submission = Submission(project_id=project.id, category=category, status="missing")
|
||||
db.add(submission)
|
||||
db.flush()
|
||||
submission.status = "submitted" if category in selected else "missing"
|
||||
for file in db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id,
|
||||
SubmissionFile.category == category, SubmissionFile.recognition_status == "recognized")).all():
|
||||
file.submission_id = submission.id
|
||||
audit(db, "import", "project", project.id, {"files": len(selected)})
|
||||
imported.append(project.id)
|
||||
db.commit()
|
||||
return {"project_ids": imported}
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
def list_projects(course_version_id: int | None = None, db: Session = Depends(get_db)):
|
||||
query = select(Project).order_by(Project.id.desc())
|
||||
if course_version_id:
|
||||
query = query.where(Project.course_version_id == course_version_id)
|
||||
result = []
|
||||
for project in db.scalars(query):
|
||||
student = db.get(Student, project.student_id)
|
||||
files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all()
|
||||
result.append({"id": project.id, "student_no": student.student_no, "name": student.name, "topic": project.topic,
|
||||
"course_version_id": project.course_version_id, "status": project.status,
|
||||
"missing": [category for category in CATEGORIES if not any(file.category == category and file.recognition_status == "recognized" for file in files)],
|
||||
"parse_errors": [file.parse_error for file in files if file.parse_error]})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}")
|
||||
def get_project(project_id: int, db: Session = Depends(get_db)):
|
||||
project = found(db, Project, project_id)
|
||||
student = db.get(Student, project.student_id)
|
||||
files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all()
|
||||
return {"id": project.id, "student_no": student.student_no, "name": student.name, "topic": project.topic,
|
||||
"course_version_id": project.course_version_id, "direction": project.direction, "facts": project.facts,
|
||||
"files": [{key: getattr(file, key) for key in ("id", "category", "relative_path", "sha256", "parse_status", "parse_error", "recognition_status")} for file in files]}
|
||||
|
||||
|
||||
@router.get("/knowledge")
|
||||
def knowledge(course_version_id: int, project_id: int | None = None, db: Session = Depends(get_db)):
|
||||
query = select(KnowledgeDocument).where(KnowledgeDocument.course_version_id == course_version_id,
|
||||
KnowledgeDocument.project_id == project_id)
|
||||
return [{"id": doc.id, "title": doc.title, "type": doc.document_type, "status": doc.status,
|
||||
"chunks": db.scalar(select(func.count(KnowledgeChunk.id)).where(KnowledgeChunk.document_id == doc.id))} for doc in db.scalars(query)]
|
||||
|
||||
|
||||
@router.post("/knowledge/ingest-course/{course_version_id}")
|
||||
def ingest_course(course_version_id: int, db: Session = Depends(get_db)):
|
||||
version = found(db, CourseVersion, course_version_id)
|
||||
root = Path("/app/course-docs")
|
||||
processed = get_settings().processed_path / f"course-{course_version_id}"
|
||||
completed, errors = [], {}
|
||||
for filename in version.templates:
|
||||
if Path(filename).name != filename:
|
||||
errors[filename] = "非法课程资料路径"
|
||||
continue
|
||||
path = root / filename
|
||||
if not path.is_file():
|
||||
errors[filename] = "课程资料文件不存在"
|
||||
continue
|
||||
try:
|
||||
category = "stage1" if "阶段报告1" in filename else "stage2" if "阶段报告2" in filename else "stage3" if "阶段报告3" in filename else "course"
|
||||
parsed = parse_document(path, category, processed / hashlib.sha256(filename.encode()).hexdigest()[:16])
|
||||
ingest(db, course_version_id=version.id, project_id=None, submission_file_id=None,
|
||||
title=filename, category=category, source_hash=sha256(path), pages=parsed["pages"])
|
||||
completed.append(filename)
|
||||
except Exception as exc:
|
||||
errors[filename] = str(exc)[:500]
|
||||
rubric = db.scalar(select(RubricVersion).where(RubricVersion.course_version_id == version.id,
|
||||
RubricVersion.published.is_(True)).order_by(RubricVersion.version.desc()))
|
||||
virtual = [
|
||||
("课程目标、毕业要求与选题方向", {"objectives": version.objectives, "indicators": version.indicators,
|
||||
"directions": version.directions}),
|
||||
("已发布评分规则", {"criteria": rubric.criteria, "weights": rubric.weights} if rubric else {}),
|
||||
]
|
||||
for title, payload in virtual:
|
||||
if not payload:
|
||||
continue
|
||||
try:
|
||||
content = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
ingest(db, course_version_id=version.id, project_id=None, submission_file_id=None, title=title,
|
||||
category="course", source_hash=hashlib.sha256(content.encode()).hexdigest(),
|
||||
pages=[{"page_number": 1, "section": title, "text": content, "tables": []}])
|
||||
completed.append(title)
|
||||
except Exception as exc:
|
||||
errors[title] = str(exc)[:500]
|
||||
return {"completed": completed, "errors": errors}
|
||||
|
||||
|
||||
@router.post("/scoring-batches")
|
||||
def create_batch(data: BatchInput, db: Session = Depends(get_db)):
|
||||
version = found(db, CourseVersion, data.course_version_id)
|
||||
rubric = found(db, RubricVersion, data.rubric_version_id)
|
||||
if not version.published or not rubric.published or rubric.course_version_id != version.id:
|
||||
raise HTTPException(400, "请选择同一课程版本下已发布的规则")
|
||||
settings = get_settings()
|
||||
batch = ScoringBatch(course_version_id=version.id, rubric_version_id=rubric.id,
|
||||
course_snapshot={key: getattr(version, key) for key in ("version", "title", "objectives", "indicators", "directions", "prompts", "templates")},
|
||||
rubric_snapshot={"version": rubric.version, "criteria": rubric.criteria, "weights": rubric.weights},
|
||||
model_config_snapshot=model_config(db), embedding_model_name=settings.embedding_model_name,
|
||||
embedding_model_version=settings.embedding_model_version)
|
||||
db.add(batch)
|
||||
db.flush()
|
||||
audit(db, "create", "scoring_batch", batch.id)
|
||||
db.commit()
|
||||
return {"id": batch.id, "status": batch.status}
|
||||
|
||||
|
||||
@router.get("/scoring-batches")
|
||||
def list_batches(db: Session = Depends(get_db)):
|
||||
return [{key: getattr(batch, key) for key in ("id", "course_version_id", "rubric_version_id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")}
|
||||
for batch in db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc()))]
|
||||
|
||||
|
||||
@router.get("/scoring-batches/{batch_id}/progress")
|
||||
def batch_progress(batch_id: int, db: Session = Depends(get_db)):
|
||||
batch = found(db, ScoringBatch, batch_id)
|
||||
return {key: getattr(batch, key) for key in ("id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")}
|
||||
|
||||
|
||||
@router.post("/scoring-batches/{batch_id}/start")
|
||||
def start_batch(batch_id: int, db: Session = Depends(get_db)):
|
||||
batch = found(db, ScoringBatch, batch_id)
|
||||
if batch.status == "running":
|
||||
raise HTTPException(409, "批次正在评分")
|
||||
try:
|
||||
Queue("wisegrade", connection=Redis.from_url(get_settings().redis_url)).enqueue(run_batch, batch_id,
|
||||
job_timeout="12h", retry=Retry(max=1, interval=[60]))
|
||||
except Exception as exc:
|
||||
raise HTTPException(503, f"无法提交后台任务: {type(exc).__name__}") from exc
|
||||
batch.status, batch.error = "queued", ""
|
||||
db.commit()
|
||||
return {"id": batch.id, "status": batch.status}
|
||||
|
||||
|
||||
@router.get("/scoring-batches/{batch_id}/scores")
|
||||
def batch_scores(batch_id: int, db: Session = Depends(get_db)):
|
||||
batch = found(db, ScoringBatch, batch_id)
|
||||
projects = db.scalars(select(Project).where(Project.course_version_id == batch.course_version_id).order_by(Project.id)).all()
|
||||
return [project_result(db, batch, project) for project in projects]
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/scores")
|
||||
def project_scores(project_id: int, batch_id: int, db: Session = Depends(get_db)):
|
||||
batch, project = found(db, ScoringBatch, batch_id), found(db, Project, project_id)
|
||||
if project.course_version_id != batch.course_version_id:
|
||||
raise HTTPException(400, "项目不属于评分批次课程版本")
|
||||
return project_result(db, batch, project)
|
||||
|
||||
|
||||
@router.put("/criterion-scores/{score_id}/review")
|
||||
def review_score(score_id: int, data: ReviewInput, db: Session = Depends(get_db)):
|
||||
score = found(db, CriterionScore, score_id)
|
||||
batch = found(db, ScoringBatch, score.batch_id)
|
||||
criterion = next((item for item in batch.rubric_snapshot["criteria"] if item["criterion_id"] == score.criterion_id), None)
|
||||
if not criterion or data.score > criterion["max_score"]:
|
||||
raise HTTPException(400, "教师分数超出规则满分")
|
||||
db.add(TeacherRevision(score_id=score.id, old_score=score.teacher_score, new_score=data.score,
|
||||
old_comment=score.teacher_comment, new_comment=data.comment, reason=data.reason))
|
||||
score.teacher_score, score.teacher_comment, score.status = data.score, data.comment, "reviewed"
|
||||
audit(db, "review", "criterion_score", score.id, {"reason": data.reason})
|
||||
db.commit()
|
||||
return {"id": score.id, "model_score": score.model_score, "teacher_score": score.teacher_score,
|
||||
"final_score": score.final_score, "teacher_comment": score.teacher_comment}
|
||||
|
||||
|
||||
@router.get("/criterion-scores/{score_id}/revisions")
|
||||
def revisions(score_id: int, db: Session = Depends(get_db)):
|
||||
found(db, CriterionScore, score_id)
|
||||
return [{key: getattr(item, key) for key in ("id", "old_score", "new_score", "old_comment", "new_comment", "reason", "created_at")}
|
||||
for item in db.scalars(select(TeacherRevision).where(TeacherRevision.score_id == score_id).order_by(TeacherRevision.id))]
|
||||
|
||||
|
||||
@router.post("/exports/excel")
|
||||
def create_excel(data: dict, db: Session = Depends(get_db)):
|
||||
task = export_excel(db, int(data["batch_id"]))
|
||||
audit(db, "export", "scoring_batch", task.batch_id, {"kind": "excel", "task_id": task.id})
|
||||
db.commit()
|
||||
return {"id": task.id}
|
||||
|
||||
|
||||
@router.post("/exports/project-pdf")
|
||||
def create_pdf(data: dict, db: Session = Depends(get_db)):
|
||||
task = export_project_pdf(db, int(data["batch_id"]), int(data["project_id"]))
|
||||
audit(db, "export", "project", task.project_id, {"kind": "pdf", "task_id": task.id})
|
||||
db.commit()
|
||||
return {"id": task.id}
|
||||
|
||||
|
||||
@router.get("/exports")
|
||||
def list_exports(db: Session = Depends(get_db)):
|
||||
return [{key: getattr(task, key) for key in ("id", "batch_id", "project_id", "kind", "status", "error", "created_at")}
|
||||
for task in db.scalars(select(ExportTask).order_by(ExportTask.id.desc()))]
|
||||
|
||||
|
||||
@router.get("/exports/{task_id}")
|
||||
def get_export(task_id: int, db: Session = Depends(get_db)):
|
||||
task = found(db, ExportTask, task_id)
|
||||
return {key: getattr(task, key) for key in ("id", "batch_id", "project_id", "kind", "status", "error", "created_at")}
|
||||
|
||||
|
||||
@router.get("/exports/{task_id}/download")
|
||||
def download_export(task_id: int, db: Session = Depends(get_db)):
|
||||
task = found(db, ExportTask, task_id)
|
||||
root = get_settings().exports_path.resolve()
|
||||
path = Path(task.path or "").resolve()
|
||||
if task.status != "completed" or not path.is_file() or not path.is_relative_to(root):
|
||||
raise HTTPException(404, "导出文件不存在")
|
||||
return FileResponse(path, filename=path.name)
|
||||
|
||||
|
||||
@router.get("/assets/{file_id}/{page}")
|
||||
def page_asset(file_id: int, page: int, db: Session = Depends(get_db)):
|
||||
file = found(db, SubmissionFile, file_id)
|
||||
pages = file.parsed.get("pages", []) if file.parsed else []
|
||||
item = next((item for item in pages if item["page_number"] == page), None)
|
||||
path = Path(item["rendered_image_path"]).resolve() if item and item.get("rendered_image_path") else None
|
||||
root = get_settings().processed_path.resolve()
|
||||
if not path or not path.is_file() or not path.is_relative_to(root):
|
||||
raise HTTPException(404, "页面图像不存在")
|
||||
return FileResponse(path, media_type="image/png")
|
||||
|
||||
|
||||
@router.get("/settings/model")
|
||||
def get_model_settings(db: Session = Depends(get_db)):
|
||||
return {**model_config(db), "api_key_configured": key_configured(db),
|
||||
"embedding_model_name": get_settings().embedding_model_name,
|
||||
"embedding_model_path": str(get_settings().embedding_model_path),
|
||||
"embedding_model_version": get_settings().embedding_model_version}
|
||||
|
||||
|
||||
@router.put("/settings/model")
|
||||
def put_model_settings(data: ModelSettingsInput, db: Session = Depends(get_db)):
|
||||
result = save_model_config(db, data.model_dump(exclude_none=True))
|
||||
audit(db, "edit", "model_settings", None, {"fields": [key for key in data.model_fields_set if key != "api_key"]})
|
||||
db.commit()
|
||||
return result
|
||||
Reference in New Issue
Block a user