From eece55d08dccd41158efca8d06fd520a94a1041d Mon Sep 17 00:00:00 2001 From: SkyLi Date: Fri, 25 Sep 2026 11:11:18 +0800 Subject: [PATCH] feat: add versioned backend scoring workflow --- backend/alembic.ini | 3 + backend/migrations/env.py | 24 + backend/migrations/script.py.mako | 21 + backend/migrations/versions/0001_initial.py | 22 + backend/wisegrade/__init__.py | 1 + backend/wisegrade/api.py | 594 ++++++++++++++++++++ backend/wisegrade/config.py | 29 + backend/wisegrade/db.py | 22 + backend/wisegrade/exports.py | 124 ++++ backend/wisegrade/llm.py | 112 ++++ backend/wisegrade/main.py | 31 + backend/wisegrade/materialized.py | 36 ++ backend/wisegrade/models.py | 274 +++++++++ backend/wisegrade/parsing.py | 125 ++++ backend/wisegrade/rag.py | 99 ++++ backend/wisegrade/rules.py | 59 ++ backend/wisegrade/scoring.py | 180 ++++++ backend/wisegrade/seed.py | 93 +++ backend/wisegrade/settings_store.py | 65 +++ backend/wisegrade/storage.py | 149 +++++ 20 files changed, 2063 insertions(+) create mode 100644 backend/alembic.ini create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/0001_initial.py create mode 100644 backend/wisegrade/__init__.py create mode 100644 backend/wisegrade/api.py create mode 100644 backend/wisegrade/config.py create mode 100644 backend/wisegrade/db.py create mode 100644 backend/wisegrade/exports.py create mode 100644 backend/wisegrade/llm.py create mode 100644 backend/wisegrade/main.py create mode 100644 backend/wisegrade/materialized.py create mode 100644 backend/wisegrade/models.py create mode 100644 backend/wisegrade/parsing.py create mode 100644 backend/wisegrade/rag.py create mode 100644 backend/wisegrade/rules.py create mode 100644 backend/wisegrade/scoring.py create mode 100644 backend/wisegrade/seed.py create mode 100644 backend/wisegrade/settings_store.py create mode 100644 backend/wisegrade/storage.py diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..8da6ede --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,3 @@ +[alembic] +script_location = migrations +sqlalchemy.url = sqlite:///./wisegrade.db diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..1390d55 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,24 @@ +from alembic import context +from sqlalchemy import engine_from_config, pool + +from wisegrade.config import get_settings +from wisegrade.db import Base +from wisegrade import models # noqa: F401 + +config = context.config +config.set_main_option("sqlalchemy.url", get_settings().database_url.replace("%", "%%")) +target_metadata = Base.metadata + + +def run_migrations_online(): + connectable = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool) + with connectable.connect() as connection: + if connection.dialect.name == "postgresql": + connection.exec_driver_sql("CREATE EXTENSION IF NOT EXISTS vector") + connection.commit() + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..f1d81bc --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,21 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/0001_initial.py b/backend/migrations/versions/0001_initial.py new file mode 100644 index 0000000..9bd5c65 --- /dev/null +++ b/backend/migrations/versions/0001_initial.py @@ -0,0 +1,22 @@ +"""Initial schema. + +Revision ID: 0001 +Revises: +""" +from alembic import op + +from wisegrade.db import Base +from wisegrade import models # noqa: F401 + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + Base.metadata.create_all(op.get_bind()) + + +def downgrade(): + Base.metadata.drop_all(op.get_bind()) diff --git a/backend/wisegrade/__init__.py b/backend/wisegrade/__init__.py new file mode 100644 index 0000000..4e55f88 --- /dev/null +++ b/backend/wisegrade/__init__.py @@ -0,0 +1 @@ +"""WiseGrade backend.""" diff --git a/backend/wisegrade/api.py b/backend/wisegrade/api.py new file mode 100644 index 0000000..3fc5ef5 --- /dev/null +++ b/backend/wisegrade/api.py @@ -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 diff --git a/backend/wisegrade/config.py b/backend/wisegrade/config.py new file mode 100644 index 0000000..890a582 --- /dev/null +++ b/backend/wisegrade/config.py @@ -0,0 +1,29 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + database_url: str = "sqlite:///./wisegrade.db" + redis_url: str = "redis://localhost:6379/0" + inbox_path: Path = Path("/data/inbox") + processed_path: Path = Path("/data/processed") + exports_path: Path = Path("/data/exports") + deepseek_base_url: str = "https://api.deepseek.com" + deepseek_model: str = "deepseek-flash" + deepseek_api_key: str = "" + wisegrade_secret_key: str = "" + embedding_model_name: str = "BAAI/bge-m3" + embedding_model_path: Path = Path("/models/bge-m3") + embedding_model_version: str = "local" + model_timeout_seconds: int = 120 + model_concurrency: int = 2 + model_retries: int = 2 + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/wisegrade/db.py b/backend/wisegrade/db.py new file mode 100644 index 0000000..23e4bea --- /dev/null +++ b/backend/wisegrade/db.py @@ -0,0 +1,22 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from .config import get_settings + + +class Base(DeclarativeBase): + pass + + +def engine_for(url: str | None = None): + url = url or get_settings().database_url + return create_engine(url, connect_args={"check_same_thread": False} if url.startswith("sqlite") else {}, pool_pre_ping=True) + + +engine = engine_for() +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + + +def get_db(): + with SessionLocal() as session: + yield session diff --git a/backend/wisegrade/exports.py b/backend/wisegrade/exports.py new file mode 100644 index 0000000..7105fa2 --- /dev/null +++ b/backend/wisegrade/exports.py @@ -0,0 +1,124 @@ +from html import escape +from pathlib import Path + +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill +from sqlalchemy import select +from weasyprint import HTML + +from .config import get_settings +from .models import (CriterionScore, ExportTask, Project, ScoreEvidence, ScoringBatch, Student, SubmissionFile) +from .rules import totals + + +def project_result(db, batch: ScoringBatch, project: Project): + scores = db.scalars(select(CriterionScore).where(CriterionScore.batch_id == batch.id, + CriterionScore.project_id == project.id)).all() + by_id = {score.criterion_id: score for score in scores} + criteria = batch.rubric_snapshot["criteria"] + totals_ = totals({key: score.final_score for key, score in by_id.items()}, criteria, batch.rubric_snapshot["weights"]) + rows = [] + for criterion in criteria: + score = by_id.get(criterion["criterion_id"]) + evidence = db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all() if score else [] + rows.append({"criterion": criterion, "score_id": score.id if score else None, + "model_score": score.model_score if score else None, "teacher_score": score.teacher_score if score else None, + "final_score": score.final_score if score else None, + "confidence": score.confidence if score else None, + "model_comment": score.model_comment if score else "", "teacher_comment": score.teacher_comment if score else None, + "missing_evidence": score.missing_evidence if score else [], "suggestion": score.suggestion if score else "", + "status": score.status if score else "pending", "error": score.error if score else "", + "evidence": [{"page": item.page, "quote": item.quote, "file_id": item.submission_file_id, + "document": db.get(SubmissionFile, item.submission_file_id).relative_path if item.submission_file_id else "课程资料", + "image_url": f"/api/assets/{item.submission_file_id}/{item.page}" if item.image_path else None} for item in evidence]}) + student = db.get(Student, project.student_id) + return {"project_id": project.id, "student_no": student.student_no, "name": student.name, "topic": project.topic, + "totals": totals_, "rows": rows, "facts": project.facts} + + +def export_excel(db, batch_id: int): + batch = db.get(ScoringBatch, batch_id) + if not batch: + raise ValueError("评分批次不存在") + root = get_settings().exports_path + root.mkdir(parents=True, exist_ok=True) + task = ExportTask(batch_id=batch_id, kind="excel", status="running") + db.add(task) + db.flush() + path = root / f"batch-{batch_id}-scores-{task.id}.xlsx" + try: + book = Workbook() + sheet = book.active + sheet.title = "成绩" + final_criteria = [c for c in batch.rubric_snapshot["criteria"] if c["category"] == "final"] + sheet.append(["学号", "姓名", "选题", "阶段报告1", "阶段报告2", "阶段报告3", *[c["name"] for c in final_criteria], "期末成绩", "总评", "状态", "批次", "规则版本"]) + for project in db.scalars(select(Project).where(Project.course_version_id == batch.course_version_id).order_by(Project.id)): + result = project_result(db, batch, project) + scores = {row["criterion"]["criterion_id"]: row["final_score"] for row in result["rows"]} + totals_ = result["totals"] + sheet.append([result["student_no"], result["name"], result["topic"], *[totals_[key] for key in ("stage1", "stage2", "stage3")], + *[scores.get(c["criterion_id"]) for c in final_criteria], totals_["final"], totals_["overall"], + "完成" if totals_["overall"] is not None else "未完成/待人工处理", batch.id, batch.rubric_version_id]) + sheet.freeze_panes = "D2" + sheet.auto_filter.ref = sheet.dimensions + for cell in sheet[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="153C65") + for column in sheet.columns: + letter = column[0].column_letter + sheet.column_dimensions[letter].width = min(50, max(12, max(len(str(cell.value or "")) for cell in column) + 2)) + book.save(path) + task.path, task.status = str(path), "completed" + except Exception as exc: + task.status, task.error = "failed", str(exc)[:1000] + db.commit() + raise + db.commit() + return task + + +def _html_report(result, batch): + esc = lambda value: escape(str(value or "")) + rows = [] + for row in result["rows"]: + criterion = row["criterion"] + evidences = "".join(f"
  • {esc(e['document'])} · 第{e['page']}页/张:{esc(e['quote'])}
  • " for e in row["evidence"]) + missing = "、".join(row["missing_evidence"] or []) + rows.append(f"

    {esc(criterion['name'])}({esc(row['final_score'])} / {criterion['max_score']})

    " + f"

    模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(row['status'])}

    " + f"

    模型评语:{esc(row['model_comment'])}

    教师评语:{esc(row['teacher_comment'])}

    " + f"

    缺失证据:{esc(missing)}

    建议:{esc(row['suggestion'])}

    异常:{esc(row['error'])}

    " + f"
    ") + totals_ = result["totals"] + return f"""

    WiseGrade 智评 · 学生反馈

    +

    学号:{esc(result['student_no'])} 姓名:{esc(result['name'])} 选题:{esc(result['topic'])}

    +

    评分批次:{batch.id} 课程版本:{batch.course_version_id} 规则版本:{batch.rubric_version_id}

    +

    模型:{esc(batch.model_config_snapshot.get('model'))} 嵌入模型:{esc(batch.embedding_model_name)} ({esc(batch.embedding_model_version)})

    +

    成绩

    阶段1:{esc(totals_['stage1'])} 阶段2:{esc(totals_['stage2'])} 阶段3:{esc(totals_['stage3'])} 期末:{esc(totals_['final'])} 总评:{esc(totals_['overall'])}

    + {''.join(rows)}""" + + +def export_project_pdf(db, batch_id: int, project_id: int): + batch = db.get(ScoringBatch, batch_id) + project = db.get(Project, project_id) + if not batch or not project or project.course_version_id != batch.course_version_id: + raise ValueError("批次或项目不存在") + root = get_settings().exports_path + root.mkdir(parents=True, exist_ok=True) + task = ExportTask(batch_id=batch_id, project_id=project_id, kind="pdf", status="running") + db.add(task) + db.flush() + path = root / f"batch-{batch_id}-project-{project_id}-{task.id}.pdf" + try: + HTML(string=_html_report(project_result(db, batch, project), batch)).write_pdf(path) + task.path, task.status = str(path), "completed" + except Exception as exc: + task.status, task.error = "failed", str(exc)[:1000] + db.commit() + raise + db.commit() + return task diff --git a/backend/wisegrade/llm.py b/backend/wisegrade/llm.py new file mode 100644 index 0000000..b2d8d3a --- /dev/null +++ b/backend/wisegrade/llm.py @@ -0,0 +1,112 @@ +import base64 +import json +import time +from pathlib import Path +from typing import Any + +import httpx +from pydantic import BaseModel, Field, ValidationError, model_validator + +from .config import get_settings + + +class EvidenceResult(BaseModel): + chunk_id: int + page: int = Field(ge=1) + quote: str = Field(min_length=1) + + +class ScoreResult(BaseModel): + criterion_id: str + score: float = Field(ge=0) + max_score: float = Field(gt=0) + reason: str = Field(min_length=1) + evidence: list[EvidenceResult] + missing_evidence: list[str] = Field(default_factory=list) + suggestion: str = "" + confidence: float = Field(ge=0, le=1) + + @model_validator(mode="after") + def valid_range(self): + if self.score > self.max_score or (self.score > 0 and not self.evidence): + raise ValueError("score exceeds maximum or lacks evidence") + return self + + +class FactResult(BaseModel): + student_no: str = "" + name: str = "" + topic: str = "" + direction: str = "" + goals: list[str] = Field(default_factory=list) + metrics: list[str] = Field(default_factory=list) + hardware: list[str] = Field(default_factory=list) + software: list[str] = Field(default_factory=list) + experiments: list[str] = Field(default_factory=list) + data: list[str] = Field(default_factory=list) + schedule: list[str] = Field(default_factory=list) + cost: list[str] = Field(default_factory=list) + conclusions: list[str] = Field(default_factory=list) + + +class ConsistencyIssue(BaseModel): + description: str + chunk_ids: list[int] = Field(min_length=2) + + +class ConsistencyResult(BaseModel): + issues: list[ConsistencyIssue] = Field(default_factory=list) + + +class ModelError(RuntimeError): + pass + + +class DeepSeekClient: + def __init__(self, *, base_url=None, model=None, api_key=None, timeout=None, retries=None, transport=None): + settings = get_settings() + self.base_url = (base_url or settings.deepseek_base_url).rstrip("/") + self.model = model or settings.deepseek_model + self.api_key = api_key or settings.deepseek_api_key + self.timeout = timeout or settings.model_timeout_seconds + self.retries = settings.model_retries if retries is None else retries + self.transport = transport + if not self.api_key: + raise ModelError("未配置DeepSeek API Key") + + def json_call(self, prompt: str, payload: dict[str, Any], schema: type[BaseModel], *, images: list[str] | None = None): + content: list[dict] = [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}] + for image in images or []: + path = Path(image) + if not path.is_file(): + raise ModelError(f"页面图像不存在: {path.name}") + content.append({"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(path.read_bytes()).decode()}}) + body = {"model": self.model, "response_format": {"type": "json_object"}, + "messages": [{"role": "system", "content": prompt + " 严格输出一个JSON对象。"}, {"role": "user", "content": content}], + "temperature": 0} + last_error = None + for attempt in range(self.retries + 1): + try: + with httpx.Client(timeout=self.timeout, transport=self.transport) as client: + response = client.post(self.base_url + "/chat/completions", json=body, + headers={"Authorization": f"Bearer {self.api_key}"}) + response.raise_for_status() + raw = response.json()["choices"][0]["message"]["content"] + return schema.model_validate(json.loads(raw)) + except (httpx.HTTPError, ValueError, KeyError, IndexError, ValidationError) as exc: + # Never include response bodies or headers: providers sometimes echo credentials. + last_error = type(exc).__name__ + if attempt < self.retries: + time.sleep(min(2 ** attempt, 5)) + raise ModelError(f"模型请求或JSON校验失败({last_error}),已重试{self.retries}次") + + +def validate_score(result: ScoreResult, criterion: dict, chunks: list[dict]): + if result.criterion_id != criterion["criterion_id"] or result.max_score != criterion["max_score"]: + raise ModelError("模型评分项或满分与规则版本不一致") + by_id = {chunk["chunk_id"]: chunk for chunk in chunks} + for evidence in result.evidence: + chunk = by_id.get(evidence.chunk_id) + if not chunk or evidence.page != chunk["page"] or evidence.quote not in chunk["text"]: + raise ModelError("模型证据未能匹配检索原文") + return result diff --git a/backend/wisegrade/main.py b/backend/wisegrade/main.py new file mode 100644 index 0000000..9f0d90f --- /dev/null +++ b/backend/wisegrade/main.py @@ -0,0 +1,31 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from .api import router +from .db import SessionLocal +from .seed import seed_initial + + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") +@asynccontextmanager +async def lifespan(_app: FastAPI): + with SessionLocal() as db: + seed_initial(db) + yield + + +app = FastAPI(title="WiseGrade 智评", version="0.1.0", lifespan=lifespan) +app.include_router(router) + + +@app.exception_handler(ValueError) +async def value_error_handler(_request: Request, exc: ValueError): + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + +@app.get("/api/health") +def health(): + return {"status": "ok"} diff --git a/backend/wisegrade/materialized.py b/backend/wisegrade/materialized.py new file mode 100644 index 0000000..9439571 --- /dev/null +++ b/backend/wisegrade/materialized.py @@ -0,0 +1,36 @@ +from sqlalchemy import delete, select + +from .models import (CourseObjective, GraduationIndicator, RubricCriterion, + RubricScoreLevel, TopicDirection) + + +def sync_course_entities(db, version): + db.execute(delete(CourseObjective).where(CourseObjective.course_version_id == version.id)) + db.execute(delete(GraduationIndicator).where(GraduationIndicator.course_version_id == version.id)) + db.execute(delete(TopicDirection).where(TopicDirection.course_version_id == version.id)) + for value in version.objectives: + db.add(CourseObjective(course_version_id=version.id, code=str(value["code"]), name=value["name"], + description=value.get("description", ""), indicator_codes=value.get("indicators", []))) + for value in version.indicators: + db.add(GraduationIndicator(course_version_id=version.id, code=str(value["code"]), description=value["description"])) + for value in version.directions: + db.add(TopicDirection(course_version_id=version.id, name=value["name"], examples=value.get("examples", []))) + db.flush() + + +def sync_rubric_entities(db, rubric): + ids = db.scalars(select(RubricCriterion.id).where(RubricCriterion.rubric_version_id == rubric.id)).all() + if ids: + db.execute(delete(RubricScoreLevel).where(RubricScoreLevel.rubric_criterion_id.in_(ids))) + db.execute(delete(RubricCriterion).where(RubricCriterion.rubric_version_id == rubric.id)) + for value in rubric.criteria: + item = RubricCriterion(rubric_version_id=rubric.id, criterion_id=value["criterion_id"], + category=value["category"], name=value["name"], description=value["description"], + max_score=value["max_score"], linked_objectives=value.get("linked_objectives", []), + linked_outcomes=value.get("linked_outcomes", []), required_evidence=value.get("required_evidence", [])) + db.add(item) + db.flush() + for level in value.get("score_levels", []): + db.add(RubricScoreLevel(rubric_criterion_id=item.id, min_score=level["min"], + max_score=level["max"], description=level["description"])) + db.flush() diff --git a/backend/wisegrade/models.py b/backend/wisegrade/models.py new file mode 100644 index 0000000..2532434 --- /dev/null +++ b/backend/wisegrade/models.py @@ -0,0 +1,274 @@ +from datetime import datetime, timezone + +from pgvector.sqlalchemy import Vector +from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint +from sqlalchemy.types import TypeDecorator + +from .db import Base + + +def now(): + return datetime.now(timezone.utc) + + +class Embedding(TypeDecorator): + impl = JSON + cache_ok = True + + def load_dialect_impl(self, dialect): + return dialect.type_descriptor(Vector(1024) if dialect.name == "postgresql" else JSON()) + + +class Course(Base): + __tablename__ = "courses" + id = Column(Integer, primary_key=True) + name = Column(String(200), unique=True, nullable=False) + code = Column(String(100), unique=True, nullable=False) + created_at = Column(DateTime(timezone=True), default=now) + + +class CourseVersion(Base): + __tablename__ = "course_versions" + id = Column(Integer, primary_key=True) + course_id = Column(Integer, ForeignKey("courses.id"), nullable=False) + version = Column(Integer, nullable=False) + title = Column(String(200), nullable=False) + objectives = Column(JSON, default=list, nullable=False) + indicators = Column(JSON, default=list, nullable=False) + directions = Column(JSON, default=list, nullable=False) + prompts = Column(JSON, default=dict, nullable=False) + templates = Column(JSON, default=list, nullable=False) + published = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime(timezone=True), default=now) + __table_args__ = (UniqueConstraint("course_id", "version"),) + + +class CourseObjective(Base): + __tablename__ = "course_objectives" + id = Column(Integer, primary_key=True) + course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) + code = Column(String(50), nullable=False) + name = Column(String(300), nullable=False) + description = Column(Text, default="") + indicator_codes = Column(JSON, default=list) + __table_args__ = (UniqueConstraint("course_version_id", "code"),) + + +class GraduationIndicator(Base): + __tablename__ = "graduation_indicators" + id = Column(Integer, primary_key=True) + course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) + code = Column(String(50), nullable=False) + description = Column(Text, nullable=False) + __table_args__ = (UniqueConstraint("course_version_id", "code"),) + + +class TopicDirection(Base): + __tablename__ = "topic_directions" + id = Column(Integer, primary_key=True) + course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) + name = Column(String(200), nullable=False) + examples = Column(JSON, default=list) + __table_args__ = (UniqueConstraint("course_version_id", "name"),) + + +class RubricVersion(Base): + __tablename__ = "rubric_versions" + id = Column(Integer, primary_key=True) + course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) + version = Column(Integer, nullable=False) + criteria = Column(JSON, default=list, nullable=False) + weights = Column(JSON, default=dict, nullable=False) + published = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime(timezone=True), default=now) + __table_args__ = (UniqueConstraint("course_version_id", "version"),) + + +class RubricCriterion(Base): + __tablename__ = "rubric_criteria" + id = Column(Integer, primary_key=True) + rubric_version_id = Column(Integer, ForeignKey("rubric_versions.id"), nullable=False) + criterion_id = Column(String(100), nullable=False) + category = Column(String(50), nullable=False) + name = Column(String(300), nullable=False) + description = Column(Text, nullable=False) + max_score = Column(Float, nullable=False) + linked_objectives = Column(JSON, default=list) + linked_outcomes = Column(JSON, default=list) + required_evidence = Column(JSON, default=list) + __table_args__ = (UniqueConstraint("rubric_version_id", "criterion_id"),) + + +class RubricScoreLevel(Base): + __tablename__ = "rubric_score_levels" + id = Column(Integer, primary_key=True) + rubric_criterion_id = Column(Integer, ForeignKey("rubric_criteria.id"), nullable=False) + min_score = Column(Float, nullable=False) + max_score = Column(Float, nullable=False) + description = Column(Text, nullable=False) + + +class Student(Base): + __tablename__ = "students" + id = Column(Integer, primary_key=True) + student_no = Column(String(80), unique=True, nullable=False) + name = Column(String(100), nullable=False) + + +class Project(Base): + __tablename__ = "projects" + id = Column(Integer, primary_key=True) + course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) + student_id = Column(Integer, ForeignKey("students.id"), nullable=False) + topic = Column(String(500), nullable=False) + direction = Column(String(200), default="") + source_dir = Column(String(1000), nullable=False) + status = Column(String(40), default="imported") + facts = Column(JSON, default=dict, nullable=False) + created_at = Column(DateTime(timezone=True), default=now) + __table_args__ = (UniqueConstraint("course_version_id", "source_dir"),) + + +class Submission(Base): + __tablename__ = "submissions" + id = Column(Integer, primary_key=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + category = Column(String(50), nullable=False) + status = Column(String(40), nullable=False) + __table_args__ = (UniqueConstraint("project_id", "category"),) + + +class SubmissionFile(Base): + __tablename__ = "submission_files" + id = Column(Integer, primary_key=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + submission_id = Column(Integer, ForeignKey("submissions.id"), nullable=True) + category = Column(String(40), nullable=False) + relative_path = Column(String(1200), nullable=False) + mime_type = Column(String(100), nullable=False) + size = Column(Integer, nullable=False) + mtime = Column(Float, nullable=False) + sha256 = Column(String(64), nullable=False) + recognition_status = Column(String(40), nullable=False) + parse_status = Column(String(40), default="pending") + parse_error = Column(Text, default="") + parsed = Column(JSON, default=dict, nullable=False) + __table_args__ = (UniqueConstraint("project_id", "relative_path"),) + + +class KnowledgeDocument(Base): + __tablename__ = "knowledge_documents" + id = Column(Integer, primary_key=True) + course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=True) + submission_file_id = Column(Integer, ForeignKey("submission_files.id"), nullable=True) + title = Column(String(500), nullable=False) + document_type = Column(String(80), nullable=False) + source_hash = Column(String(64), nullable=False) + status = Column(String(40), default="ready") + __table_args__ = (UniqueConstraint("course_version_id", "project_id", "submission_file_id", "source_hash"),) + + +class KnowledgeChunk(Base): + __tablename__ = "knowledge_chunks" + id = Column(Integer, primary_key=True) + document_id = Column(Integer, ForeignKey("knowledge_documents.id"), nullable=False) + course_version_id = Column(Integer, nullable=False) + project_id = Column(Integer, nullable=True) + document_type = Column(String(80), nullable=False) + page = Column(Integer, nullable=False) + section = Column(String(500), default="") + text = Column(Text, nullable=False) + embedding = Column(Embedding, nullable=True) + + +class ScoringBatch(Base): + __tablename__ = "scoring_batches" + id = Column(Integer, primary_key=True) + course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) + rubric_version_id = Column(Integer, ForeignKey("rubric_versions.id"), nullable=False) + course_snapshot = Column(JSON, nullable=False) + rubric_snapshot = Column(JSON, nullable=False) + model_config_snapshot = Column(JSON, nullable=False) + embedding_model_name = Column(String(200), nullable=False) + embedding_model_version = Column(String(100), nullable=False) + status = Column(String(40), default="queued") + progress_done = Column(Integer, default=0) + progress_total = Column(Integer, default=0) + error = Column(Text, default="") + created_at = Column(DateTime(timezone=True), default=now) + finished_at = Column(DateTime(timezone=True), nullable=True) + + +class CriterionScore(Base): + __tablename__ = "criterion_scores" + id = Column(Integer, primary_key=True) + batch_id = Column(Integer, ForeignKey("scoring_batches.id"), nullable=False) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + criterion_id = Column(String(100), nullable=False) + model_score = Column(Float, nullable=True) + teacher_score = Column(Float, nullable=True) + model_comment = Column(Text, default="") + teacher_comment = Column(Text, nullable=True) + confidence = Column(Float, nullable=True) + missing_evidence = Column(JSON, default=list) + suggestion = Column(Text, default="") + status = Column(String(40), default="pending") + error = Column(Text, default="") + __table_args__ = (UniqueConstraint("batch_id", "project_id", "criterion_id"),) + + @property + def final_score(self): + return self.teacher_score if self.teacher_score is not None else self.model_score + + +class ScoreEvidence(Base): + __tablename__ = "score_evidences" + id = Column(Integer, primary_key=True) + score_id = Column(Integer, ForeignKey("criterion_scores.id"), nullable=False) + document_id = Column(Integer, ForeignKey("knowledge_documents.id"), nullable=True) + submission_file_id = Column(Integer, ForeignKey("submission_files.id"), nullable=True) + page = Column(Integer, nullable=False) + quote = Column(Text, nullable=False) + image_path = Column(String(1200), nullable=True) + + +class TeacherRevision(Base): + __tablename__ = "teacher_revisions" + id = Column(Integer, primary_key=True) + score_id = Column(Integer, ForeignKey("criterion_scores.id"), nullable=False) + old_score = Column(Float, nullable=True) + new_score = Column(Float, nullable=True) + old_comment = Column(Text, nullable=True) + new_comment = Column(Text, nullable=True) + reason = Column(Text, default="") + created_at = Column(DateTime(timezone=True), default=now) + + +class ExportTask(Base): + __tablename__ = "export_tasks" + id = Column(Integer, primary_key=True) + batch_id = Column(Integer, ForeignKey("scoring_batches.id"), nullable=False) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=True) + kind = Column(String(20), nullable=False) + path = Column(String(1200), nullable=True) + status = Column(String(40), default="queued") + error = Column(Text, default="") + created_at = Column(DateTime(timezone=True), default=now) + + +class AppSetting(Base): + __tablename__ = "app_settings" + key = Column(String(100), primary_key=True) + encrypted_value = Column(LargeBinary, nullable=False) + updated_at = Column(DateTime(timezone=True), default=now) + + +class AuditLog(Base): + __tablename__ = "audit_logs" + id = Column(Integer, primary_key=True) + action = Column(String(100), nullable=False) + entity_type = Column(String(100), nullable=False) + entity_id = Column(Integer, nullable=True) + details = Column(JSON, default=dict) + created_at = Column(DateTime(timezone=True), default=now) diff --git a/backend/wisegrade/parsing.py b/backend/wisegrade/parsing.py new file mode 100644 index 0000000..6b6dfb1 --- /dev/null +++ b/backend/wisegrade/parsing.py @@ -0,0 +1,125 @@ +import subprocess +import tempfile +from pathlib import Path + +import pymupdf as fitz +from docx import Document +from pptx import Presentation + + +def _render(page, output: Path): + output.parent.mkdir(parents=True, exist_ok=True) + page.get_pixmap(matrix=fitz.Matrix(1.5, 1.5), alpha=False).save(output) + return str(output) + + +def _ocr(image_path: str): + try: + from paddleocr import PaddleOCR + except ImportError as exc: + raise RuntimeError("扫描PDF需要PaddleOCR;请安装OCR依赖或交由教师处理") from exc + try: + engine = PaddleOCR(lang="ch", use_textline_orientation=True) + except TypeError: + engine = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False) + if hasattr(engine, "predict"): + result = engine.predict(image_path) + lines = [] + for page in result: + payload = page.json if hasattr(page, "json") else page + payload = payload() if callable(payload) else payload + payload = payload.get("res", payload) if isinstance(payload, dict) else {} + lines.extend(payload.get("rec_texts", [])) + return "\n".join(str(line) for line in lines) + result = engine.ocr(image_path, cls=True) + return "\n".join(line[1][0] for page in result for line in (page or [])) + + +def parse_pdf(path: Path, asset_dir: Path, *, ocr=True): + pages = [] + with fitz.open(path) as pdf: + if not len(pdf): + raise ValueError("PDF没有页面") + for index, page in enumerate(pdf): + image = _render(page, asset_dir / f"page-{index + 1}.png") + text = page.get_text(sort=True).strip() + if ocr and len(text) < 30: + text = _ocr(image) + tables = [] + try: + tables = [{"rows": table.extract(), "bbox": list(table.bbox)} for table in page.find_tables().tables] + except Exception: + pass + images = [{"xref": item[0], "width": item[2], "height": item[3]} for item in page.get_images(full=True)] + pages.append({"page_number": index + 1, "text": text, "tables": tables, "images": images, + "section": next((line.strip() for line in text.splitlines() if line.strip()), ""), + "rendered_image_path": image}) + return pages + + +def _convert_to_pdf(path: Path, output_dir: Path): + with tempfile.TemporaryDirectory() as profile: + command = ["libreoffice", f"-env:UserInstallation=file://{profile}", "--headless", "--convert-to", "pdf", "--outdir", str(output_dir), str(path)] + result = subprocess.run(command, capture_output=True, text=True, timeout=180, check=False) + pdf = output_dir / f"{path.stem}.pdf" + if result.returncode or not pdf.exists(): + raise RuntimeError(f"LibreOffice转换失败: {(result.stderr or result.stdout).strip()[:500]}") + return pdf + + +def _docx_structure(path: Path): + doc = Document(path) + headings = [{"text": p.text, "style": p.style.name} for p in doc.paragraphs if p.style and p.style.name.startswith("Heading")] + tables = [[list(cell.text for cell in row.cells) for row in table.rows] for table in doc.tables] + images = len(doc.inline_shapes) + return {"headings": headings, "tables": tables, "image_count": images} + + +def _pptx_structure(path: Path): + slides = [] + deck = Presentation(path) + for number, slide in enumerate(deck.slides, start=1): + texts, tables, images = [], [], 0 + for shape in slide.shapes: + if shape.has_text_frame: + texts.append(shape.text) + if shape.has_table: + tables.append([[cell.text for cell in row.cells] for row in shape.table.rows]) + if shape.shape_type == 13: + images += 1 + try: + notes = slide.notes_slide.notes_text_frame.text if slide.has_notes_slide else "" + except Exception: + notes = "" + slides.append({"page_number": number, "text": "\n".join(texts), "tables": tables, "images": [{"count": images}], + "notes": notes, "section": texts[0][:200] if texts else ""}) + if not slides: + raise ValueError("PPTX没有幻灯片") + return slides + + +def parse_document(path: Path, category: str, asset_dir: Path, *, ocr=True): + suffix = path.suffix.lower() + asset_dir.mkdir(parents=True, exist_ok=True) + if suffix == ".pdf": + pages = parse_pdf(path, asset_dir, ocr=ocr) + extra = {} + elif suffix in {".docx", ".doc"}: + extra = _docx_structure(path) if suffix == ".docx" else {} + pdf = _convert_to_pdf(path, asset_dir) + pages = parse_pdf(pdf, asset_dir, ocr=ocr) + pdf.unlink(missing_ok=True) + elif suffix == ".pptx": + slides = _pptx_structure(path) + pdf = _convert_to_pdf(path, asset_dir) + rendered = parse_pdf(pdf, asset_dir, ocr=False) + pdf.unlink(missing_ok=True) + if len(rendered) != len(slides): + raise ValueError("PPTX渲染页数与幻灯片数不一致") + pages = [{**slide, "rendered_image_path": rendered[index]["rendered_image_path"]} for index, slide in enumerate(slides)] + extra = {} + else: + raise ValueError("仅支持PDF、DOCX和PPTX") + if not any(page["text"].strip() for page in pages): + raise ValueError("未能提取文档内容") + return {"document_type": category, "source_path": str(path), "pages": pages, **extra} diff --git a/backend/wisegrade/rag.py b/backend/wisegrade/rag.py new file mode 100644 index 0000000..e5f2048 --- /dev/null +++ b/backend/wisegrade/rag.py @@ -0,0 +1,99 @@ +from functools import lru_cache +from pathlib import Path + +from sqlalchemy import cast, delete, func, or_, select + +from .config import get_settings +from .models import KnowledgeChunk, KnowledgeDocument + + +def split_pages(pages: list[dict], *, size=650, overlap=100): + for page in pages: + blocks = [block.strip() for block in page.get("text", "").split("\n\n") if block.strip()] + for table in page.get("tables", []): + blocks.append("表格: " + str(table.get("rows", table))) + for block in blocks: + start = 0 + while start < len(block): + yield {"page": page["page_number"], "section": page.get("section", ""), "text": block[start:start + size]} + if start + size >= len(block): + break + start += size - overlap + + +@lru_cache(maxsize=2) +def local_encoder(model_path: str): + path = Path(model_path) + if not path.is_dir() or not any(path.iterdir()): + raise RuntimeError(f"离线嵌入模型不存在: {path};请挂载本地模型包") + from sentence_transformers import SentenceTransformer + return SentenceTransformer(str(path), local_files_only=True, trust_remote_code=False) + + +def embed(texts: list[str], *, model_path: str | None = None): + if not texts: + return [] + model = local_encoder(model_path or str(get_settings().embedding_model_path)) + vectors = model.encode(texts, normalize_embeddings=True) + output = [] + for vector in vectors: + values = vector.tolist() + if len(values) > 1024: + raise RuntimeError("嵌入维度超过pgvector列容量1024") + output.append(values + [0.0] * (1024 - len(values))) + return output + + +def ingest(db, *, course_version_id: int, project_id: int | None, submission_file_id: int | None, + title: str, category: str, source_hash: str, pages: list[dict], embedder=embed): + existing = db.scalar(select(KnowledgeDocument).where(KnowledgeDocument.course_version_id == course_version_id, + KnowledgeDocument.project_id == project_id, KnowledgeDocument.submission_file_id == submission_file_id, + KnowledgeDocument.title == title)) + if existing and existing.source_hash == source_hash and existing.status == "ready": + return existing + if existing: + db.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == existing.id)) + db.delete(existing) + db.flush() + doc = KnowledgeDocument(course_version_id=course_version_id, project_id=project_id, submission_file_id=submission_file_id, + title=title, document_type=category, source_hash=source_hash, status="indexing") + db.add(doc) + db.flush() + chunks = list(split_pages(pages)) + vectors = embedder([chunk["text"] for chunk in chunks]) + for chunk, vector in zip(chunks, vectors): + db.add(KnowledgeChunk(document_id=doc.id, course_version_id=course_version_id, project_id=project_id, + document_type=category, page=chunk["page"], section=chunk["section"], text=chunk["text"], embedding=vector)) + doc.status = "ready" + db.commit() + return doc + + +def retrieve(db, *, course_version_id: int, project_id: int, query: str, category: str | None = None, + limit=10, embedder=embed, include_public=True, public_only=False): + """Hybrid retrieval with project isolation and an optional public course namespace.""" + scope = (KnowledgeChunk.project_id.is_(None) if public_only else + or_(KnowledgeChunk.project_id == project_id, KnowledgeChunk.project_id.is_(None)) if include_public else + KnowledgeChunk.project_id == project_id) + filters = [KnowledgeChunk.course_version_id == course_version_id, scope] + if category: + filters.append(KnowledgeChunk.document_type == category) + vector = embedder([query])[0] + if db.bind.dialect.name == "postgresql": + from pgvector.sqlalchemy import Vector + semantic = (1 - cast(KnowledgeChunk.embedding, Vector(1024)).cosine_distance(vector)).label("semantic") + lexical = func.ts_rank(func.to_tsvector("simple", KnowledgeChunk.text), func.plainto_tsquery("simple", query)).label("lexical") + rows = db.execute(select(KnowledgeChunk, semantic, lexical).where(*filters).order_by(semantic.desc(), lexical.desc()).limit(limit * 3)).all() + ranked = [(row[0], float(row.semantic or 0) * .7 + float(row.lexical or 0) * .3) for row in rows] + else: + chunks = db.scalars(select(KnowledgeChunk).where(*filters)).all() + terms = [query[i:i+2] for i in range(max(0, len(query)-1)) if query[i:i+2].strip()] + ranked = [] + for chunk in chunks: + semantic = sum(a*b for a, b in zip(chunk.embedding or [], vector)) + lexical = sum(term in chunk.text for term in terms) / max(len(terms), 1) + ranked.append((chunk, semantic * .7 + lexical * .3)) + ranked.sort(key=lambda pair: pair[1], reverse=True) + return [{"chunk_id": chunk.id, "document_id": chunk.document_id, "project_id": chunk.project_id, + "document_type": chunk.document_type, "page": chunk.page, "section": chunk.section, + "text": chunk.text, "rank": float(rank)} for chunk, rank in ranked[:limit]] diff --git a/backend/wisegrade/rules.py b/backend/wisegrade/rules.py new file mode 100644 index 0000000..6374206 --- /dev/null +++ b/backend/wisegrade/rules.py @@ -0,0 +1,59 @@ +from pydantic import BaseModel, Field, model_validator + + +class ScoreLevel(BaseModel): + min: float = Field(ge=0) + max: float = Field(ge=0) + description: str = Field(min_length=1) + + +class Criterion(BaseModel): + criterion_id: str = Field(min_length=1) + category: str + name: str = Field(min_length=1) + max_score: float = Field(gt=0) + description: str = Field(min_length=1) + linked_objectives: list[str] = Field(default_factory=list) + linked_outcomes: list[str] = Field(default_factory=list) + required_evidence: list[str] = Field(default_factory=list) + score_levels: list[ScoreLevel] = Field(default_factory=list) + + @model_validator(mode="after") + def check_levels(self): + if not self.score_levels: + raise ValueError(f"{self.criterion_id}: score levels are required") + ranges = sorted(self.score_levels, key=lambda x: x.min) + if ranges[0].min != 0 or ranges[-1].max != self.max_score: + raise ValueError(f"{self.criterion_id}: score levels must cover 0 through max_score") + for previous, current in zip(ranges, ranges[1:]): + if current.min > previous.max + 1 or current.min <= previous.max: + raise ValueError(f"{self.criterion_id}: score levels overlap or have gaps") + return self + + +class RubricData(BaseModel): + criteria: list[Criterion] + weights: dict[str, float] + + @model_validator(mode="after") + def check_weights(self): + expected = {c.category for c in self.criteria} + if not expected or set(self.weights) != expected or abs(sum(self.weights.values()) - 1) > 1e-8 or any(v < 0 for v in self.weights.values()): + raise ValueError("weights must cover all rubric categories and sum to 1") + for category in expected: + if abs(sum(c.max_score for c in self.criteria if c.category == category) - 100) > 1e-8: + raise ValueError(f"{category} criteria must total 100") + ids = [c.criterion_id for c in self.criteria] + if len(set(ids)) != len(ids): + raise ValueError("duplicate criterion_id") + return self + + +def totals(scores: dict[str, float | None], criteria: list[dict], weights: dict[str, float]): + grouped = {} + for category in weights: + group = [c for c in criteria if c["category"] == category] + values = [scores.get(c["criterion_id"]) for c in group] + grouped[category] = round(sum(values), 2) if values and all(v is not None for v in values) else None + overall = round(sum(grouped[k] * weights[k] for k in weights), 2) if all(v is not None for v in grouped.values()) else None + return {**grouped, "overall": overall} diff --git a/backend/wisegrade/scoring.py b/backend/wisegrade/scoring.py new file mode 100644 index 0000000..ea50198 --- /dev/null +++ b/backend/wisegrade/scoring.py @@ -0,0 +1,180 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed + +from sqlalchemy import select, update + +from .config import get_settings +from .db import SessionLocal +from .llm import ConsistencyResult, FactResult, ModelError, ScoreResult, validate_score +from .models import (CriterionScore, KnowledgeDocument, Project, ScoreEvidence, ScoringBatch, + SubmissionFile, now) +from .parsing import parse_document +from .rag import ingest, retrieve +from .storage import secure_path, sha256 +from .settings_store import client_for_batch + + +def parse_submission(db, file: SubmissionFile, *, embedder=None): + path = secure_path(file.relative_path) + if sha256(path) != file.sha256: + file.parse_status, file.parse_error = "changed", "源文件已变化,请重新扫描并确认导入" + db.commit() + raise ValueError(file.parse_error) + if file.parse_status == "ready" and file.parsed: + return file.parsed + settings = get_settings() + try: + parsed = parse_document(path, file.category, settings.processed_path / f"file-{file.id}") + project = db.get(Project, file.project_id) + kwargs = {"embedder": embedder} if embedder else {} + ingest(db, course_version_id=project.course_version_id, project_id=project.id, + submission_file_id=file.id, title=path.name, category=file.category, + source_hash=file.sha256, pages=parsed["pages"], **kwargs) + file.parsed, file.parse_status, file.parse_error = parsed, "ready", "" + db.commit() + return parsed + except Exception as exc: + file.parse_status, file.parse_error = "failed", str(exc)[:1000] + db.commit() + raise + + +def _project_facts(db, project, course_snapshot, client, *, retriever): + prompts = course_snapshot["prompts"] + categories = ("stage1", "stage2", "stage3", "final_report", "presentation") + snippets = [] + for category in categories: + snippets.extend(retriever(db, course_version_id=project.course_version_id, project_id=project.id, + category=category, query="项目名称 目标 指标 硬件 软件 实验 数据 成本 结论", limit=3, include_public=False)) + if not snippets: + return {} + facts = client.json_call(prompts["facts"], {"evidence": snippets}, FactResult).model_dump() + from .models import Student + student = db.get(Student, project.student_id) + warnings = [] + for key, expected in (("student_no", student.student_no), ("name", student.name), ("topic", project.topic)): + if facts.get(key) and facts[key] != expected: + warnings.append(f"模型抽取的{key}与教师确认信息不一致") + consistency = client.json_call(prompts["consistency"], {"facts": facts, "evidence": snippets}, ConsistencyResult) + known = {snippet["chunk_id"] for snippet in snippets} + for issue in consistency.issues: + if not set(issue.chunk_ids) <= known: + raise ModelError("一致性检查引用了未提供的证据") + project.facts = {**facts, "identity_warnings": warnings, + "consistency_issues": [issue.model_dump() for issue in consistency.issues]} + db.commit() + return project.facts + + +def _score_criterion(db, batch, project, criterion, client, *, retriever): + score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id, + CriterionScore.project_id == project.id, CriterionScore.criterion_id == criterion["criterion_id"])) + if score and score.status in ("scored", "reviewed"): + return score + if not score: + score = CriterionScore(batch_id=batch.id, project_id=project.id, criterion_id=criterion["criterion_id"]) + db.add(score) + db.flush() + category = "presentation" if criterion["criterion_id"] == "final.presentation" else ( + "final_report" if criterion["category"] == "final" else criterion["category"]) + files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id, + SubmissionFile.category == category, SubmissionFile.recognition_status == "recognized")).all() + ready = [file for file in files if file.parse_status == "ready"] + if len(files) != 1 or len(ready) != 1: + score.status, score.error = "unscorable", "材料缺失、重复、待确认或解析失败" + db.commit() + return score + query = criterion["description"] + " " + " ".join(criterion["required_evidence"]) + chunks = retriever(db, course_version_id=batch.course_version_id, project_id=project.id, + category=category, query=query, limit=10, include_public=False) + if not chunks: + score.status, score.error = "unscorable", "无可检索的材料证据" + db.commit() + return score + page_paths = {page["page_number"]: page.get("rendered_image_path") for page in ready[0].parsed["pages"]} + images = [page_paths[page] for page in dict.fromkeys(chunk["page"] for chunk in chunks[:2]) if page_paths.get(page)] + public_context = retriever(db, course_version_id=batch.course_version_id, project_id=project.id, + category=None, query=query, limit=6, public_only=True) + payload = {"criterion": criterion, "project_facts": project.facts, "course_context": public_context, + "evidence": chunks, + "required_output": {"criterion_id": "string", "score": "number", "max_score": "number", "reason": "string", + "evidence": [{"chunk_id": "integer", "page": "integer", "quote": "exact substring"}], + "missing_evidence": ["string"], "suggestion": "string", "confidence": "0..1"}} + result = validate_score(client.json_call(batch.course_snapshot["prompts"]["score"], payload, ScoreResult, images=images), criterion, chunks) + score.model_score, score.model_comment, score.confidence = result.score, result.reason, result.confidence + score.missing_evidence, score.suggestion, score.status, score.error = result.missing_evidence, result.suggestion, "scored", "" + for evidence in db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all(): + db.delete(evidence) + by_id = {chunk["chunk_id"]: chunk for chunk in chunks} + for evidence in result.evidence: + chunk = by_id[evidence.chunk_id] + doc = db.get(KnowledgeDocument, chunk["document_id"]) + db.add(ScoreEvidence(score_id=score.id, document_id=doc.id, submission_file_id=doc.submission_file_id, + page=evidence.page, quote=evidence.quote, image_path=page_paths.get(evidence.page))) + db.commit() + return score + + +def _run_project(batch_id, project_id, session_factory, client_factory, embedder, retriever): + failures = 0 + with session_factory() as db: + batch = db.get(ScoringBatch, batch_id) + project = db.get(Project, project_id) + client = client_factory(db, batch) + files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all() + for file in files: + if file.recognition_status == "recognized" and file.parse_status != "ready": + try: + parse_submission(db, file, embedder=embedder) + except Exception: + failures += 1 + try: + _project_facts(db, project, batch.course_snapshot, client, retriever=retriever) + except Exception as exc: + project.facts = {"error": str(exc)[:500]} + db.commit() + failures += 1 + for criterion in batch.rubric_snapshot["criteria"]: + try: + result = _score_criterion(db, batch, project, criterion, client, retriever=retriever) + if result.status not in ("scored", "reviewed"): + failures += 1 + except Exception as exc: + score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id, + CriterionScore.project_id == project.id, CriterionScore.criterion_id == criterion["criterion_id"])) + if not score: + score = CriterionScore(batch_id=batch.id, project_id=project.id, criterion_id=criterion["criterion_id"]) + db.add(score) + score.status, score.error = "failed", str(exc)[:500] + db.commit() + failures += 1 + db.execute(update(ScoringBatch).where(ScoringBatch.id == batch_id).values(progress_done=ScoringBatch.progress_done + 1)) + db.commit() + return failures + + +def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=client_for_batch, embedder=None, retriever=retrieve): + with session_factory() as db: + batch = db.get(ScoringBatch, batch_id) + if not batch: + raise ValueError("评分批次不存在") + project_ids = db.scalars(select(Project.id).where(Project.course_version_id == batch.course_version_id).order_by(Project.id)).all() + criteria = batch.rubric_snapshot["criteria"] + batch.status, batch.progress_total, batch.progress_done = "running", len(project_ids) * len(criteria), 0 + db.commit() + try: + client_factory(db, batch) + except Exception as exc: + batch.status, batch.error, batch.finished_at = "failed", str(exc), now() + db.commit() + return + failures = 0 + with ThreadPoolExecutor(max_workers=max(1, int(batch.model_config_snapshot.get("concurrency", 1)))) as pool: + futures = [pool.submit(_run_project, batch_id, project_id, session_factory, client_factory, embedder, retriever) for project_id in project_ids] + for future in as_completed(futures): + try: + failures += future.result() + except Exception as exc: + failures += 1 + batch.error = (batch.error + f" 项目任务失败: {type(exc).__name__}")[:1000] + batch.status, batch.finished_at = ("partial" if failures else "completed"), now() + db.commit() diff --git a/backend/wisegrade/seed.py b/backend/wisegrade/seed.py new file mode 100644 index 0000000..a43a37f --- /dev/null +++ b/backend/wisegrade/seed.py @@ -0,0 +1,93 @@ +from sqlalchemy import select + +from .models import Course, CourseVersion, RubricVersion +from .rules import RubricData +from .materialized import sync_course_entities, sync_rubric_entities + + +INDICATORS = [ + {"code": "2.3", "description": "基于文献研究获得复杂自动化工程问题的解决方案及替代方案"}, + {"code": "2.4", "description": "运用数理和自动化工程基础知识分析解决方案合理性"}, + {"code": "4.1", "description": "调研和分析本领域复杂工程问题的解决方案"}, + {"code": "4.2", "description": "选择研究路线并设计实验方案"}, + {"code": "4.3", "description": "构建实验系统、安全操作实验并分析解释数据"}, + {"code": "4.4", "description": "整理数据、分析结果并撰写实验报告"}, + {"code": "11.1", "description": "掌握工程管理原理和经济决策方法"}, + {"code": "11.2", "description": "理解自动化系统全周期成本并应用工程管理和经济决策"}, +] +OBJECTIVES = [ + {"code": "1", "name": "问题解决与研究能力", "indicators": ["2.3", "4.1"]}, + {"code": "2", "name": "知识运用与分析能力", "indicators": ["2.4", "11.1", "11.2"]}, + {"code": "3", "name": "实验设计与操作能力", "indicators": ["4.2", "4.3"]}, + {"code": "4", "name": "数据处理与报告撰写能力", "indicators": ["4.4"]}, +] +DIRECTIONS = [ + {"name": "赛教融合", "examples": ["西门子杯中智赛", "电子设计竞赛", "挑战杯-揭榜挂帅"]}, + {"name": "科教融合", "examples": ["智慧交通", "智慧电网", "智能飞行器"]}, + {"name": "产教融合", "examples": ["智能驾驶", "智能制造", "具身智能"]}, +] + + +def criterion(category, code, name, points, evidence, objective, outcomes, description=None, levels=None): + high = int(points * .75 + .5) + mid = int(points * .35 + .5) + levels = levels or [(high, points, "证据充分,分析准确且具体"), (mid, high - 1, "基本满足要求,但证据或分析不充分"), (0, mid - 1, "核心证据缺失或内容明显不足")] + return { + "criterion_id": f"{category}.{code}", "category": category, "name": name, + "max_score": points, "description": description or name, + "required_evidence": evidence, "linked_objectives": objective, + "linked_outcomes": outcomes, + "score_levels": [{"min": low, "max": high_, "description": text} for low, high_, text in levels], + } + + +INITIAL_CRITERIA = [ + criterion("stage1", "problem", "复杂工程问题和需求分析", 25, ["问题定义", "需求分析"], ["1"], ["2.3", "4.1"]), + criterion("stage1", "literature", "文献调研和技术现状总结", 20, ["参考文献", "技术现状"], ["1"], ["2.3", "4.1"]), + criterion("stage1", "solutions", "主方案和替代方案设计", 25, ["主方案", "替代方案", "方案比较"], ["1", "2"], ["2.3", "2.4"]), + criterion("stage1", "feasibility", "可行性、风险和合理性论证", 15, ["工程依据", "风险分析"], ["2"], ["2.4"]), + criterion("stage1", "management", "工程管理、进度和成本分析", 10, ["进度计划", "资源配置", "成本"], ["2"], ["11.1", "11.2"]), + criterion("stage1", "format", "报告完整性和规范性", 5, ["标题", "章节", "参考文献"], ["4"], ["4.4"]), + criterion("stage2", "architecture", "系统架构和实现思路", 20, ["系统架构", "实现思路"], ["2", "3"], ["2.4", "4.2"]), + criterion("stage2", "hardware", "硬件选型及选型依据", 20, ["硬件清单", "参数", "选型依据"], ["3"], ["4.2"]), + criterion("stage2", "interfaces", "系统框图、连接关系和接口设计", 20, ["系统框图", "接口", "连接关系"], ["3"], ["4.2", "4.3"]), + criterion("stage2", "experiment", "实验方案、测试方法和预期指标", 20, ["实验步骤", "测试方法", "预期指标"], ["3"], ["4.2", "4.3"]), + criterion("stage2", "safety", "安全风险和防护措施", 10, ["安全风险", "防护措施"], ["3"], ["4.3"]), + criterion("stage2", "progress", "阶段进展及下一阶段计划", 10, ["进展", "下一阶段计划"], ["2"], ["11.1"]), + criterion("stage3", "software", "软件逻辑和系统实现", 20, ["软件逻辑", "流程图"], ["3"], ["4.2", "4.3"]), + criterion("stage3", "code", "核心代码和功能说明", 20, ["核心代码", "功能说明"], ["3"], ["4.3"]), + criterion("stage3", "debug", "调试过程、问题分析和解决方法", 20, ["故障", "原因", "解决方法"], ["3"], ["4.3"]), + criterion("stage3", "data", "实验数据和初步分析", 20, ["实验数据", "分析"], ["4"], ["4.4"]), + criterion("stage3", "verification", "功能和性能指标验证", 15, ["功能测试", "性能指标"], ["3", "4"], ["4.3", "4.4"]), + criterion("stage3", "plan", "下一阶段计划及报告规范性", 5, ["计划", "章节"], ["2", "4"], ["11.1", "4.4"]), + criterion("final", "completion", "任务完成度", 20, ["设计任务", "实现结果", "功能验证"], ["2", "3"], ["2.4", "4.3"], levels=[(15, 20, "按时完成全部设计与实现任务,系统功能达标且完整"), (7, 14, "完成核心任务,系统功能基本达标"), (0, 6, "未完成核心任务或功能严重缺失")]), + criterion("final", "report", "报告规范性", 20, ["方案论证", "设计过程", "实验数据", "结果分析"], ["4"], ["4.4"], levels=[(15, 20, "结构完整、格式规范、逻辑严谨"), (7, 14, "结构基本完整,格式较规范"), (0, 6, "结构混乱,无法清晰呈现解决方案")]), + criterion("final", "presentation", "汇报答辩(仅按PPT内容评价)", 40, ["PPT逻辑", "解决方案", "成果展示"], ["2", "4"], ["2.4", "4.4"], description="只评价PPT或PPT PDF内容,不评价现场口头答辩", levels=[(30, 40, "PPT内容逻辑清晰,准确阐述方案及合理性"), (13, 29, "PPT基本阐述解决方案"), (0, 12, "PPT无法清晰表达解决方案")]), + criterion("final", "analysis", "结果分析", 20, ["实验数据", "误差分析", "优化方向", "结论"], ["4"], ["4.4"], levels=[(15, 20, "数据完整,分析深入,结论有依据"), (7, 14, "数据基本完整,分析较合理"), (0, 6, "数据缺失,结论缺乏依据")]), +] + +INITIAL_PROMPTS = { + "facts": "从证据中抽取项目事实。仅记录证据明确支持的事实;不确定字段留空。返回严格JSON。", + "score": "你是自动化专业综合实践教师。仅根据提供的评分规则和带页码证据评分。不得臆测缺失内容。PPT项只评价页面内容,不评价口头答辩。返回严格JSON。", + "consistency": "检查各阶段报告、最终报告和PPT中项目名称、技术路线、硬件、实验数据及成果的一致性。仅报告有证据的矛盾。返回严格JSON。", +} + +COURSE_DOCS = ["课程导学-自动化专业综合实践.pdf", "自动化专业综合实践三融合选题课程概述.pdf", "大作业评分表-自动化专业综合实践.docx", "阶段报告1模板.doc", "阶段报告2模板.doc", "阶段报告3模板.doc"] + + +def seed_initial(db): + if db.scalar(select(Course.id).limit(1)): + return + rubric = RubricData(criteria=INITIAL_CRITERIA, weights={"stage1": .1, "stage2": .1, "stage3": .1, "final": .7}) + course = Course(name="自动化专业综合实践", code="automation_comprehensive_practice") + db.add(course) + db.flush() + version = CourseVersion(course_id=course.id, version=1, title="初始课程版本", objectives=OBJECTIVES, indicators=INDICATORS, directions=DIRECTIONS, prompts=INITIAL_PROMPTS, templates=COURSE_DOCS, published=True) + db.add(version) + db.flush() + sync_course_entities(db, version) + rubric_version = RubricVersion(course_version_id=version.id, version=1, criteria=[c.model_dump() for c in rubric.criteria], weights=rubric.weights, published=True) + db.add(rubric_version) + db.flush() + sync_rubric_entities(db, rubric_version) + db.commit() diff --git a/backend/wisegrade/settings_store.py b/backend/wisegrade/settings_store.py new file mode 100644 index 0000000..c7dadfc --- /dev/null +++ b/backend/wisegrade/settings_store.py @@ -0,0 +1,65 @@ +import base64 +import hashlib +import json + +from cryptography.fernet import Fernet, InvalidToken + +from .config import get_settings +from .llm import DeepSeekClient +from .models import AppSetting + + +def _cipher(): + secret = get_settings().wisegrade_secret_key + if len(secret) < 32 or secret == "replace-with-a-random-32-character-secret": + raise ValueError("请设置至少32位随机WISEGRADE_SECRET_KEY后保存模型配置") + return Fernet(base64.urlsafe_b64encode(hashlib.sha256(secret.encode()).digest())) + + +def _get(db, key): + item = db.get(AppSetting, key) + if not item: + return None + try: + return _cipher().decrypt(item.encrypted_value).decode() + except InvalidToken as exc: + raise ValueError("已保存的模型设置无法解密,请恢复原WISEGRADE_SECRET_KEY") from exc + + +def _set(db, key, value): + item = db.get(AppSetting, key) + if not item: + item = AppSetting(key=key, encrypted_value=b"") + db.add(item) + item.encrypted_value = _cipher().encrypt(value.encode()) + + +def model_config(db): + settings = get_settings() + base = {"base_url": settings.deepseek_base_url, "model": settings.deepseek_model, + "timeout_seconds": settings.model_timeout_seconds, "concurrency": settings.model_concurrency, + "retries": settings.model_retries} + stored = _get(db, "model_config") if db.get(AppSetting, "model_config") else None + return {**base, **(json.loads(stored) if stored else {})} + + +def key_configured(db): + return bool(get_settings().deepseek_api_key or db.get(AppSetting, "api_key")) + + +def save_model_config(db, data: dict): + config = model_config(db) + config.update({key: data[key] for key in config if key in data}) + if "api_key" in data and data["api_key"]: + _set(db, "api_key", data["api_key"]) + _set(db, "model_config", json.dumps(config)) + db.commit() + return {**config, "api_key_configured": key_configured(db)} + + +def client_for_batch(db, batch): + stored_key = _get(db, "api_key") if db.get(AppSetting, "api_key") else None + config = batch.model_config_snapshot + return DeepSeekClient(base_url=config["base_url"], model=config["model"], + api_key=stored_key or get_settings().deepseek_api_key, + timeout=config["timeout_seconds"], retries=config["retries"]) diff --git a/backend/wisegrade/storage.py b/backend/wisegrade/storage.py new file mode 100644 index 0000000..1c604e2 --- /dev/null +++ b/backend/wisegrade/storage.py @@ -0,0 +1,149 @@ +import hashlib +import mimetypes +import re +from pathlib import Path, PurePosixPath +from zipfile import ZipFile +from xml.etree import ElementTree + +from .config import get_settings + + +CATEGORIES = ("stage1", "stage2", "stage3", "final_report", "presentation") +SUPPORTED = {".pdf", ".docx", ".pptx"} +NAMES = {"stage1": "阶段报告1", "stage2": "阶段报告2", "stage3": "阶段报告3", "final_report": "最终报告", "presentation": "汇报PPT"} + + +def secure_path(relative: str, *, root: Path | None = None, must_exist=True) -> Path: + root = (root or get_settings().inbox_path).resolve() + if not relative or "\\" in relative or "\x00" in relative: + raise ValueError("必须使用挂载目录内的相对路径") + path = PurePosixPath(relative) + if path.is_absolute() or any(part in ("..", "") for part in path.parts): + raise ValueError("路径超出挂载目录") + target = root.joinpath(*path.parts) + if any(p.is_symlink() for p in (target, *target.parents) if p != root and root in p.parents): + raise ValueError("不允许符号链接") + resolved = target.resolve(strict=must_exist) + if not resolved.is_relative_to(root): + raise ValueError("路径超出挂载目录") + return resolved + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def parse_student_folder(name: str): + parts = name.split("_", 2) + if len(parts) != 3 or not re.fullmatch(r"\d{5,20}", parts[0]) or not all(parts[1:]): + return {"student_no": "", "name": "", "topic": "", "status": "needs_confirmation"} + return {"student_no": parts[0], "name": parts[1].strip(), "topic": parts[2].strip(), "status": "recognized"} + + +def extract_identity(text: str): + patterns = { + "student_no": r"学号\s*[::]?\s*(\d{5,20})", + "name": r"姓名\s*[::]?\s*([^\s::]{2,10})", + "topic": r"(?:课题名称|项目名称|题目)\s*[::]?\s*([^\n\r]{3,100})", + } + return {key: (match.group(1).strip() if (match := re.search(pattern, text)) else "") for key, pattern in patterns.items()} + + +def first_text(path: Path) -> str: + try: + if path.suffix.lower() == ".pdf": + import pymupdf as fitz + with fitz.open(path) as doc: + return doc[0].get_text()[:6000] if len(doc) else "" + if path.suffix.lower() in {".docx", ".pptx"}: + part = "word/document.xml" if path.suffix.lower() == ".docx" else "ppt/slides/slide1.xml" + with ZipFile(path) as archive: + root = ElementTree.fromstring(archive.read(part)) + return " ".join(element.text or "" for element in root.iter() if element.tag.endswith("}t"))[:6000] + except Exception as exc: + raise ValueError(f"无法读取文件首页: {exc}") from exc + return "" + + +def classify(path: Path, text: str = ""): + suffix = path.suffix.lower() + if suffix not in SUPPORTED: + return "unknown", "unsupported" + def identify(value): + value = value.lower().replace(" ", "") + if any(term in value for term in ("汇报ppt", "汇报幻灯", "答辩ppt", "演示文稿", "presentation")): + return "presentation" + for digit, chinese, category in (("1", "一", "stage1"), ("2", "二", "stage2"), ("3", "三", "stage3")): + if any(term in value for term in (f"阶段报告{digit}", f"阶段报告{chinese}", f"第{chinese}阶段", f"阶段{digit}报告")): + return category + if any(term in value for term in ("最终报告", "期末报告", "综合实践报告", "结题报告")): + return "final_report" + return None + if suffix == ".pptx": + return "presentation", "recognized" + category = identify(path.stem) or identify(text[:600]) + if category: + return category, "needs_confirmation" if category == "presentation" and suffix == ".pdf" else "recognized" + return "unknown", "needs_confirmation" + + +def directories(relative: str = ".", *, root: Path | None = None): + path = secure_path(relative, root=root) + if not path.is_dir(): + raise ValueError("请选择目录") + base = (root or get_settings().inbox_path).resolve() + return [{"name": item.name, "relative_path": item.relative_to(base).as_posix()} for item in sorted(path.iterdir()) if item.is_dir() and not item.is_symlink()] + + +def scan(relative: str, *, root: Path | None = None): + base = (root or get_settings().inbox_path).resolve() + path = secure_path(relative, root=base) + if not path.is_dir(): + raise ValueError("请选择学生总目录") + projects = [] + for folder in sorted(path.iterdir()): + if not folder.is_dir() or folder.is_symlink(): + continue + info = parse_student_folder(folder.name) + files = [] + extracted = {} + warnings = [] + for candidate in sorted(folder.iterdir()): + if not candidate.is_file() or candidate.is_symlink(): + continue + if candidate.suffix.lower() not in SUPPORTED: + continue + error = "" + try: + text = first_text(candidate) + category, status = classify(candidate, text) + identity = extract_identity(text) + for key, value in identity.items(): + if value and key not in extracted: + extracted[key] = value + except ValueError as exc: + category, status, error = "unknown", "unparseable", str(exc) + stat = candidate.stat() + files.append({"relative_path": candidate.relative_to(base).as_posix(), "category": category, "status": status, + "size": stat.st_size, "mtime": stat.st_mtime, "sha256": sha256(candidate), + "mime_type": mimetypes.guess_type(candidate.name)[0] or "application/octet-stream", "error": error, + "first_text": text[:500] if not error else ""}) + for category in CATEGORIES: + matches = [file for file in files if file["category"] == category] + if len(matches) > 1: + for file in matches: + file["status"] = "duplicate" + for key, value in extracted.items(): + if info.get(key) and value != info[key]: + warnings.append(f"封面{key}“{value}”与目录名“{info[key]}”不一致") + if warnings: + info["status"] = "needs_confirmation" + info.update({"relative_dir": folder.relative_to(base).as_posix(), "files": files, + "detected_identity": extracted, "warnings": warnings, + "missing": [category for category in CATEGORIES if not any(file["category"] == category for file in files)]}) + projects.append(info) + return {"relative_dir": relative, "projects": projects}