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, batch.finished_at = "queued", "", None 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